From e2b4b1179b3442da82968ed00cb920ac31b1abd2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:02:56 +0300 Subject: [PATCH 01/99] Invite attribution: core client API, catalog entry and package guard Adds com.codename1.analytics.invite: mint an invite link, share it through the native share sheet, and on the invited device recover the invite that caused the install. Resolved attribution is written as persistent analytics dimensions, so every later event -- including the purchase event the framework already emits -- carries the campaign and the referrer. The package boundary is load-bearing, not cosmetic. The PlatformFeatureCatalog entry that buys the Play Install Referrer library also raises the application's minimum API level to 21, and the catalog matches on a package prefix. Keyed one package higher it would match com/codename1/analytics/Analytics, which nearly every application references, and put that dependency and that floor on all of them -- the DatabaseConfig failure AndroidGradleBuilder.usesClass records, which deleting the unused sources later does not undo. Two tests pin the boundary and were confirmed to fail when the prefix is widened. Analytics.java is not modified. resetClientId() does not clear custom dimensions, which is right for an application's own dimensions but would leave the referral dimensions behind and re-link a fresh pseudonymous id to the same inviter. InviteAttributionProvider observes the client id through the init callback Analytics already makes, and erases only the referral dimensions. --- .../invite/InstallReferrerCallback.java | 52 + .../invite/InstallReferrerSource.java | 54 + .../codename1/analytics/invite/Invite.java | 122 ++ .../analytics/invite/InviteAttribution.java | 168 +++ .../invite/InviteAttributionProvider.java | 89 ++ .../analytics/invite/InviteListener.java | 58 + .../analytics/invite/InviteRequest.java | 322 ++++ .../analytics/invite/InviteStore.java | 211 +++ .../codename1/analytics/invite/Invites.java | 1328 +++++++++++++++++ .../analytics/invite/package-info.java | 64 + .../codename1/components/InviteButton.java | 262 ++++ .../build/shared/PlatformFeatureCatalog.java | 29 + .../shared/PlatformFeatureCatalogTest.java | 54 + 13 files changed, 2813 insertions(+) create mode 100644 CodenameOne/src/com/codename1/analytics/invite/InstallReferrerCallback.java create mode 100644 CodenameOne/src/com/codename1/analytics/invite/InstallReferrerSource.java create mode 100644 CodenameOne/src/com/codename1/analytics/invite/Invite.java create mode 100644 CodenameOne/src/com/codename1/analytics/invite/InviteAttribution.java create mode 100644 CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java create mode 100644 CodenameOne/src/com/codename1/analytics/invite/InviteListener.java create mode 100644 CodenameOne/src/com/codename1/analytics/invite/InviteRequest.java create mode 100644 CodenameOne/src/com/codename1/analytics/invite/InviteStore.java create mode 100644 CodenameOne/src/com/codename1/analytics/invite/Invites.java create mode 100644 CodenameOne/src/com/codename1/analytics/invite/package-info.java create mode 100644 CodenameOne/src/com/codename1/components/InviteButton.java diff --git a/CodenameOne/src/com/codename1/analytics/invite/InstallReferrerCallback.java b/CodenameOne/src/com/codename1/analytics/invite/InstallReferrerCallback.java new file mode 100644 index 00000000000..61f6311a1a3 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/InstallReferrerCallback.java @@ -0,0 +1,52 @@ +/* + * 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.analytics.invite; + +/// Receives the answer from an [InstallReferrerSource]. +/// +/// Implemented by the framework; an application never implements this. +public interface InstallReferrerCallback { + /// Called with the raw referrer query string the store recorded at + /// install time. + /// + /// #### Parameters + /// + /// - `rawReferrer`: the undecoded referrer query string, may be empty + /// + /// - `referrerClickSeconds`: when the link was clicked, in seconds since + /// the epoch, or 0 when the store did not say + /// + /// - `installBeginSeconds`: when the install began, in seconds since the + /// epoch, or 0 when the store did not say + public void onReferrer(String rawReferrer, long referrerClickSeconds, + long installBeginSeconds); + + /// Called when no referrer can be obtained. This is the normal answer on + /// a device with no store client -- a sideload, an emulator without store + /// services, or a non-store distribution -- and is not an error. + /// + /// #### Parameters + /// + /// - `reason`: one of the `REASON_` constants on [Invites] + public void onUnavailable(String reason); +} diff --git a/CodenameOne/src/com/codename1/analytics/invite/InstallReferrerSource.java b/CodenameOne/src/com/codename1/analytics/invite/InstallReferrerSource.java new file mode 100644 index 00000000000..a8518f479e0 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/InstallReferrerSource.java @@ -0,0 +1,54 @@ +/* + * 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.analytics.invite; + +/// Reads the referrer the application store recorded when this application +/// was installed. This is the deterministic half of invite attribution: the +/// invite code makes the whole round trip through the store, so no matching +/// or guessing is involved. +/// +/// The Codename One build supplies the implementation on platforms that have +/// one and registers it through +/// [Invites#registerInstallReferrerSource] before the application starts. +/// Where none is registered -- the simulator, the desktop build, iOS, and any +/// Android device without store services -- [Invites] behaves exactly as it +/// does on a device that reports no referrer. +/// +/// An application does not implement this interface. +public interface InstallReferrerSource { + /// Whether this source can answer at all on the current device. + /// + /// #### Returns + /// + /// true when a store client is present + public boolean isSupported(); + + /// Asks for the install referrer. The answer arrives on the callback, + /// possibly asynchronously and possibly on another thread; [Invites] + /// marshals it back onto the EDT. + /// + /// #### Parameters + /// + /// - `callback`: receives the answer, never null + public void requestReferrer(InstallReferrerCallback callback); +} diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invite.java b/CodenameOne/src/com/codename1/analytics/invite/Invite.java new file mode 100644 index 00000000000..7f7198ec160 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/Invite.java @@ -0,0 +1,122 @@ +/* + * 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.analytics.invite; + +/// An invite that has been minted and is ready to share. Immutable; create one +/// with [Invites#create]. +/// +/// [#getUrl] is the link to send. It is usable the moment [Invites#create] +/// returns, including with no network at all, so the share sheet never waits +/// on a server. +public final class Invite { + private final String code; + private final String url; + private final String campaign; + private final String channel; + private final String payload; + private final long createdTimestamp; + private final boolean registered; + + Invite(String code, String url, String campaign, String channel, String payload, + long createdTimestamp, boolean registered) { + this.code = code; + this.url = url; + this.campaign = campaign; + this.channel = channel; + this.payload = payload; + this.createdTimestamp = createdTimestamp; + this.registered = registered; + } + + /// The opaque invite code. This identifies the invite and authorizes + /// nothing, so it is safe to print, log or show to the user. + /// + /// #### Returns + /// + /// the code, never null + public String getCode() { + return code; + } + + /// The link to share. + /// + /// #### Returns + /// + /// an absolute https url, never null + public String getUrl() { + return url; + } + + /// The campaign this invite belongs to, or null. + /// + /// #### Returns + /// + /// the campaign + public String getCampaign() { + return campaign; + } + + /// The channel this invite was minted for, or null. + /// + /// #### Returns + /// + /// the channel + public String getChannel() { + return channel; + } + + /// The application defined payload carried to the invited device, or null. + /// + /// #### Returns + /// + /// the payload + public String getPayload() { + return payload; + } + + /// When this invite was minted, in milliseconds since the epoch. + /// + /// #### Returns + /// + /// the creation time + public long getCreatedTimestamp() { + return createdTimestamp; + } + + /// Whether the link service has acknowledged this invite. An invite that + /// has not been acknowledged is still shareable and still attributes -- + /// registration is retried in the background -- so this is a diagnostic, + /// not a gate. + /// + /// #### Returns + /// + /// true once the server has acknowledged the invite + public boolean isRegistered() { + return registered; + } + + @Override + public String toString() { + return "Invite[" + code + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteAttribution.java b/CodenameOne/src/com/codename1/analytics/invite/InviteAttribution.java new file mode 100644 index 00000000000..1f9dce1eb64 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteAttribution.java @@ -0,0 +1,168 @@ +/* + * 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.analytics.invite; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/// The invite that caused this install or open. Immutable; delivered to an +/// [InviteListener]. +/// +/// Read [#getMatchType] before acting on this. A deterministic match came +/// through the store or from a code the user entered and is exact. A +/// [Invites#MATCH_FINGERPRINT] match is a statistical guess made on the +/// server, because the App Store carries no referrer of its own, and it is +/// occasionally wrong. Do not pay a referral bounty on a probabilistic match +/// without saying so. +public final class InviteAttribution { + private final String code; + private final String campaign; + private final String channel; + private final String payload; + private final String matchType; + private final double confidence; + private final boolean deferred; + private final long clickTimestamp; + private final long resolvedTimestamp; + private final Map parameters; + + InviteAttribution(String code, String campaign, String channel, String payload, + String matchType, double confidence, boolean deferred, long clickTimestamp, + long resolvedTimestamp, Map parameters) { + this.code = code; + this.campaign = campaign; + this.channel = channel; + this.payload = payload; + this.matchType = matchType; + this.confidence = confidence; + this.deferred = deferred; + this.clickTimestamp = clickTimestamp; + this.resolvedTimestamp = resolvedTimestamp; + Map copy = new LinkedHashMap(); + if (parameters != null) { + copy.putAll(parameters); + } + this.parameters = Collections.unmodifiableMap(copy); + } + + /// The invite code that was matched. + /// + /// #### Returns + /// + /// the code, never null + public String getCode() { + return code; + } + + /// The campaign the invite belonged to, or null when the server could not + /// be reached to look it up. + /// + /// #### Returns + /// + /// the campaign + public String getCampaign() { + return campaign; + } + + /// The channel the invite was sent through, or null. + /// + /// #### Returns + /// + /// the channel + public String getChannel() { + return channel; + } + + /// The payload the inviter attached, or null. + /// + /// #### Returns + /// + /// the payload + public String getPayload() { + return payload; + } + + /// How this attribution was established: [Invites#MATCH_DIRECT], + /// [Invites#MATCH_REFERRER] or [Invites#MATCH_FINGERPRINT]. + /// + /// #### Returns + /// + /// the match type, never null + public String getMatchType() { + return matchType; + } + + /// How much to trust this attribution, from 0 to 1. Both deterministic + /// match types report 1; a fingerprint match reports the server's score. + /// + /// #### Returns + /// + /// the confidence + public double getConfidence() { + return confidence; + } + + /// Whether this attribution explains the install itself, as opposed to a + /// link opened by someone who already had the application. + /// + /// #### Returns + /// + /// true when the invite caused the install + public boolean isDeferred() { + return deferred; + } + + /// When the link was clicked, in milliseconds since the epoch, or 0 when + /// unknown. + /// + /// #### Returns + /// + /// the click time + public long getClickTimestamp() { + return clickTimestamp; + } + + /// When this attribution was resolved, in milliseconds since the epoch. + /// + /// #### Returns + /// + /// the resolution time + public long getResolvedTimestamp() { + return resolvedTimestamp; + } + + /// The custom parameters the inviter attached. + /// + /// #### Returns + /// + /// an unmodifiable, insertion ordered map, never null + public Map getParameters() { + return parameters; + } + + @Override + public String toString() { + return "InviteAttribution[" + code + " " + matchType + "]"; + } +} diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java b/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java new file mode 100644 index 00000000000..82bb72e34a8 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java @@ -0,0 +1,89 @@ +/* + * 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.analytics.invite; + +import com.codename1.analytics.AbstractAnalyticsProvider; +import com.codename1.analytics.AnalyticsCapability; +import com.codename1.analytics.AnalyticsConsent; +import com.codename1.analytics.AnalyticsContext; +import com.codename1.io.Preferences; + +// The seam that lets invite attribution honour an erasure request and a +// consent change without any edit to the Analytics facade. +// +// Analytics already calls init(context) on every provider from +// resetClientId(), with a context carrying the NEW client id, and +// onConsentChanged(consent) on every provider from setConsent(). Registering +// a provider is therefore enough to observe both, and this class exists only +// to do that. +// +// It matters because Analytics.resetClientId() does not clear custom +// dimensions. That is the right default -- an application's own dimensions +// are its data and it never asked to lose them -- but the referral dimensions +// identify an inviter, so leaving them behind would re-link a freshly issued +// pseudonymous id to the same person and defeat the erasure. Widening +// resetClientId to clear everything would have taken the application's +// dimensions with it, so the scoped erase lives here instead. +// +// The provider reports no capabilities and does nothing with events; it is a +// listener wearing a provider's interface. +final class InviteAttributionProvider extends AbstractAnalyticsProvider { + // The last client id this provider saw. A change means resetClientId() + // ran, which is what an erasure request looks like from here. + private static final String PREF_LAST_CLIENT_ID = "cn1$inviteLastClientId"; + + @Override + public String getName() { + return "invite-attribution"; + } + + @Override + public void init(AnalyticsContext context) { + super.init(context); + String seen = context == null ? null : context.getClientId(); + if (seen == null) { + return; + } + String last = Preferences.get(PREF_LAST_CLIENT_ID, ""); + if (last == null || last.length() == 0) { + // First registration on this device. Record the baseline; this is + // a provider being added, not an identity being erased. + Preferences.set(PREF_LAST_CLIENT_ID, seen); + return; + } + if (!last.equals(seen)) { + Invites.eraseInternal(); + Preferences.set(PREF_LAST_CLIENT_ID, seen); + } + } + + @Override + public void onConsentChanged(AnalyticsConsent consent) { + Invites.onConsentChanged(consent != null && consent.isAnalytics()); + } + + @Override + public boolean supports(AnalyticsCapability capability) { + return false; + } +} diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteListener.java b/CodenameOne/src/com/codename1/analytics/invite/InviteListener.java new file mode 100644 index 00000000000..3f8d63e2858 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteListener.java @@ -0,0 +1,58 @@ +/* + * 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.analytics.invite; + +/// Receives the invite that caused this install, if there was one. +/// +/// Register with [Invites#setInviteListener] before calling +/// [Invites#checkForInvite]. Exactly one of the two methods is called per +/// install, on the EDT, and neither is called again on later launches -- the +/// answer is remembered. +/// +/// ```java +/// Invites.setInviteListener(new InviteListener() { +/// public void inviteReceived(InviteAttribution attribution) { +/// Dialog.show("Welcome", "Invited by " + attribution.getCampaign(), "OK", null); +/// } +/// +/// public void attributionUnavailable(String reason) { +/// } +/// }); +/// ``` +public interface InviteListener { + /// Called when this install is attributed to an invite. + /// + /// #### Parameters + /// + /// - `attribution`: the resolved attribution, never null + public void inviteReceived(InviteAttribution attribution); + + /// Called when no invite will be attributed to this install. This is the + /// ordinary outcome -- most installs are not invited -- so treat it as + /// information rather than as a failure. + /// + /// #### Parameters + /// + /// - `reason`: one of the `REASON_` constants on [Invites] + public void attributionUnavailable(String reason); +} diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteRequest.java b/CodenameOne/src/com/codename1/analytics/invite/InviteRequest.java new file mode 100644 index 00000000000..0a10364c46d --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteRequest.java @@ -0,0 +1,322 @@ +/* + * 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.analytics.invite; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/// Describes the invite to mint. Immutable; build one with [#create]. +/// +/// ```java +/// InviteRequest request = InviteRequest.create() +/// .campaign("spring") +/// .channel("whatsapp") +/// .title("Join me") +/// .description("I am using this and thought of you.") +/// .payload("room-42") +/// .build(); +/// ``` +/// +/// `title`, `description` and `imageUrl` drive the preview card the link +/// service renders, which is what makes a shared link look like an invitation +/// in a messaging application rather than a bare address. +/// +/// [Builder#build] validates and throws `IllegalArgumentException` naming the +/// offending field, so a mistake surfaces at the call you can see rather than +/// as a silently dropped value later. +public final class InviteRequest { + /// The longest accepted [Builder#payload]. + public static final int MAX_PAYLOAD_LENGTH = 512; + + /// The longest accepted [Builder#title]. + public static final int MAX_TITLE_LENGTH = 128; + + /// The longest accepted [Builder#description]. + public static final int MAX_DESCRIPTION_LENGTH = 256; + + /// The longest accepted [Builder#campaign] or [Builder#channel]. + public static final int MAX_TOKEN_LENGTH = 64; + + private final String campaign; + private final String channel; + private final String payload; + private final String title; + private final String description; + private final String imageUrl; + private final Map parameters; + + private InviteRequest(Builder b) { + this.campaign = b.campaign; + this.channel = b.channel; + this.payload = b.payload; + this.title = b.title; + this.description = b.description; + this.imageUrl = b.imageUrl; + this.parameters = Collections.unmodifiableMap( + new LinkedHashMap(b.parameters)); + } + + /// Starts building a request. + /// + /// #### Returns + /// + /// a new builder + public static Builder create() { + return new Builder(); + } + + /// The campaign, or null. + /// + /// #### Returns + /// + /// the campaign + public String getCampaign() { + return campaign; + } + + /// The channel, or null. + /// + /// #### Returns + /// + /// the channel + public String getChannel() { + return channel; + } + + /// The application defined payload, or null. + /// + /// #### Returns + /// + /// the payload + public String getPayload() { + return payload; + } + + /// The preview card title, or null. + /// + /// #### Returns + /// + /// the title + public String getTitle() { + return title; + } + + /// The preview card description, or null. + /// + /// #### Returns + /// + /// the description + public String getDescription() { + return description; + } + + /// The preview card image address, or null. + /// + /// #### Returns + /// + /// the image address + public String getImageUrl() { + return imageUrl; + } + + /// The custom parameters carried to the invited device. + /// + /// #### Returns + /// + /// an unmodifiable, insertion ordered map, never null + public Map getParameters() { + return parameters; + } + + /// Builds an [InviteRequest]. + public static final class Builder { + private String campaign; + private String channel; + private String payload; + private String title; + private String description; + private String imageUrl; + private final Map parameters = new LinkedHashMap(); + + Builder() { + } + + /// Groups this invite with others for reporting, for example a + /// seasonal push. Letters, digits, `.`, `_` and `-` only. + /// + /// #### Parameters + /// + /// - `campaign`: the campaign name + /// + /// #### Returns + /// + /// this builder + public Builder campaign(String campaign) { + this.campaign = campaign; + return this; + } + + /// How the invite is being sent, for example `sms` or `whatsapp`. + /// Letters, digits, `.`, `_` and `-` only. + /// + /// #### Parameters + /// + /// - `channel`: the channel name + /// + /// #### Returns + /// + /// this builder + public Builder channel(String channel) { + this.channel = channel; + return this; + } + + /// An application defined string handed back to the invited device, + /// for example the room or team the friend is being invited to. + /// + /// #### Parameters + /// + /// - `payload`: the payload + /// + /// #### Returns + /// + /// this builder + public Builder payload(String payload) { + this.payload = payload; + return this; + } + + /// The headline on the preview card the link shows in a messaging + /// application. + /// + /// #### Parameters + /// + /// - `title`: the title + /// + /// #### Returns + /// + /// this builder + public Builder title(String title) { + this.title = title; + return this; + } + + /// The body text on the preview card. + /// + /// #### Parameters + /// + /// - `description`: the description + /// + /// #### Returns + /// + /// this builder + public Builder description(String description) { + this.description = description; + return this; + } + + /// The image on the preview card, as an absolute address. + /// + /// #### Parameters + /// + /// - `url`: the image address + /// + /// #### Returns + /// + /// this builder + public Builder imageUrl(String url) { + this.imageUrl = url; + return this; + } + + /// Adds a custom parameter carried to the invited device. A null + /// value removes the key. + /// + /// #### Parameters + /// + /// - `key`: the parameter name + /// + /// - `value`: the parameter value, or null to remove it + /// + /// #### Returns + /// + /// this builder + public Builder param(String key, String value) { + if (key == null || key.length() == 0) { + return this; + } + if (value == null) { + parameters.remove(key); + } else { + parameters.put(key, value); + } + return this; + } + + /// Validates and builds the request. + /// + /// #### Returns + /// + /// the immutable request + public InviteRequest build() { + checkToken("campaign", campaign); + checkToken("channel", channel); + checkLength("payload", payload, MAX_PAYLOAD_LENGTH); + checkLength("title", title, MAX_TITLE_LENGTH); + checkLength("description", description, MAX_DESCRIPTION_LENGTH); + return new InviteRequest(this); + } + + private static void checkLength(String field, String value, int max) { + if (value != null && value.length() > max) { + throw new IllegalArgumentException( + field + " is longer than " + max + " characters"); + } + } + + // Campaign and channel end up both in a url and as an analytics + // dimension value, so they are restricted to characters that are safe + // unescaped in either. Checked here rather than silently rewritten, + // because a rewritten campaign name stops matching the one in the + // report. + private static void checkToken(String field, String value) { + if (value == null) { + return; + } + if (value.length() == 0 || value.length() > MAX_TOKEN_LENGTH) { + throw new IllegalArgumentException( + field + " must be 1 to " + MAX_TOKEN_LENGTH + " characters"); + } + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + boolean ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-'; + if (!ok) { + throw new IllegalArgumentException( + field + " may only contain letters, digits, '.', '_' and '-'"); + } + } + } + } +} diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java new file mode 100644 index 00000000000..eee5789c454 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java @@ -0,0 +1,211 @@ +/* + * 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.analytics.invite; + +import com.codename1.io.Log; +import com.codename1.io.Storage; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +// The three durable records invite attribution keeps on the device. +// +// Storage rather than Preferences, for the reason Continuity records: a +// Preferences write discards Storage.writeObject's boolean and the matching +// read comes back out of the same in-memory map, so a failed write is +// invisible. These records decide whether a user is attributed at all and +// whether the same install is attributed twice, so a silent write failure has +// to be observable. +// +// Every record is a flat map of strings. That is what survives Util's object +// serialization on every port without registering an Externalizable, and it +// keeps the format readable if it ever has to be inspected on a device. +final class InviteStore { + // The fingerprint and the code seen before attribution resolves. + static final String PENDING = "CN1$InvitePending"; + + // The resolved attribution, plus whether the application has been told. + static final String ATTRIBUTION = "CN1$InviteAttribution"; + + // Mint registrations that have not reached the link service yet. + static final String OUTBOX = "CN1$InviteOutbox"; + + // A viral inviter can mint faster than a bad network drains the queue. + // Dropping the oldest is right: an unregistered invite still attributes + // once the server sees the click, so the newest are the ones whose + // registration is still worth racing. + static final int MAX_OUTBOX = 32; + + private InviteStore() { + } + + static Map read(String record) { + try { + Storage s = Storage.getInstance(); + if (s == null || !s.exists(record)) { + return null; + } + Object o = s.readObject(record); + if (!(o instanceof Map)) { + return null; + } + Map out = new LinkedHashMap(); + Map raw = (Map) o; + for (Iterator i = raw.keySet().iterator(); i.hasNext();) { + Object k = i.next(); + Object v = raw.get(k); + if (k instanceof String && v instanceof String) { + out.put((String) k, (String) v); + } + } + return out; + } catch (Throwable t) { + Log.e(t); + return null; + } + } + + // Returns false when the record did not reach the disk. Callers that care + // about exactly-once behaviour check this; the rest may ignore it. + static boolean write(String record, Map values) { + try { + Storage s = Storage.getInstance(); + if (s == null) { + return false; + } + return s.writeObject(record, new LinkedHashMap(values)); + } catch (Throwable t) { + Log.e(t); + return false; + } + } + + static void delete(String record) { + try { + Storage s = Storage.getInstance(); + if (s != null && s.exists(record)) { + s.deleteStorageFile(record); + } + } catch (Throwable t) { + Log.e(t); + } + } + + static List readOutbox() { + List out = new ArrayList(); + try { + Storage s = Storage.getInstance(); + if (s == null || !s.exists(OUTBOX)) { + return out; + } + Object o = s.readObject(OUTBOX); + if (!(o instanceof List)) { + return out; + } + List raw = (List) o; + for (int i = 0; i < raw.size(); i++) { + Object v = raw.get(i); + if (v instanceof String) { + out.add((String) v); + } + } + } catch (Throwable t) { + Log.e(t); + } + return out; + } + + static void writeOutbox(List entries) { + try { + Storage s = Storage.getInstance(); + if (s == null) { + return; + } + List copy = new ArrayList(entries); + while (copy.size() > MAX_OUTBOX) { + copy.remove(0); + } + if (copy.isEmpty()) { + if (s.exists(OUTBOX)) { + s.deleteStorageFile(OUTBOX); + } + return; + } + s.writeObject(OUTBOX, copy); + } catch (Throwable t) { + Log.e(t); + } + } + + static String get(Map record, String key, String def) { + if (record == null) { + return def; + } + String v = record.get(key); + return v == null ? def : v; + } + + static long getLong(Map record, String key, long def) { + String v = get(record, key, null); + if (v == null || v.length() == 0) { + return def; + } + try { + return Long.parseLong(v); + } catch (NumberFormatException e) { + return def; + } + } + + static int getInt(Map record, String key, int def) { + return (int) getLong(record, key, def); + } + + static double getDouble(Map record, String key, double def) { + String v = get(record, key, null); + if (v == null || v.length() == 0) { + return def; + } + try { + return Double.parseDouble(v); + } catch (NumberFormatException e) { + return def; + } + } + + static boolean getBoolean(Map record, String key, boolean def) { + String v = get(record, key, null); + if (v == null) { + return def; + } + return "true".equals(v); + } + + static void put(Map record, String key, String value) { + if (value != null) { + record.put(key, value); + } + } +} diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java new file mode 100644 index 00000000000..a214f388228 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -0,0 +1,1328 @@ +/* + * 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.analytics.invite; + +import com.codename1.analytics.Analytics; +import com.codename1.analytics.AnalyticsConsent; +import com.codename1.analytics.ConsentMode; +import com.codename1.io.ConnectionRequest; +import com.codename1.io.JSONParser; +import com.codename1.io.Log; +import com.codename1.io.NetworkManager; +import com.codename1.io.Preferences; +import com.codename1.io.Util; +import com.codename1.share.ShareResult; +import com.codename1.share.ShareResultListener; +import com.codename1.ui.Display; +import com.codename1.ui.geom.Rectangle; +import com.codename1.util.Base64; +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/// Invite a friend, and follow the invitation through to what it caused. +/// +/// Mint an invite, share it, and on the friend's device recover the invite +/// that produced the install. Once attribution resolves it is written as +/// persistent analytics dimensions, so every later event -- including the +/// `purchase` event the framework already emits -- carries the campaign and +/// the referrer, and revenue per campaign comes out of the reports you have. +/// +/// ### Sending +/// +/// ```java +/// Invite invite = Invites.create(InviteRequest.create() +/// .campaign("spring") +/// .channel("share_sheet") +/// .build()); +/// Invites.share(invite, "Come and try this with me"); +/// ``` +/// +/// [#create] returns immediately and works with no network, so the share +/// sheet never waits on a server. Registration with the link service is +/// retried in the background. +/// +/// ### Receiving +/// +/// ```java +/// Invites.setInviteListener(new InviteListener() { +/// public void inviteReceived(InviteAttribution attribution) { +/// // attribution.getCode(), getCampaign(), getPayload() +/// } +/// +/// public void attributionUnavailable(String reason) { +/// } +/// }); +/// Invites.checkForInvite(); +/// ``` +/// +/// Call [#checkForInvite] from your `start()` method. It is a pull rather +/// than a callback on purpose: Android delivers a link by replacing the +/// activity intent and iOS by setting a property, and reading the launch +/// argument is the one path that behaves the same on both. +/// +/// ### Consent, and what is on the device before it +/// +/// Everything reported here is gated on the analytics consent category of +/// [Analytics], and nothing is transmitted until consent is granted. +/// +/// One thing does happen before consent: on first launch a coarse device +/// profile -- operating system version, hardware model, language, screen size +/// -- is written to local storage so that a deferred match is still possible +/// once consent arrives. It is never transmitted while consent is withheld, +/// and it is deleted outright if consent is refused. There is no alternative +/// that also works, because the window in which a deferred match can be made +/// closes within the hour, long before a typical consent prompt is answered. +/// [#setAttributionWindow] with `0` switches deferred attribution off +/// entirely. +/// +/// ### How exact the answer is +/// +/// [InviteAttribution#getMatchType] says how the attribution was made. +/// [#MATCH_DIRECT] and [#MATCH_REFERRER] are exact. [#MATCH_FINGERPRINT] is a +/// statistical match made on the server, used where the platform's store +/// carries no referrer, and it is occasionally wrong -- check +/// [InviteAttribution#getConfidence] and do not pay a referral bounty on it +/// without saying so. +public final class Invites { + /// Nothing has been attributed and nothing is outstanding. + public static final int STATE_NONE = 0; + + /// An invite is being resolved; the answer has not arrived yet. + public static final int STATE_PENDING = 1; + + /// This install has been attributed to an invite. + public static final int STATE_RESOLVED = 2; + + /// No invite will be attributed to this install. + public static final int STATE_NONE_FOUND = 3; + + /// Attribution was abandoned because analytics consent was refused. + public static final int STATE_DECLINED = 4; + + /// The link opened an application that was already installed. Exact. + public static final String MATCH_DIRECT = "direct"; + + /// The invite code made the whole trip through the application store and + /// came back verbatim. Exact. + public static final String MATCH_REFERRER = "referrer"; + + /// The server matched this install to a click statistically, because the + /// platform's store carries no referrer. Not exact. + public static final String MATCH_FINGERPRINT = "fingerprint"; + + /// No invite matched. The ordinary outcome for an uninvited install. + public static final String REASON_NO_MATCH = "no_match"; + + /// The attribution window closed before an answer arrived. + public static final String REASON_EXPIRED = "expired"; + + /// Analytics consent was refused, so attribution was abandoned. + public static final String REASON_CONSENT_DENIED = "consent_denied"; + + /// This platform cannot recover a deferred invite. + public static final String REASON_UNSUPPORTED = "unsupported"; + + /// The analytics category every invite event is reported under. + public static final String CATEGORY = "referral"; + + /// Dimension carrying the matched invite code. + public static final String DIMENSION_CODE = "cn1_invite_code"; + + /// Dimension carrying the campaign the invite belonged to. + public static final String DIMENSION_CAMPAIGN = "cn1_campaign"; + + /// Dimension carrying the channel the invite was sent through. + public static final String DIMENSION_CHANNEL = "cn1_channel"; + + /// Dimension carrying how the attribution was made. + public static final String DIMENSION_MATCH = "cn1_invite_match"; + + /// The default attribution window: how long after a first launch a + /// deferred invite may still be resolved. + public static final long DEFAULT_ATTRIBUTION_WINDOW = 7L * 24L * 60L * 60L * 1000L; + + static final String[] DIMENSIONS = { + DIMENSION_CODE, DIMENSION_CAMPAIGN, DIMENSION_CHANNEL, DIMENSION_MATCH + }; + + private static final String DEFAULT_BASE_URL = "https://cloud.codenameone.com"; + private static final String PATH_MINT = "/api/v2/analytics/invites"; + private static final String PATH_CLAIM = "/api/v2/analytics/invites/claim"; + private static final String PATH_MATCH = "/api/v2/analytics/invites/match"; + + private static final String PREF_SLUG = "cn1$inviteSlug"; + private static final String PREF_CONSUMED_ARG = "cn1$inviteConsumedArg"; + + // The referrer key the link service puts on the store url. Compared with + // equals and never case folded: String.toLowerCase is locale sensitive and + // has no root-locale overload here, so under a Turkish default locale the + // 'i' folds to a dotless i and the key silently stops matching on exactly + // the devices nobody can reproduce on. + private static final String REFERRER_KEY = "cn1_invite"; + + private static final int MAX_ATTEMPTS = 5; + + private static String linkBase; + private static long attributionWindow = DEFAULT_ATTRIBUTION_WINDOW; + private static boolean reattribution; + private static InviteListener listener; + private static InstallReferrerSource referrerSource; + private static InviteAttribution resolved; + private static int state = -1; + private static boolean deliveredThisRun; + private static boolean deferredStarted; + + private Invites() { + } + + /// Registers the platform hook that reads the application store's install + /// referrer. The Codename One build calls this before the application + /// starts on platforms that have one; an application does not. + /// + /// #### Parameters + /// + /// - `source`: the platform source, or null to remove it + public static void registerInstallReferrerSource(InstallReferrerSource source) { + referrerSource = source; + } + + // ---- sending --------------------------------------------------------- + + /// Mints an invite and returns it immediately. + /// + /// This never blocks and never fails for want of a network. The code is + /// generated on the device, so [Invite#getUrl] is usable at once; + /// registration with the link service is queued and retried until it + /// lands. A link clicked before that registration arrives is still + /// attributed, because the server records the click against the code and + /// joins it when the registration turns up. + /// + /// #### Parameters + /// + /// - `request`: what to mint, must not be null + /// + /// #### Returns + /// + /// the invite, never null + public static Invite create(InviteRequest request) { + if (request == null) { + throw new IllegalArgumentException("request is null"); + } + ensureProvider(); + String code = newCode(); + long now = System.currentTimeMillis(); + Invite invite = new Invite(code, buildUrl(code), request.getCampaign(), + request.getChannel(), request.getPayload(), now, false); + queueRegistration(invite, request); + Map p = new HashMap(); + p.put("invite_code", code); + putIfSet(p, "campaign", request.getCampaign()); + putIfSet(p, "channel", request.getChannel()); + Analytics.autoEvent("invite_created", CATEGORY, p); + flush(); + return invite; + } + + /// Shares an invite through the native share sheet. + /// + /// #### Parameters + /// + /// - `invite`: the invite to share, must not be null + /// + /// - `message`: text placed before the link, or null for the link alone + public static void share(Invite invite, String message) { + share(invite, message, null, null); + } + + /// Shares an invite through the native share sheet and reports the + /// outcome. + /// + /// The invite funnel's `invite_shared` event is emitted from here, and + /// only when the platform confirms the user actually shared -- a + /// dismissed sheet reports `invite_share_dismissed` instead. That is what + /// makes the "shared" number a measurement rather than an assumption. + /// + /// #### Parameters + /// + /// - `invite`: the invite to share, must not be null + /// + /// - `message`: text placed before the link, or null for the link alone + /// + /// - `sourceRect`: popover anchor hint, may be null + /// + /// - `resultListener`: receives the share outcome, may be null + public static void share(Invite invite, String message, Rectangle sourceRect, + ShareResultListener resultListener) { + if (invite == null) { + throw new IllegalArgumentException("invite is null"); + } + Display d = Display.getInstance(); + if (d == null) { + return; + } + String text = message == null || message.length() == 0 + ? invite.getUrl() : message + " " + invite.getUrl(); + d.share(text, null, null, sourceRect, chain(invite, resultListener)); + } + + /// Reports the outcome of a share your application performed itself, + /// rather than through [#share]. Use this when the invite goes out + /// through your own user interface -- a contact picker, a message + /// composer, a copy-link button -- so the funnel still records whether it + /// was really sent. + /// + /// `invite_shared` is emitted only when `result` says the user actually + /// shared; a dismissed sheet reports `invite_share_dismissed` instead. + /// Calling this is optional and calling it twice for one share double + /// counts, so call it once, from the share callback. + /// + /// #### Parameters + /// + /// - `invite`: the invite that was shared, must not be null + /// + /// - `result`: the outcome the platform reported, may be null + public static void reportShareResult(Invite invite, ShareResult result) { + if (invite == null || result == null) { + return; + } + Map p = new HashMap(); + p.put("invite_code", invite.getCode()); + putIfSet(p, "campaign", invite.getCampaign()); + putIfSet(p, "channel", invite.getChannel()); + if (result.isSharedTo()) { + // May legitimately be null on older Android and the web share + // api. Omitted rather than filled with a placeholder, so the + // console's unknown rate stays honest. + putIfSet(p, "target", result.getPackageName()); + Analytics.autoEvent("invite_shared", CATEGORY, p); + } else if (result.isDismissed()) { + Analytics.autoEvent("invite_share_dismissed", CATEGORY, p); + } + } + + // Wraps the caller's listener so the funnel sees the real outcome and the + // caller still gets theirs. + private static ShareResultListener chain(final Invite invite, + final ShareResultListener delegate) { + return new ShareResultListener() { + @Override + public void onResult(ShareResult result) { + try { + reportShareResult(invite, result); + } catch (Throwable t) { + Log.e(t); + } + if (delegate != null) { + delegate.onResult(result); + } + } + }; + } + + // ---- receiving ------------------------------------------------------- + + /// Registers the listener that receives the invite behind this install. + /// + /// An answer that arrived before the listener was registered -- which + /// happens routinely on a cold launch from a link, because the platform + /// delivers the link before the application starts -- is delivered as + /// soon as this is called. + /// + /// #### Parameters + /// + /// - `l`: the listener, or null to remove it + public static void setInviteListener(InviteListener l) { + listener = l; + ensureProvider(); + if (l != null) { + deliverPending(); + } + } + + /// The registered listener, or null. + /// + /// #### Returns + /// + /// the listener + public static InviteListener getInviteListener() { + return listener; + } + + /// Looks for an invite: first in the launch argument, then, when this + /// looks like a fresh install, by asking the link service. + /// + /// Safe and cheap to call on every start; it will not attribute twice and + /// will not report twice. + /// + /// #### Returns + /// + /// true when the launch argument carried an invite link + public static boolean checkForInvite() { + ensureProvider(); + deliverPending(); + String appArg = null; + Display d = Display.getInstance(); + if (d != null) { + appArg = d.getProperty("AppArg", null); + } + boolean consumed = false; + if (appArg != null && appArg.length() > 0 + && !appArg.equals(Preferences.get(PREF_CONSUMED_ARG, ""))) { + consumed = handleUrl(appArg); + if (consumed) { + Preferences.set(PREF_CONSUMED_ARG, appArg); + } + } + if (!consumed) { + beginDeferred(); + } + return consumed; + } + + /// Offers a url to the invite machinery directly, for applications that + /// consume the launch argument themselves or route it through + /// `com.codename1.router`. + /// + /// #### Parameters + /// + /// - `url`: the url to inspect, may be null + /// + /// #### Returns + /// + /// true when the url carried an invite code + public static boolean handleUrl(String url) { + String code = extractCode(url); + if (code == null) { + return false; + } + ensureProvider(); + if (getState() == STATE_RESOLVED && !reattribution) { + // Already attributed. Re-engagement is worth counting, but + // rewriting the cohort mid-stream would make lifetime value per + // referrer unjoinable, so first touch stands. + Map p = new HashMap(); + p.put("invite_code", code); + p.put("match", MATCH_DIRECT); + Analytics.autoEvent("invite_opened", CATEGORY, p); + return true; + } + Map pending = pendingRecord(); + pending.put("code", code); + InviteStore.write(InviteStore.PENDING, pending); + setState(STATE_PENDING); + claim(code, "universal_link", "", MATCH_DIRECT, false); + return true; + } + + /// The attribution for this install, or null when there is none yet. + /// + /// #### Returns + /// + /// the attribution + public static InviteAttribution getAttribution() { + if (resolved == null) { + resolved = readAttribution(); + } + return resolved; + } + + /// Where attribution has got to: one of the `STATE_` constants. + /// + /// #### Returns + /// + /// the current state + public static int getState() { + if (state < 0) { + if (getAttribution() != null) { + state = STATE_RESOLVED; + } else { + Map pending = InviteStore.read(InviteStore.PENDING); + state = pending == null ? STATE_NONE + : InviteStore.getInt(pending, "state", STATE_PENDING); + } + } + return state; + } + + // ---- closing the funnel --------------------------------------------- + + /// Reports that the invited user reached the outcome the invite existed + /// for -- signed up, joined the room, completed onboarding. No-op unless + /// this install was attributed. + /// + /// #### Parameters + /// + /// - `action`: what the user did + public static void conversion(String action) { + conversion(action, 0d, null); + } + + /// Reports a conversion carrying a value, so revenue can be attributed to + /// the campaign and the referrer. No-op unless this install was + /// attributed. + /// + /// #### Parameters + /// + /// - `action`: what the user did + /// + /// - `value`: the value of the conversion + /// + /// - `currency`: the currency code, or null + public static void conversion(String action, double value, String currency) { + InviteAttribution a = getAttribution(); + if (a == null) { + return; + } + Map p = new HashMap(); + p.put("invite_code", a.getCode()); + putIfSet(p, "campaign", a.getCampaign()); + putIfSet(p, "channel", a.getChannel()); + putIfSet(p, "action", action); + if (value != 0d) { + p.put("value", new Double(value)); + } + putIfSet(p, "currency", currency); + Analytics.autoEvent("invite_converted", CATEGORY, p); + } + + // ---- configuration --------------------------------------------------- + + /// Points the invite machinery at a different link service. Defaults to + /// the Codename One cloud, honouring the `cloudServerURL` display + /// property. + /// + /// #### Parameters + /// + /// - `url`: the base address, with no trailing path + public static void setLinkBase(String url) { + linkBase = url; + } + + /// The link service base address in use. + /// + /// #### Returns + /// + /// the base address, never null + public static String getLinkBase() { + if (linkBase != null && linkBase.length() > 0) { + return trimSlash(linkBase); + } + Display d = Display.getInstance(); + String base = d == null ? DEFAULT_BASE_URL + : d.getProperty("cloudServerURL", DEFAULT_BASE_URL); + if (base == null || base.length() == 0) { + base = DEFAULT_BASE_URL; + } + return trimSlash(base); + } + + /// How long after a first launch a deferred invite may still be + /// resolved. Clamped to at most 30 days. Zero switches deferred + /// attribution off, which is the supported way to ship without the + /// statistical match. + /// + /// #### Parameters + /// + /// - `millis`: the window in milliseconds + public static void setAttributionWindow(long millis) { + long max = 30L * 24L * 60L * 60L * 1000L; + if (millis < 0) { + millis = 0; + } + if (millis > max) { + millis = max; + } + attributionWindow = millis; + } + + /// The attribution window in milliseconds. + /// + /// #### Returns + /// + /// the window + public static long getAttributionWindow() { + return attributionWindow; + } + + /// Whether a later invite replaces an earlier attribution. Off by + /// default: first touch stands, so a user's cohort does not change + /// underneath the reports. + /// + /// #### Parameters + /// + /// - `value`: true for last touch + public static void setReattribution(boolean value) { + reattribution = value; + } + + /// Whether last touch attribution is enabled. + /// + /// #### Returns + /// + /// true when a later invite replaces an earlier one + public static boolean isReattribution() { + return reattribution; + } + + // ---- housekeeping ---------------------------------------------------- + + /// Retries anything queued: unregistered invites, and an outstanding + /// deferred match. Called for you on the paths that matter; exposed for + /// an application that knows it has just regained connectivity. + public static void flush() { + drainOutbox(); + } + + /// Forgets every trace of invite attribution on this device: the pending + /// fingerprint, the resolved attribution and the referral dimensions. + /// + /// [Analytics#resetClientId] triggers this for you, because an erasure + /// that left the referral dimensions behind would re-link the fresh + /// identity to the same inviter. + public static void reset() { + InviteStore.delete(InviteStore.PENDING); + InviteStore.delete(InviteStore.ATTRIBUTION); + InviteStore.delete(InviteStore.OUTBOX); + Preferences.delete(PREF_CONSUMED_ARG); + clearDimensions(); + resolved = null; + state = STATE_NONE; + deliveredThisRun = false; + deferredStarted = false; + } + + // Package private: the analytics provider hook calls this when the client + // id changes underneath us, which is what an erasure request looks like. + static void eraseInternal() { + reset(); + } + + // Package private: called from the provider when consent changes. + static void onConsentChanged(boolean allowed) { + if (allowed) { + if (getState() == STATE_PENDING) { + deferredStarted = false; + beginDeferred(); + } else if (getState() == STATE_RESOLVED) { + // Re-granting restores the dimensions from the record we kept, + // without re-reporting the install or telling the app again. + InviteAttribution a = getAttribution(); + if (a != null) { + writeDimensions(a); + } + } + drainOutbox(); + return; + } + // Refused. A device profile held for a match that is no longer + // permitted has no reason to exist, so it goes now rather than at the + // end of the window. + if (getState() == STATE_PENDING) { + InviteStore.delete(InviteStore.PENDING); + setState(STATE_DECLINED); + notifyUnavailable(REASON_CONSENT_DENIED); + } + clearDimensions(); + } + + // ---- internals ------------------------------------------------------- + + // Registers the provider that gives us the erasure and consent hooks. + // Analytics.clearProviders() can drop it, so this re-registers on facade + // entry rather than only once; the provider list is a handful of entries. + private static void ensureProvider() { + try { + List providers = Analytics.getProviders(); + for (int i = 0; i < providers.size(); i++) { + if (providers.get(i) instanceof InviteAttributionProvider) { + return; + } + } + Analytics.addProvider(new InviteAttributionProvider()); + } catch (Throwable t) { + Log.e(t); + } + } + + // The ordinary gate: what Analytics itself would allow. + private static boolean allowed() { + AnalyticsConsent c = Analytics.getConsent(); + if (Analytics.getConsentMode() == ConsentMode.OPT_OUT) { + return c == null || c.isAnalytics(); + } + return c != null && c.isAnalytics(); + } + + // The strict gate, for the statistical match only. Opt-out mode reports + // permission with no user choice on record -- the deprecated + // AnalyticsService forces exactly that for legacy callers -- and sending + // a device profile under an implicit allow is not defensible. Everything + // else uses allowed(). + private static boolean explicitlyAllowed() { + AnalyticsConsent c = Analytics.getConsent(); + return c != null && c.isAnalytics(); + } + + private static String newCode() { + byte[] raw = new byte[16]; + try { + Util.secureRandomBytes(raw); + } catch (Throwable t) { + // A code identifies an invite and authorizes nothing, so a weaker + // source degrades uniqueness, not security. Reported once rather + // than failing the invite. + Log.e(t); + java.util.Random r = new java.util.Random(); + r.nextBytes(raw); + } + String s = Base64.encodeUrlSafe(raw); + int pad = s.indexOf('='); + if (pad > 0) { + s = s.substring(0, pad); + } + return s; + } + + private static String buildUrl(String code) { + String slug = Preferences.get(PREF_SLUG, ""); + if (slug != null && slug.length() > 0) { + return getLinkBase() + "/i/" + slug + "/" + code; + } + // No slug known yet -- the very first invite on a fresh install with + // no network. The bare form still redirects correctly; the server + // hands back the slugged url on registration and later invites use it. + return getLinkBase() + "/i/" + code; + } + + // Recognises our own link, or any url carrying the referrer key. The host + // is compared with regionMatches rather than folded, because case folding + // a protocol token is locale sensitive here. + static String extractCode(String url) { + if (url == null || url.length() == 0) { + return null; + } + int q = url.indexOf('?'); + if (q >= 0) { + String code = codeFromQuery(url.substring(q + 1)); + if (code != null) { + return code; + } + } + String host = hostOf(url); + if (host == null) { + return null; + } + String base = getLinkBase(); + String expected = hostOf(base); + if (expected == null || !host.regionMatches(true, 0, expected, 0, expected.length()) + || host.length() != expected.length()) { + return null; + } + String path = url; + int schemeEnd = path.indexOf("://"); + if (schemeEnd >= 0) { + int slash = path.indexOf('/', schemeEnd + 3); + if (slash < 0) { + return null; + } + path = path.substring(slash); + } + if (q >= 0) { + int rel = path.indexOf('?'); + if (rel >= 0) { + path = path.substring(0, rel); + } + } + if (!path.startsWith("/i/")) { + return null; + } + String rest = path.substring(3); + while (rest.endsWith("/")) { + rest = rest.substring(0, rest.length() - 1); + } + if (rest.length() == 0) { + return null; + } + int slash = rest.lastIndexOf('/'); + String code = slash < 0 ? rest : rest.substring(slash + 1); + if (slash > 0) { + // Remember the slug so later invites mint the precise form. + Preferences.set(PREF_SLUG, rest.substring(0, slash)); + } + return code.length() == 0 ? null : code; + } + + // Parses a referrer or query string for the invite key. Split on the + // FIRST '=' only, and compare the key with equals -- never a case fold. + static String codeFromQuery(String query) { + if (query == null || query.length() == 0) { + return null; + } + int start = 0; + while (start <= query.length()) { + int amp = query.indexOf('&', start); + String pair = amp < 0 ? query.substring(start) : query.substring(start, amp); + int eq = pair.indexOf('='); + if (eq > 0) { + String key = pair.substring(0, eq); + if (REFERRER_KEY.equals(key)) { + String value = pair.substring(eq + 1); + try { + value = Util.decode(value, "UTF-8", true); + } catch (Throwable t) { + Log.e(t); + } + return value.length() == 0 ? null : value; + } + } + if (amp < 0) { + break; + } + start = amp + 1; + } + return null; + } + + private static String hostOf(String url) { + int schemeEnd = url.indexOf("://"); + if (schemeEnd < 0) { + return null; + } + int start = schemeEnd + 3; + int end = url.length(); + for (int i = start; i < url.length(); i++) { + char c = url.charAt(i); + if (c == '/' || c == '?' || c == '#' || c == ':') { + end = i; + break; + } + } + return end > start ? url.substring(start, end) : null; + } + + private static String trimSlash(String base) { + while (base.endsWith("/")) { + base = base.substring(0, base.length() - 1); + } + return base; + } + + private static void putIfSet(Map p, String key, String value) { + if (value != null && value.length() > 0) { + p.put(key, value); + } + } + + private static void setState(int s) { + state = s; + Map pending = InviteStore.read(InviteStore.PENDING); + if (pending != null) { + pending.put("state", String.valueOf(s)); + InviteStore.write(InviteStore.PENDING, pending); + } + } + + private static Map pendingRecord() { + Map pending = InviteStore.read(InviteStore.PENDING); + if (pending != null) { + return pending; + } + pending = new LinkedHashMap(); + long now = System.currentTimeMillis(); + pending.put("firstLaunch", String.valueOf(now)); + pending.put("expiresAt", String.valueOf(now + attributionWindow)); + pending.put("attempts", "0"); + pending.put("state", String.valueOf(STATE_PENDING)); + Display d = Display.getInstance(); + if (d != null) { + InviteStore.put(pending, "platform", d.getPlatformName()); + InviteStore.put(pending, "osVersion", d.getProperty("OSVer", "")); + InviteStore.put(pending, "deviceModel", + d.getProperty("DeviceHardwareModel", d.getProperty("DeviceName", ""))); + pending.put("screenWidth", String.valueOf(d.getDisplayWidth())); + pending.put("screenHeight", String.valueOf(d.getDisplayHeight())); + } + Locale loc = Locale.getDefault(); + InviteStore.put(pending, "locale", loc == null ? "" : loc.toString()); + InviteStore.write(InviteStore.PENDING, pending); + return pending; + } + + private static void beginDeferred() { + if (deferredStarted) { + return; + } + int s = getState(); + if (s == STATE_RESOLVED || s == STATE_NONE_FOUND || s == STATE_DECLINED) { + return; + } + if (attributionWindow == 0) { + setState(STATE_NONE_FOUND); + notifyUnavailable(REASON_UNSUPPORTED); + return; + } + Map pending = pendingRecord(); + long expires = InviteStore.getLong(pending, "expiresAt", 0); + if (expires > 0 && System.currentTimeMillis() > expires) { + InviteStore.delete(InviteStore.PENDING); + state = STATE_NONE_FOUND; + notifyUnavailable(REASON_EXPIRED); + return; + } + if (InviteStore.getInt(pending, "attempts", 0) >= MAX_ATTEMPTS) { + state = STATE_NONE_FOUND; + notifyUnavailable(REASON_NO_MATCH); + return; + } + setState(STATE_PENDING); + if (!allowed()) { + // Nothing leaves the device. The record stays; onConsentChanged + // restarts this the moment consent arrives. + return; + } + deferredStarted = true; + String code = InviteStore.get(pending, "code", null); + if (code != null && code.length() > 0) { + claim(code, "universal_link", "", MATCH_DIRECT, false); + return; + } + InstallReferrerSource source = referrerSource; + if (source != null && safeSupported(source)) { + requestReferrer(source); + return; + } + requestMatch(pending); + } + + private static boolean safeSupported(InstallReferrerSource source) { + try { + return source.isSupported(); + } catch (Throwable t) { + Log.e(t); + return false; + } + } + + private static void requestReferrer(InstallReferrerSource source) { + try { + source.requestReferrer(new InstallReferrerCallback() { + @Override + public void onReferrer(final String rawReferrer, final long clickSeconds, + final long beginSeconds) { + onEdt(new Runnable() { + public void run() { + String code = codeFromQuery(rawReferrer); + if (code == null) { + fallBackToMatch(); + return; + } + claim(code, "install_referrer", + rawReferrer == null ? "" : rawReferrer, + MATCH_REFERRER, true); + } + }); + } + + @Override + public void onUnavailable(String reason) { + onEdt(new Runnable() { + public void run() { + fallBackToMatch(); + } + }); + } + }); + } catch (Throwable t) { + Log.e(t); + fallBackToMatch(); + } + } + + // No store referrer: either the device has no store client, or this was + // an organic install. Either way the statistical match is the only path + // left, and it is the same one iOS always takes. + private static void fallBackToMatch() { + Map pending = InviteStore.read(InviteStore.PENDING); + if (pending == null) { + return; + } + requestMatch(pending); + } + + private static void onEdt(Runnable r) { + Display d = Display.getInstance(); + if (d == null) { + r.run(); + return; + } + if (d.isEdt()) { + r.run(); + } else { + d.callSerially(r); + } + } + + private static void requestMatch(Map pending) { + if (!explicitlyAllowed()) { + return; + } + bumpAttempts(pending); + Map body = identity(); + body.put("platform", InviteStore.get(pending, "platform", "")); + body.put("osVersion", InviteStore.get(pending, "osVersion", "")); + body.put("deviceModel", InviteStore.get(pending, "deviceModel", "")); + body.put("locale", InviteStore.get(pending, "locale", "")); + body.put("screenWidth", new Integer(InviteStore.getInt(pending, "screenWidth", 0))); + body.put("screenHeight", new Integer(InviteStore.getInt(pending, "screenHeight", 0))); + post(getLinkBase() + PATH_MATCH, body, MATCH_FINGERPRINT, true); + } + + private static void claim(String code, String source, String rawReferrer, + final String matchType, final boolean deferred) { + if (!allowed()) { + return; + } + Map pending = InviteStore.read(InviteStore.PENDING); + if (pending != null) { + bumpAttempts(pending); + } + Map body = identity(); + body.put("code", code); + body.put("source", source); + body.put("rawReferrer", rawReferrer == null ? "" : rawReferrer); + post(getLinkBase() + PATH_CLAIM, body, matchType, deferred); + } + + private static void bumpAttempts(Map pending) { + pending.put("attempts", + String.valueOf(InviteStore.getInt(pending, "attempts", 0) + 1)); + InviteStore.write(InviteStore.PENDING, pending); + } + + private static Map identity() { + Map body = new LinkedHashMap(); + Display d = Display.getInstance(); + body.put("clientId", Analytics.clientId()); + body.put("buildKey", d == null ? "" : d.getProperty("build_key", "")); + body.put("packageName", d == null ? "" : d.getProperty("package_name", "")); + body.put("consentAnalytics", Boolean.valueOf(allowed())); + return body; + } + + private static void post(String url, Map body, final String matchType, + final boolean deferred) { + try { + ConnectionRequest req = new ConnectionRequest() { + private String payload; + + @Override + protected void readResponse(InputStream input) throws IOException { + byte[] data = Util.readInputStream(input); + payload = data == null ? null : new String(data, "UTF-8"); + } + + @Override + protected void postResponse() { + handleResolution(payload, matchType, deferred); + } + }; + req.setUrl(url); + req.setPost(true); + req.setContentType("application/json"); + req.setRequestBody(JSONParser.mapToJson(body)); + req.setFailSilently(true); + NetworkManager.getInstance().addToQueue(req); + } catch (Throwable t) { + Log.e(t); + } + } + + private static void handleResolution(String payload, String matchType, boolean deferred) { + try { + if (payload == null || payload.length() == 0) { + return; + } + Map json = JSONParser.parseJSON(payload); + if (json == null) { + return; + } + Object slug = json.get("slug"); + if (slug instanceof String && ((String) slug).length() > 0) { + Preferences.set(PREF_SLUG, (String) slug); + } + if (!truthy(json.get("resolved"))) { + state = STATE_NONE_FOUND; + notifyUnavailable(REASON_NO_MATCH); + return; + } + String code = str(json.get("code")); + if (code == null) { + return; + } + String confidence = str(json.get("confidence")); + double score = 1d; + Object rawScore = json.get("score"); + if (rawScore instanceof Number) { + double s = ((Number) rawScore).doubleValue(); + score = s > 1d ? s / 100d : s; + } else if (MATCH_FINGERPRINT.equals(matchType)) { + score = 0d; + } + if (MATCH_DIRECT.equals(matchType) || MATCH_REFERRER.equals(matchType)) { + score = 1d; + } + Map params = new LinkedHashMap(); + Object rawParams = json.get("parameters"); + if (rawParams instanceof Map) { + Map raw = (Map) rawParams; + for (java.util.Iterator i = raw.keySet().iterator(); i.hasNext();) { + Object k = i.next(); + Object v = raw.get(k); + if (k instanceof String && v instanceof String) { + params.put((String) k, (String) v); + } + } + } + String serverMatch = str(json.get("match")); + InviteAttribution a = new InviteAttribution(code, str(json.get("campaign")), + str(json.get("channel")), str(json.get("payload")), + serverMatch == null ? matchType : serverMatch, score, deferred, + longOf(json.get("clickTs")), System.currentTimeMillis(), params); + resolve(a, confidence); + } catch (Throwable t) { + Log.e(t); + } + } + + private static void resolve(InviteAttribution a, String confidence) { + Map record = new LinkedHashMap(); + record.put("code", a.getCode()); + InviteStore.put(record, "campaign", a.getCampaign()); + InviteStore.put(record, "channel", a.getChannel()); + InviteStore.put(record, "payload", a.getPayload()); + record.put("match", a.getMatchType()); + record.put("confidence", String.valueOf(a.getConfidence())); + record.put("deferred", String.valueOf(a.isDeferred())); + record.put("clickTs", String.valueOf(a.getClickTimestamp())); + record.put("resolvedTs", String.valueOf(a.getResolvedTimestamp())); + record.put("delivered", "false"); + InviteStore.write(InviteStore.ATTRIBUTION, record); + InviteStore.delete(InviteStore.PENDING); + resolved = a; + state = STATE_RESOLVED; + writeDimensions(a); + Map p = new HashMap(); + p.put("invite_code", a.getCode()); + putIfSet(p, "campaign", a.getCampaign()); + putIfSet(p, "channel", a.getChannel()); + p.put("match", a.getMatchType()); + putIfSet(p, "confidence", confidence); + if (a.isDeferred()) { + p.put("deferred", Boolean.TRUE); + Analytics.autoEvent("invite_install", CATEGORY, p); + } else { + Analytics.autoEvent("invite_opened", CATEGORY, p); + } + deliverPending(); + } + + private static void writeDimensions(InviteAttribution a) { + Analytics.setDimension(DIMENSION_CODE, a.getCode()); + if (a.getCampaign() != null) { + Analytics.setDimension(DIMENSION_CAMPAIGN, a.getCampaign()); + } + if (a.getChannel() != null) { + Analytics.setDimension(DIMENSION_CHANNEL, a.getChannel()); + } + Analytics.setDimension(DIMENSION_MATCH, a.getMatchType()); + } + + private static void clearDimensions() { + for (int i = 0; i < DIMENSIONS.length; i++) { + Analytics.clearDimension(DIMENSIONS[i]); + } + } + + private static InviteAttribution readAttribution() { + Map r = InviteStore.read(InviteStore.ATTRIBUTION); + if (r == null) { + return null; + } + String code = InviteStore.get(r, "code", null); + if (code == null) { + return null; + } + return new InviteAttribution(code, InviteStore.get(r, "campaign", null), + InviteStore.get(r, "channel", null), InviteStore.get(r, "payload", null), + InviteStore.get(r, "match", MATCH_DIRECT), + InviteStore.getDouble(r, "confidence", 1d), + InviteStore.getBoolean(r, "deferred", false), + InviteStore.getLong(r, "clickTs", 0), + InviteStore.getLong(r, "resolvedTs", 0), + new LinkedHashMap()); + } + + // Delivers at most once per install. The durable flag is what survives a + // restart; deliveredThisRun covers the window between resolving and the + // flag reaching the disk, so a failed write costs at most a duplicate + // after a crash rather than one on every launch. + private static void deliverPending() { + if (listener == null || deliveredThisRun) { + return; + } + Map r = InviteStore.read(InviteStore.ATTRIBUTION); + if (r == null || InviteStore.getBoolean(r, "delivered", false)) { + return; + } + InviteAttribution a = getAttribution(); + if (a == null) { + return; + } + deliveredThisRun = true; + r.put("delivered", "true"); + InviteStore.write(InviteStore.ATTRIBUTION, r); + try { + listener.inviteReceived(a); + } catch (Throwable t) { + Log.e(t); + } + } + + private static void notifyUnavailable(String reason) { + if (listener == null || deliveredThisRun) { + return; + } + deliveredThisRun = true; + try { + listener.attributionUnavailable(reason); + } catch (Throwable t) { + Log.e(t); + } + } + + // ---- registration outbox -------------------------------------------- + + private static void queueRegistration(Invite invite, InviteRequest request) { + Map body = identity(); + body.put("code", invite.getCode()); + putIfSet(body, "campaign", invite.getCampaign()); + putIfSet(body, "channel", invite.getChannel()); + putIfSet(body, "payload", invite.getPayload()); + putIfSet(body, "title", request.getTitle()); + putIfSet(body, "description", request.getDescription()); + putIfSet(body, "imageUrl", request.getImageUrl()); + if (!request.getParameters().isEmpty()) { + body.put("parameters", new LinkedHashMap(request.getParameters())); + } + List outbox = InviteStore.readOutbox(); + outbox.add(JSONParser.mapToJson(body)); + InviteStore.writeOutbox(outbox); + } + + private static void drainOutbox() { + if (!allowed()) { + return; + } + List outbox = InviteStore.readOutbox(); + if (outbox.isEmpty()) { + return; + } + for (int i = 0; i < outbox.size(); i++) { + postRegistration(outbox.get(i)); + } + // Cleared optimistically: a registration that does not land is + // recoverable server side from the click itself, and keeping the + // entries would re-post them on every facade call. + InviteStore.writeOutbox(new java.util.ArrayList()); + } + + private static void postRegistration(final String json) { + try { + ConnectionRequest req = new ConnectionRequest() { + private String payload; + + @Override + protected void readResponse(InputStream input) throws IOException { + byte[] data = Util.readInputStream(input); + payload = data == null ? null : new String(data, "UTF-8"); + } + + @Override + protected void postResponse() { + try { + if (payload == null || payload.length() == 0) { + return; + } + Map r = JSONParser.parseJSON(payload); + if (r == null) { + return; + } + Object slug = r.get("slug"); + if (slug instanceof String && ((String) slug).length() > 0) { + Preferences.set(PREF_SLUG, (String) slug); + } + } catch (Throwable t) { + Log.e(t); + } + } + }; + req.setUrl(getLinkBase() + PATH_MINT); + req.setPost(true); + req.setContentType("application/json"); + req.setRequestBody(json); + req.setFailSilently(true); + NetworkManager.getInstance().addToQueue(req); + } catch (Throwable t) { + Log.e(t); + } + } + + private static boolean truthy(Object o) { + if (o instanceof Boolean) { + return ((Boolean) o).booleanValue(); + } + if (o instanceof String) { + return "true".equals(o); + } + return false; + } + + private static String str(Object o) { + if (o instanceof String && ((String) o).length() > 0) { + return (String) o; + } + return null; + } + + private static long longOf(Object o) { + if (o instanceof Number) { + return ((Number) o).longValue(); + } + return 0; + } +} diff --git a/CodenameOne/src/com/codename1/analytics/invite/package-info.java b/CodenameOne/src/com/codename1/analytics/invite/package-info.java new file mode 100644 index 00000000000..d0b1827c0eb --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/package-info.java @@ -0,0 +1,64 @@ +/* + * 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. + */ +/// Invite / referral attribution: who invited whom, who installed because of +/// it, and what that invited cohort went on to do. +/// +/// [Invites] mints an invite link, shares it through the native share sheet, +/// and -- on the friend's device -- recovers the invite that caused the +/// install. Once attribution resolves it is written as persistent analytics +/// dimensions, so every later event, including the `purchase` event the +/// framework already emits for you, arrives tagged with the campaign and the +/// referrer. Revenue and lifetime value per campaign fall out of the reports +/// you already have. +/// +/// ```java +/// // On the inviter's device. +/// Invite invite = Invites.create(InviteRequest.create() +/// .campaign("spring") +/// .channel("share_sheet") +/// .build()); +/// Invites.share(invite, "Come and try this with me"); +/// +/// // On the friend's device, from your start() method. +/// Invites.setInviteListener(new InviteListener() { +/// public void inviteReceived(InviteAttribution attribution) { +/// // Credit attribution.getCampaign() / getCode(). +/// } +/// +/// public void attributionUnavailable(String reason) { +/// // Ordinary: most installs are not invited. +/// } +/// }); +/// Invites.checkForInvite(); +/// ``` +/// +/// Everything here is gated on the analytics consent category of +/// {@link com.codename1.analytics.Analytics}, and nothing is reported until +/// consent is granted. +/// +/// This package is deliberately separate from +/// {@link com.codename1.analytics}. The Android half of the attribution links +/// the Play Install Referrer library, which raises the application's minimum +/// API level, and the build only does that for applications that actually +/// reference this package. +package com.codename1.analytics.invite; diff --git a/CodenameOne/src/com/codename1/components/InviteButton.java b/CodenameOne/src/com/codename1/components/InviteButton.java new file mode 100644 index 00000000000..d4ddd9deda3 --- /dev/null +++ b/CodenameOne/src/com/codename1/components/InviteButton.java @@ -0,0 +1,262 @@ +/* + * 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.components; + +import com.codename1.analytics.invite.Invite; +import com.codename1.analytics.invite.InviteRequest; +import com.codename1.analytics.invite.Invites; +import com.codename1.share.ShareResultListener; +import com.codename1.ui.FontImage; +import com.codename1.ui.events.ActionEvent; + +/// A [ShareButton] that mints a fresh invite on every press and shares it, so +/// the whole invite funnel is wired with one component. +/// +/// ```java +/// InviteButton invite = new InviteButton("Invite a friend"); +/// invite.setCampaign("spring"); +/// invite.setMessage("Come and try this with me"); +/// form.add(invite); +/// ``` +/// +/// The button owns the share result, so `invite_shared` is reported only when +/// the platform confirms the user really shared. Your own +/// [#setShareResultListener] still works and is still called. +/// +/// See [Invites] for the receiving half and for how attribution reaches your +/// analytics reports. +public class InviteButton extends ShareButton { + private String campaign; + private String channel; + private String payload; + private String message; + private Invite invite; + private ShareResultListener appListener; + + /// Default constructor. + public InviteButton() { + setUIID("InviteButton"); + FontImage.setMaterialIcon(this, FontImage.MATERIAL_GROUP_ADD); + installChain(); + } + + /// Creates a button with the given label. + /// + /// #### Parameters + /// + /// - `text`: the button label + public InviteButton(String text) { + this(); + setText(text); + } + + // ShareButton.actionPerformed reads its private listener FIELD, not the + // getter, so the chaining listener has to be installed through super's + // setter exactly once. The overridden accessors below then keep the + // application's listener in a field of our own -- without that, setting a + // listener would silently replace the chain and the funnel would lose + // every share. + private void installChain() { + super.setShareResultListener(new ShareResultListener() { + @Override + public void onResult(com.codename1.share.ShareResult result) { + Invite current = invite; + if (current != null) { + Invites.reportShareResult(current, result); + } + if (appListener != null) { + appListener.onResult(result); + } + } + }); + } + + /// Groups the invites this button mints under a campaign. + /// + /// #### Parameters + /// + /// - `campaign`: the campaign name + public void setCampaign(String campaign) { + this.campaign = campaign; + } + + /// The campaign, or null. + /// + /// #### Returns + /// + /// the campaign + public String getCampaign() { + return campaign; + } + + /// Records how the invite is being sent. + /// + /// #### Parameters + /// + /// - `channel`: the channel name + public void setChannel(String channel) { + this.channel = channel; + } + + /// The channel, or null. + /// + /// #### Returns + /// + /// the channel + public String getChannel() { + return channel; + } + + /// An application defined string handed to the invited device. + /// + /// #### Parameters + /// + /// - `payload`: the payload + public void setPayload(String payload) { + this.payload = payload; + } + + /// The payload, or null. + /// + /// #### Returns + /// + /// the payload + public String getPayload() { + return payload; + } + + /// The text placed before the link in the shared message. + /// + /// #### Parameters + /// + /// - `message`: the message + public void setMessage(String message) { + this.message = message; + } + + /// The message, or null. + /// + /// #### Returns + /// + /// the message + public String getMessage() { + return message; + } + + /// The invite minted for the most recent press, or null before the first + /// press. + /// + /// #### Returns + /// + /// the invite + public Invite getInvite() { + return invite; + } + + /// {@inheritDoc} + @Override + public void setShareResultListener(ShareResultListener listener) { + this.appListener = listener; + } + + /// {@inheritDoc} + @Override + public ShareResultListener getShareResultListener() { + return appListener; + } + + /// {@inheritDoc} + @Override + public void actionPerformed(ActionEvent evt) { + InviteRequest.Builder b = InviteRequest.create(); + if (campaign != null) { + b.campaign(campaign); + } + if (channel != null) { + b.channel(channel); + } + if (payload != null) { + b.payload(payload); + } + invite = Invites.create(b.build()); + String text = message == null || message.length() == 0 + ? invite.getUrl() : message + " " + invite.getUrl(); + setTextToShare(text); + // ShareButton defers the share by one EDT cycle, so setting the text + // here is in time. + super.actionPerformed(evt); + } + + /// {@inheritDoc} + @Override + public String[] getPropertyNames() { + return new String[]{"textToShare", "campaign", "channel", "payload", "message"}; + } + + /// {@inheritDoc} + @Override + public Class[] getPropertyTypes() { + return new Class[]{String.class, String.class, String.class, String.class, String.class}; + } + + /// {@inheritDoc} + @Override + public Object getPropertyValue(String name) { + if ("campaign".equals(name)) { + return getCampaign(); + } + if ("channel".equals(name)) { + return getChannel(); + } + if ("payload".equals(name)) { + return getPayload(); + } + if ("message".equals(name)) { + return getMessage(); + } + return super.getPropertyValue(name); + } + + /// {@inheritDoc} + @Override + public String setPropertyValue(String name, Object value) { + String v = value instanceof String ? (String) value : null; + if ("campaign".equals(name)) { + setCampaign(v); + return null; + } + if ("channel".equals(name)) { + setChannel(v); + return null; + } + if ("payload".equals(name)) { + setPayload(v); + return null; + } + if ("message".equals(name)) { + setMessage(v); + return null; + } + return super.setPropertyValue(name, value); + } +} diff --git a/maven/platform-feature-catalog/src/main/java/com/codename1/build/shared/PlatformFeatureCatalog.java b/maven/platform-feature-catalog/src/main/java/com/codename1/build/shared/PlatformFeatureCatalog.java index ac951abbc16..d7f58731363 100644 --- a/maven/platform-feature-catalog/src/main/java/com/codename1/build/shared/PlatformFeatureCatalog.java +++ b/maven/platform-feature-catalog/src/main/java/com/codename1/build/shared/PlatformFeatureCatalog.java @@ -830,6 +830,35 @@ public final class PlatformFeatureCatalog { .androidMetaData("com.google.ar.core", "optional") .description("Cross-platform augmented reality (world/image/face tracking)")); + // Invite / referral attribution. The Play Install Referrer library is + // the deterministic half of the attribution and carries a minSdk 21 + // floor of its own, so this entry MUST stay keyed on the invite + // subpackage rather than on com/codename1/analytics/. Display and the + // Analytics facade are referenced by practically every application; + // keying one package higher would put a Play dependency and an API 21 + // floor on all of them, which is the DatabaseConfig failure recorded + // in AndroidGradleBuilder.usesClass -- and the later deletion of the + // unused sources does not undo it, because the dependency and the + // floor are already in the gradle file. + // + // No iOS half: there is nothing to link. The iOS attribution path is + // an HTTPS call built from Display properties that already exist, so + // it needs no pod, no framework and no deployment target lift. + e.add(new Entry("com/codename1/analytics/invite/") + .androidGradle("com.android.installreferrer:installreferrer:2.2") + .androidMinimumSdk(21) + .description("Invite referral attribution (Play Install Referrer)")); + + // InviteButton lives beside ShareButton in com/codename1/components, + // outside the prefix above, and an application can reference it + // without naming anything in the invite package. Matched as an exact + // class (no trailing slash) so the rest of com/codename1/components + // is unaffected. + e.add(new Entry("com/codename1/components/InviteButton") + .androidGradle("com.android.installreferrer:installreferrer:2.2") + .androidMinimumSdk(21) + .description("Invite button (Play Install Referrer)")); + ENTRIES = Collections.unmodifiableList(e); Set classPrefixes = new LinkedHashSet(); Set methodKeys = new LinkedHashSet(); diff --git a/maven/platform-feature-catalog/src/test/java/com/codename1/build/shared/PlatformFeatureCatalogTest.java b/maven/platform-feature-catalog/src/test/java/com/codename1/build/shared/PlatformFeatureCatalogTest.java index 806ac91d664..6986b65fd78 100644 --- a/maven/platform-feature-catalog/src/test/java/com/codename1/build/shared/PlatformFeatureCatalogTest.java +++ b/maven/platform-feature-catalog/src/test/java/com/codename1/build/shared/PlatformFeatureCatalogTest.java @@ -33,6 +33,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -888,4 +889,57 @@ void theSharedNearbyPackageCostsNothing() { assertTrue(PlatformFeatureCatalog.matchesFor( "com/codename1/nearby/spi/NearbyBridge").isEmpty()); } + @Test + void inviteAttributionBuysThePlayInstallReferrerAndItsFloor() { + List hits = PlatformFeatureCatalog.matchesFor( + "com/codename1/analytics/invite/Invites"); + assertEquals(1, hits.size(), "expected one entry to fire"); + PlatformFeatureCatalog.Entry e = hits.get(0); + assertTrue(e.androidGradleDeps().get(0) + .startsWith("com.android.installreferrer:installreferrer"), + "the deterministic Android path needs the Play referrer library"); + assertEquals(21, e.androidMinimumSdk(), + "the installreferrer aar declares minSdk 21 and the merger enforces it"); + assertTrue(e.iosPods().isEmpty(), "the iOS path links nothing"); + assertTrue(e.iosFrameworks().isEmpty(), "the iOS path links nothing"); + assertNull(e.iosMinimumDeploymentTarget(), + "attribution over HTTPS must not lift the deployment target"); + } + + @Test + void plainAnalyticsDoesNotBuyThePlayInstallReferrer() { + // The whole reason the invite classes live in their own subpackage. + // Practically every application references the Analytics facade; if + // the entry were keyed on com/codename1/analytics/ instead, all of + // them would gain a Play dependency and an API 21 floor. That is the + // DatabaseConfig failure AndroidGradleBuilder.usesClass documents, + // and deleting the unused sources later does not undo it. + assertTrue(PlatformFeatureCatalog.matchesFor( + "com/codename1/analytics/Analytics").isEmpty(), + "the analytics facade must buy nothing"); + assertTrue(PlatformFeatureCatalog.matchesFor( + "com/codename1/analytics/AnalyticsEvent").isEmpty(), + "the analytics value types must buy nothing"); + assertTrue(PlatformFeatureCatalog.matchesFor( + "com/codename1/analytics/CodenameOneAnalyticsProvider").isEmpty(), + "the first-party provider must buy nothing"); + } + + @Test + void inviteButtonIsMatchedExactlyAndLeavesTheRestOfComponentsAlone() { + // InviteButton sits beside ShareButton, outside the invite package, + // because a Button subclass does not belong in an analytics package. + // An application can reference it and nothing else, so it needs its + // own entry -- but as an exact class, or every component would fire. + List hits = PlatformFeatureCatalog.matchesFor( + "com/codename1/components/InviteButton"); + assertEquals(1, hits.size(), "expected one entry to fire"); + assertEquals(21, hits.get(0).androidMinimumSdk()); + assertTrue(PlatformFeatureCatalog.matchesFor( + "com/codename1/components/ShareButton").isEmpty(), + "the plain share button must buy nothing"); + assertTrue(PlatformFeatureCatalog.matchesFor( + "com/codename1/components/InfiniteProgress").isEmpty(), + "an exact-class key must not behave like a prefix"); + } } From 1a5055e0cfba074b1326e2804c1188c39b51a228 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:57:33 +0300 Subject: [PATCH 02/99] Invite attribution: tests, and the analysis gates they have to pass Adds 33 unit tests over the invite client: minting offline, url and referrer parsing, the funnel events, the consent state machine, erasure, and exactly-once delivery. Three of them are the ones worth keeping honest about: - resetClientId must clear the referral dimensions AND leave the application's own dimensions alone. Both halves are asserted, because either one alone is a bug. - Opt-out consent mode alone must not authorise the statistical match. The deprecated AnalyticsService forces that mode, so the ordinary gate reports permission with no user choice on record. - A dismissed share sheet must never report invite_shared, which is what makes the shared count a measurement rather than an assumption. The referrer key is compared with equals and never case folded, and a test pins that a differently cased key does not match: String.toLowerCase is locale sensitive with no root-locale overload in this runtime, so a folded comparison silently stops matching under a Turkish default locale. Ten SpotBugs findings and four cast-semantics findings in the new code are fixed rather than excluded. The one exclusion added is scoped to Invites$InviteConnection, a one-shot ConnectionRequest that is never compared or used as a map key -- the same idiom and reasoning as the existing OsrmRouteService$RouteConnection entry. The casts were rewritten into the positive instanceof form the verifier recognises, which matters beyond the gate: ParparVM does not throw on a failed cast, so the surrounding catch(Throwable) would never have run on iOS. --- .../analytics/invite/InviteStore.java | 44 ++-- .../codename1/analytics/invite/Invites.java | 167 ++++++++------- maven/core-unittests/spotbugs-exclude.xml | 14 ++ .../invite/InviteConsentAndErasureTest.java | 196 ++++++++++++++++++ .../analytics/invite/InviteDeliveryTest.java | 194 +++++++++++++++++ .../invite/InviteFunnelEventsTest.java | 170 +++++++++++++++ .../analytics/invite/InviteMintTest.java | 159 ++++++++++++++ .../analytics/invite/InviteTestSupport.java | 77 +++++++ .../invite/InviteUrlParsingTest.java | 100 +++++++++ .../analytics/invite/RecordingProvider.java | 88 ++++++++ 10 files changed, 1115 insertions(+), 94 deletions(-) create mode 100644 maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteFunnelEventsTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteMintTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java create mode 100644 maven/core-unittests/src/test/java/com/codename1/analytics/invite/RecordingProvider.java diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java index eee5789c454..cd973d32d97 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java @@ -68,19 +68,26 @@ static Map read(String record) { return null; } Object o = s.readObject(record); - if (!(o instanceof Map)) { - return null; - } - Map out = new LinkedHashMap(); - Map raw = (Map) o; - for (Iterator i = raw.keySet().iterator(); i.hasNext();) { - Object k = i.next(); - Object v = raw.get(k); - if (k instanceof String && v instanceof String) { - out.put((String) k, (String) v); + // Positive instanceof guards throughout: ParparVM does not throw + // on a failed cast, so the catch below would never see one and the + // wrong object would simply be read as the wrong type. + if (o instanceof Map) { + Map raw = (Map) o; + Map out = new LinkedHashMap(); + for (Iterator i = raw.entrySet().iterator(); i.hasNext();) { + Object next = i.next(); + if (next instanceof Map.Entry) { + Map.Entry en = (Map.Entry) next; + Object k = en.getKey(); + Object v = en.getValue(); + if (k instanceof String && v instanceof String) { + out.put((String) k, (String) v); + } + } } + return out; } - return out; + return null; } catch (Throwable t) { Log.e(t); return null; @@ -121,14 +128,13 @@ static List readOutbox() { return out; } Object o = s.readObject(OUTBOX); - if (!(o instanceof List)) { - return out; - } - List raw = (List) o; - for (int i = 0; i < raw.size(); i++) { - Object v = raw.get(i); - if (v instanceof String) { - out.add((String) v); + if (o instanceof List) { + List raw = (List) o; + for (int i = 0; i < raw.size(); i++) { + Object v = raw.get(i); + if (v instanceof String) { + out.add((String) v); + } } } } catch (Throwable t) { diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index a214f388228..e6f04e30427 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -175,8 +175,9 @@ public final class Invites { private static final String PATH_CLAIM = "/api/v2/analytics/invites/claim"; private static final String PATH_MATCH = "/api/v2/analytics/invites/match"; - private static final String PREF_SLUG = "cn1$inviteSlug"; - private static final String PREF_CONSUMED_ARG = "cn1$inviteConsumedArg"; + // Package private so the unit tests can clear them between cases. + static final String PREF_SLUG = "cn1$inviteSlug"; + static final String PREF_CONSUMED_ARG = "cn1$inviteConsumedArg"; // The referrer key the link service puts on the store url. Compared with // equals and never case folded: String.toLowerCase is locale sensitive and @@ -197,6 +198,10 @@ public final class Invites { private static boolean deliveredThisRun; private static boolean deferredStarted; + // Only ever touched on the fallback path in newCode(), and held as a field + // so there is one generator for the process rather than one per call. + private static final java.util.Random FALLBACK_RANDOM = new java.util.Random(); + private Invites() { } @@ -445,10 +450,12 @@ public static boolean handleUrl(String url) { /// /// the attribution public static InviteAttribution getAttribution() { - if (resolved == null) { - resolved = readAttribution(); + InviteAttribution a = resolved; + if (a == null) { + a = readAttribution(); + resolved = a; } - return resolved; + return a; } /// Where attribution has got to: one of the `STATE_` constants. @@ -504,7 +511,7 @@ public static void conversion(String action, double value, String currency) { putIfSet(p, "channel", a.getChannel()); putIfSet(p, "action", action); if (value != 0d) { - p.put("value", new Double(value)); + p.put("value", Double.valueOf(value)); } putIfSet(p, "currency", currency); Analytics.autoEvent("invite_converted", CATEGORY, p); @@ -697,8 +704,7 @@ private static String newCode() { // source degrades uniqueness, not security. Reported once rather // than failing the invite. Log.e(t); - java.util.Random r = new java.util.Random(); - r.nextBytes(raw); + FALLBACK_RANDOM.nextBytes(raw); } String s = Base64.encodeUrlSafe(raw); int pad = s.indexOf('='); @@ -997,8 +1003,8 @@ private static void requestMatch(Map pending) { body.put("osVersion", InviteStore.get(pending, "osVersion", "")); body.put("deviceModel", InviteStore.get(pending, "deviceModel", "")); body.put("locale", InviteStore.get(pending, "locale", "")); - body.put("screenWidth", new Integer(InviteStore.getInt(pending, "screenWidth", 0))); - body.put("screenHeight", new Integer(InviteStore.getInt(pending, "screenHeight", 0))); + body.put("screenWidth", Integer.valueOf(InviteStore.getInt(pending, "screenWidth", 0))); + body.put("screenHeight", Integer.valueOf(InviteStore.getInt(pending, "screenHeight", 0))); post(getLinkBase() + PATH_MATCH, body, MATCH_FINGERPRINT, true); } @@ -1034,27 +1040,19 @@ private static Map identity() { return body; } - private static void post(String url, Map body, final String matchType, - final boolean deferred) { - try { - ConnectionRequest req = new ConnectionRequest() { - private String payload; - - @Override - protected void readResponse(InputStream input) throws IOException { - byte[] data = Util.readInputStream(input); - payload = data == null ? null : new String(data, "UTF-8"); - } + private static void post(String url, Map body, String matchType, + boolean deferred) { + send(url, JSONParser.mapToJson(body), matchType, deferred, false); + } - @Override - protected void postResponse() { - handleResolution(payload, matchType, deferred); - } - }; + private static void send(String url, String json, String matchType, boolean deferred, + boolean registration) { + try { + InviteConnection req = new InviteConnection(matchType, deferred, registration); req.setUrl(url); req.setPost(true); req.setContentType("application/json"); - req.setRequestBody(JSONParser.mapToJson(body)); + req.setRequestBody(json); req.setFailSilently(true); NetworkManager.getInstance().addToQueue(req); } catch (Throwable t) { @@ -1062,19 +1060,71 @@ protected void postResponse() { } } - private static void handleResolution(String payload, String matchType, boolean deferred) { + // One request type for every invite call. Named rather than anonymous so + // the two call sites share a single implementation, and so the equals() + // exemption a one-shot request needs is scoped to one class. + private static final class InviteConnection extends ConnectionRequest { + private final String matchType; + private final boolean deferred; + private final boolean registration; + private String payload; + + InviteConnection(String matchType, boolean deferred, boolean registration) { + this.matchType = matchType; + this.deferred = deferred; + this.registration = registration; + } + + @Override + protected void readResponse(InputStream input) throws IOException { + payload = new String(Util.readInputStream(input), "UTF-8"); + } + + @Override + protected void postResponse() { + if (registration) { + applySlug(payload); + } else { + handleResolution(payload, matchType, deferred); + } + } + } + + // The link service hands back the per-application path segment on any + // answer. Remembering it is what lets later invites mint the precise form + // that keeps two enrolled applications on one device from claiming each + // other's links. + private static void applySlug(String payload) { try { if (payload == null || payload.length() == 0) { return; } - Map json = JSONParser.parseJSON(payload); - if (json == null) { + Map r = JSONParser.parseJSON(payload); + if (r == null) { return; } - Object slug = json.get("slug"); + Object slug = r.get("slug"); if (slug instanceof String && ((String) slug).length() > 0) { Preferences.set(PREF_SLUG, (String) slug); } + } catch (Throwable t) { + Log.e(t); + } + } + + // Package private rather than private so the unit tests can drive the real + // resolution path with a canned server answer instead of racing the + // network thread. + static void handleResolution(String payload, String matchType, boolean deferred) { + try { + if (payload == null || payload.length() == 0) { + return; + } + Map json = JSONParser.parseJSON(payload); + if (json == null) { + return; + } + applySlug(payload); if (!truthy(json.get("resolved"))) { state = STATE_NONE_FOUND; notifyUnavailable(REASON_NO_MATCH); @@ -1100,11 +1150,15 @@ private static void handleResolution(String payload, String matchType, boolean d Object rawParams = json.get("parameters"); if (rawParams instanceof Map) { Map raw = (Map) rawParams; - for (java.util.Iterator i = raw.keySet().iterator(); i.hasNext();) { - Object k = i.next(); - Object v = raw.get(k); - if (k instanceof String && v instanceof String) { - params.put((String) k, (String) v); + for (java.util.Iterator i = raw.entrySet().iterator(); i.hasNext();) { + Object next = i.next(); + if (next instanceof Map.Entry) { + Map.Entry en = (Map.Entry) next; + Object k = en.getKey(); + Object v = en.getValue(); + if (k instanceof String && v instanceof String) { + params.put((String) k, (String) v); + } } } } @@ -1261,45 +1315,8 @@ private static void drainOutbox() { InviteStore.writeOutbox(new java.util.ArrayList()); } - private static void postRegistration(final String json) { - try { - ConnectionRequest req = new ConnectionRequest() { - private String payload; - - @Override - protected void readResponse(InputStream input) throws IOException { - byte[] data = Util.readInputStream(input); - payload = data == null ? null : new String(data, "UTF-8"); - } - - @Override - protected void postResponse() { - try { - if (payload == null || payload.length() == 0) { - return; - } - Map r = JSONParser.parseJSON(payload); - if (r == null) { - return; - } - Object slug = r.get("slug"); - if (slug instanceof String && ((String) slug).length() > 0) { - Preferences.set(PREF_SLUG, (String) slug); - } - } catch (Throwable t) { - Log.e(t); - } - } - }; - req.setUrl(getLinkBase() + PATH_MINT); - req.setPost(true); - req.setContentType("application/json"); - req.setRequestBody(json); - req.setFailSilently(true); - NetworkManager.getInstance().addToQueue(req); - } catch (Throwable t) { - Log.e(t); - } + private static void postRegistration(String json) { + send(getLinkBase() + PATH_MINT, json, MATCH_DIRECT, false, true); } private static boolean truthy(Object o) { diff --git a/maven/core-unittests/spotbugs-exclude.xml b/maven/core-unittests/spotbugs-exclude.xml index 70f3ede56a1..d3a0f9ea280 100644 --- a/maven/core-unittests/spotbugs-exclude.xml +++ b/maven/core-unittests/spotbugs-exclude.xml @@ -409,4 +409,18 @@ + + + + + + diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java new file mode 100644 index 00000000000..343fb97af3c --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java @@ -0,0 +1,196 @@ +/* + * 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.analytics.invite; + +import com.codename1.analytics.Analytics; +import com.codename1.analytics.AnalyticsConsent; +import com.codename1.analytics.ConsentMode; +import com.codename1.io.ConnectionRequest; +import com.codename1.io.Storage; +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InviteConsentAndErasureTest extends UITestBase { + + @AfterEach + void cleanUp() { + InviteTestSupport.tearDown(); + } + + @FormTest + void resolutionWritesTheReferralDimensions() { + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + + Invites.handleResolution( + InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + Invites.MATCH_REFERRER, true); + + Map dims = Analytics.getDimensions(); + assertEquals("ABC123", dims.get(Invites.DIMENSION_CODE)); + assertEquals("spring", dims.get(Invites.DIMENSION_CAMPAIGN)); + assertEquals("sms", dims.get(Invites.DIMENSION_CHANNEL)); + assertEquals(Invites.MATCH_REFERRER, dims.get(Invites.DIMENSION_MATCH)); + } + + @FormTest + void theDimensionsRideEveryLaterBatch() { + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.handleResolution( + InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + Invites.MATCH_REFERRER, true); + + Analytics.clearProviders(); + Analytics.setConsentMode(ConsentMode.OPT_OUT); + Analytics.addProvider(new com.codename1.analytics.CodenameOneAnalyticsProvider()); + implementation.clearQueuedRequests(); + + // This is the claim the whole feature rests on: revenue per campaign + // needs no new aggregation, because the purchase event the framework + // already emits arrives carrying the attribution. + Analytics.event(com.codename1.analytics.AnalyticsEvent.create("purchase") + .param("value", 9.99).build()); + Analytics.flush(); + + List requests = implementation.getQueuedRequests(); + assertEquals(1, requests.size()); + String body = requests.get(0).getRequestBody(); + assertTrue(body.contains(Invites.DIMENSION_CAMPAIGN), body); + assertTrue(body.contains("spring"), body); + assertTrue(body.contains("purchase"), body); + } + + @FormTest + void resetClientIdErasesTheReferralDimensionsAndKeepsTheApplicationsOwn() { + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Analytics.setDimension("plan", "pro"); + Invites.handleResolution( + InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + Invites.MATCH_REFERRER, true); + assertNotNull(Invites.getAttribution()); + + Analytics.resetClientId(); + + // Leaving the referral dimensions behind would re-link the freshly + // issued pseudonymous id to the same inviter, which is exactly what + // the erasure was asked to undo. + Map dims = Analytics.getDimensions(); + assertNull(dims.get(Invites.DIMENSION_CODE)); + assertNull(dims.get(Invites.DIMENSION_CAMPAIGN)); + assertNull(dims.get(Invites.DIMENSION_CHANNEL)); + assertNull(dims.get(Invites.DIMENSION_MATCH)); + // ... and taking the application's own dimensions with it would be + // destroying data it never asked to lose. + assertEquals("pro", dims.get("plan")); + assertNull(Invites.getAttribution()); + assertEquals(Invites.STATE_NONE, Invites.getState()); + } + + @FormTest + void registeringTheProviderIsNotMistakenForAnErasure() { + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.handleResolution( + InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + Invites.MATCH_REFERRER, true); + + // addProvider calls init() with the current client id, exactly as + // resetClientId does. Only a CHANGE means erase. + Analytics.addProvider(new RecordingProvider()); + Analytics.addProvider(new InviteAttributionProvider()); + + assertNotNull(Invites.getAttribution(), "a plain registration erased the attribution"); + assertEquals("spring", Analytics.getDimensions().get(Invites.DIMENSION_CAMPAIGN)); + } + + @FormTest + void nothingIsTransmittedBeforeConsentAndTheProfileIsDeletedIfRefused() { + InviteTestSupport.freshInstall(); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.none()); + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + + Invites.checkForInvite(); + + assertEquals(0, implementation.getQueuedRequests().size(), + "nothing may leave the device before consent"); + // The profile is held locally so a deferred match is still possible if + // consent arrives inside the window. + assertTrue(Storage.getInstance().exists(InviteStore.PENDING)); + + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + + assertFalse(Storage.getInstance().exists(InviteStore.PENDING), + "a refused profile must be deleted, not held"); + assertEquals(Invites.STATE_DECLINED, Invites.getState()); + } + + @FormTest + void optOutModeAloneDoesNotAuthoriseTheStatisticalMatch() { + InviteTestSupport.freshInstall(); + // The deprecated AnalyticsService forces OPT_OUT, under which the + // ordinary gate reports permission with no user choice on record. + // Sending a device profile on that basis is not defensible, so the + // match requires an explicit grant. + Analytics.setConsentMode(ConsentMode.OPT_OUT); + Analytics.setConsent(null); + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + + Invites.checkForInvite(); + + for (ConnectionRequest r : implementation.getQueuedRequests()) { + assertFalse(r.getUrl().endsWith("/invites/match"), + "the statistical match went out under an implicit allow"); + } + } + + @FormTest + void revokingConsentClearsTheDimensionsButKeepsTheAttribution() { + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.handleResolution( + InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + Invites.MATCH_REFERRER, true); + + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + assertNull(Analytics.getDimensions().get(Invites.DIMENSION_CAMPAIGN)); + assertNotNull(Invites.getAttribution(), "the local record is not personal to anyone else"); + + Analytics.setConsent(AnalyticsConsent.granted()); + assertEquals("spring", Analytics.getDimensions().get(Invites.DIMENSION_CAMPAIGN), + "re-granting must restore the dimensions from the stored record"); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java new file mode 100644 index 00000000000..46977368ea6 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java @@ -0,0 +1,194 @@ +/* + * 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.analytics.invite; + +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.AfterEach; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InviteDeliveryTest extends UITestBase { + + @AfterEach + void cleanUp() { + InviteTestSupport.tearDown(); + } + + private static final class Capture implements InviteListener { + final List received = new ArrayList(); + final List unavailable = new ArrayList(); + + public void inviteReceived(InviteAttribution attribution) { + received.add(attribution); + } + + public void attributionUnavailable(String reason) { + unavailable.add(reason); + } + } + + @FormTest + void anAttributionIsDeliveredExactlyOnce() { + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Capture capture = new Capture(); + Invites.setInviteListener(capture); + + Invites.handleResolution( + InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + Invites.MATCH_REFERRER, true); + + assertEquals(1, capture.received.size()); + assertEquals("ABC123", capture.received.get(0).getCode()); + assertTrue(capture.received.get(0).isDeferred()); + + // Re-entering the facade, as a later start() would, must not deliver + // the same attribution a second time. + Invites.checkForInvite(); + Invites.setInviteListener(capture); + assertEquals(1, capture.received.size(), "the attribution was delivered twice"); + } + + @FormTest + void anAnswerThatArrivesBeforeTheListenerIsHeldAndDeliveredOnRegistration() { + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + + // A cold launch from a link resolves before the application has run + // start(), so the answer has to wait rather than be dropped. + Invites.handleResolution( + InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + Invites.MATCH_REFERRER, true); + + Capture capture = new Capture(); + Invites.setInviteListener(capture); + + assertEquals(1, capture.received.size(), "the held attribution was never delivered"); + assertEquals("ABC123", capture.received.get(0).getCode()); + } + + @FormTest + void aZeroWindowSwitchesDeferredAttributionOff() { + InviteTestSupport.freshInstall(); + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + Capture capture = new Capture(); + Invites.setInviteListener(capture); + Invites.setAttributionWindow(0); + + Invites.checkForInvite(); + + assertEquals(0, implementation.getQueuedRequests().size(), + "the documented kill switch still sent something"); + assertEquals(1, capture.unavailable.size()); + assertEquals(Invites.REASON_UNSUPPORTED, capture.unavailable.get(0)); + } + + @FormTest + void aStoreReferrerResolvesDeterministically() { + InviteTestSupport.freshInstall(); + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + callback.onReferrer( + "utm_source=cn1_invite&utm_medium=referral&cn1_invite=ABC123", + 1700000000L, 1700000060L); + } + }); + + Invites.checkForInvite(); + + // The deterministic path posts a claim carrying the code itself, and + // never the statistical match. + boolean sawClaim = false; + for (int i = 0; i < implementation.getQueuedRequests().size(); i++) { + String url = implementation.getQueuedRequests().get(i).getUrl(); + assertTrue(!url.endsWith("/invites/match"), + "a device with a store referrer must not be fingerprinted"); + if (url.endsWith("/invites/claim")) { + sawClaim = true; + assertTrue(implementation.getQueuedRequests().get(i) + .getRequestBody().contains("ABC123")); + } + } + assertTrue(sawClaim, "expected a deterministic claim"); + } + + @FormTest + void noStoreReferrerFallsBackToTheStatisticalMatch() { + InviteTestSupport.freshInstall(); + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + callback.onUnavailable(Invites.REASON_NO_MATCH); + } + }); + + Invites.checkForInvite(); + + boolean sawMatch = false; + for (int i = 0; i < implementation.getQueuedRequests().size(); i++) { + if (implementation.getQueuedRequests().get(i).getUrl().endsWith("/invites/match")) { + sawMatch = true; + String body = implementation.getQueuedRequests().get(i).getRequestBody(); + // The server reads the address off the socket; the client must + // never try to enumerate it. + assertTrue(!body.contains("\"ip\""), body); + assertTrue(body.contains("osVersion"), body); + assertTrue(body.contains("deviceModel"), body); + } + } + assertTrue(sawMatch, "expected the statistical match as the fallback"); + } + + @FormTest + void aSecondLinkDoesNotRewriteTheFirstTouchCohort() { + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.handleResolution( + InviteTestSupport.resolvedJson("FIRST", "spring", "sms"), + Invites.MATCH_REFERRER, true); + + Invites.handleUrl("https://cloud.codenameone.com/i/SECOND"); + + InviteAttribution a = Invites.getAttribution(); + assertNotNull(a); + assertEquals("FIRST", a.getCode(), + "rewriting the cohort mid-stream makes lifetime value unjoinable"); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteFunnelEventsTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteFunnelEventsTest.java new file mode 100644 index 00000000000..4fe909c5569 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteFunnelEventsTest.java @@ -0,0 +1,170 @@ +/* + * 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.analytics.invite; + +import com.codename1.analytics.AnalyticsEvent; +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import com.codename1.share.ShareResult; +import org.junit.jupiter.api.AfterEach; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InviteFunnelEventsTest extends UITestBase { + + @AfterEach + void cleanUp() { + InviteTestSupport.tearDown(); + } + + @FormTest + void sharedToReportsInviteSharedWithTheRealTarget() { + RecordingProvider recorder = InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invite invite = Invites.create(InviteRequest.create().campaign("spring").build()); + recorder.clear(); + + Invites.reportShareResult(invite, ShareResult.sharedTo("com.whatsapp")); + + AnalyticsEvent e = recorder.first("invite_shared"); + assertNotNull(e, "expected invite_shared, saw " + recorder.names()); + assertEquals(Invites.CATEGORY, e.getCategory()); + assertEquals(invite.getCode(), e.getParameters().get("invite_code")); + assertEquals("com.whatsapp", e.getParameters().get("target")); + } + + @FormTest + void aDismissedSheetNeverReportsAShare() { + RecordingProvider recorder = InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invite invite = Invites.create(InviteRequest.create().build()); + recorder.clear(); + + Invites.reportShareResult(invite, ShareResult.dismissed()); + + // This is the difference between a measured funnel and an assumed one: + // "created but abandoned" has to be distinguishable from "sent". + assertEquals(0, recorder.count("invite_shared")); + assertNotNull(recorder.first("invite_share_dismissed")); + } + + @FormTest + void anUnknownTargetOmitsTheParameterRatherThanInventingOne() { + RecordingProvider recorder = InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invite invite = Invites.create(InviteRequest.create().build()); + recorder.clear(); + + // Older Android and the web share api cannot say where it went. + Invites.reportShareResult(invite, ShareResult.sharedTo(null)); + + AnalyticsEvent e = recorder.first("invite_shared"); + assertNotNull(e); + assertFalse(e.getParameters().containsKey("target"), + "an unknown target must be absent, not a placeholder"); + } + + @FormTest + void conversionIsANoOpUntilSomethingIsAttributed() { + RecordingProvider recorder = InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + recorder.clear(); + + Invites.conversion("signup", 9.99, "USD"); + + assertEquals(0, recorder.count("invite_converted")); + } + + @FormTest + void conversionCarriesValueAndCurrencyOnceAttributed() { + RecordingProvider recorder = InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.handleResolution( + InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + Invites.MATCH_REFERRER, true); + recorder.clear(); + + Invites.conversion("signup", 9.99, "USD"); + + AnalyticsEvent e = recorder.first("invite_converted"); + assertNotNull(e, "expected invite_converted, saw " + recorder.names()); + assertEquals(Invites.CATEGORY, e.getCategory()); + assertEquals("ABC123", e.getParameters().get("invite_code")); + assertEquals("spring", e.getParameters().get("campaign")); + assertEquals("signup", e.getParameters().get("action")); + assertEquals("USD", e.getParameters().get("currency")); + assertNotNull(e.getParameters().get("value")); + } + + @FormTest + void aDeferredResolutionReportsAnInstallRatherThanAnOpen() { + RecordingProvider recorder = InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + recorder.clear(); + + Invites.handleResolution( + InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + Invites.MATCH_REFERRER, true); + + assertNotNull(recorder.first("invite_install")); + assertEquals(0, recorder.count("invite_opened")); + assertEquals(Invites.MATCH_REFERRER, + recorder.first("invite_install").getParameters().get("match")); + } + + @FormTest + void aDirectOpenReportsAnOpenRatherThanAnInstall() { + RecordingProvider recorder = InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + recorder.clear(); + + Invites.handleResolution( + InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + Invites.MATCH_DIRECT, false); + + assertNotNull(recorder.first("invite_opened")); + assertEquals(0, recorder.count("invite_install")); + } + + @FormTest + void everyFunnelEventUsesTheReferralCategory() { + RecordingProvider recorder = InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invite invite = Invites.create(InviteRequest.create().build()); + Invites.reportShareResult(invite, ShareResult.sharedTo("com.whatsapp")); + Invites.handleResolution( + InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + Invites.MATCH_REFERRER, true); + Invites.conversion("signup"); + + assertTrue(recorder.events().size() >= 4, recorder.names().toString()); + for (AnalyticsEvent e : recorder.events()) { + assertEquals(Invites.CATEGORY, e.getCategory(), + e.getName() + " is not under the referral category"); + } + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteMintTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteMintTest.java new file mode 100644 index 00000000000..6980195825e --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteMintTest.java @@ -0,0 +1,159 @@ +/* + * 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.analytics.invite; + +import com.codename1.analytics.AnalyticsEvent; +import com.codename1.io.ConnectionRequest; +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.AfterEach; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InviteMintTest extends UITestBase { + + @AfterEach + void cleanUp() { + InviteTestSupport.tearDown(); + } + + @FormTest + void createReturnsUsableInviteWithNoNetwork() { + InviteTestSupport.freshInstall(); + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + + Invite invite = Invites.create(InviteRequest.create() + .campaign("spring").channel("sms").build()); + + // The whole point of minting on the device: an invite is shareable the + // instant it is asked for, on a plane, in a queue, at a conference. + assertNotNull(invite); + assertNotNull(invite.getCode()); + assertTrue(invite.getUrl().startsWith("https://"), invite.getUrl()); + assertTrue(invite.getUrl().endsWith("/i/" + invite.getCode()), invite.getUrl()); + assertEquals("spring", invite.getCampaign()); + assertEquals("sms", invite.getChannel()); + assertFalse(invite.isRegistered()); + } + + @FormTest + void codesAreUrlSafeAndDistinct() { + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Set seen = new HashSet(); + for (int i = 0; i < 200; i++) { + String code = Invites.create(InviteRequest.create().build()).getCode(); + assertTrue(seen.add(code), "duplicate code " + code); + for (int j = 0; j < code.length(); j++) { + char c = code.charAt(j); + boolean ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') || c == '-' || c == '_'; + assertTrue(ok, "code is not url safe: " + code); + } + } + } + + @FormTest + void createQueuesRegistrationCarryingTheCodeAndIdentity() { + InviteTestSupport.freshInstall(); + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + + Invite invite = Invites.create(InviteRequest.create() + .campaign("spring").channel("sms").payload("room-42").build()); + + List requests = implementation.getQueuedRequests(); + assertEquals(1, requests.size(), "expected exactly one registration post"); + ConnectionRequest r = requests.get(0); + assertTrue(r.getUrl().endsWith("/api/v2/analytics/invites"), r.getUrl()); + assertTrue(r.isPost()); + String body = r.getRequestBody(); + assertTrue(body.contains(invite.getCode()), body); + // mapToJson pretty prints, so compare with the whitespace removed + // rather than pinning the exact rendering. + String compact = body.replace(" ", "").replace("\n", ""); + assertTrue(compact.contains("\"campaign\":\"spring\""), body); + assertTrue(compact.contains("\"channel\":\"sms\""), body); + assertTrue(compact.contains("\"payload\":\"room-42\""), body); + assertTrue(compact.contains("\"clientId\":"), body); + } + + @FormTest + void createEmitsInviteCreatedUnderTheReferralCategory() { + RecordingProvider recorder = InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + + Invite invite = Invites.create(InviteRequest.create().campaign("spring").build()); + + AnalyticsEvent e = recorder.first("invite_created"); + assertNotNull(e, "expected invite_created, saw " + recorder.names()); + assertEquals(Invites.CATEGORY, e.getCategory()); + assertEquals(invite.getCode(), e.getParameters().get("invite_code")); + assertEquals("spring", e.getParameters().get("campaign")); + } + + @FormTest + void linkBaseIsOverridable() { + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.setLinkBase("https://links.example.com/"); + + Invite invite = Invites.create(InviteRequest.create().build()); + + // The trailing slash on the configured base must not survive into the + // url, or every link would carry a double slash. + assertEquals("https://links.example.com/i/" + invite.getCode(), invite.getUrl()); + } + + @FormTest + void builderRejectsBadInputAtTheCallTheDeveloperCanSee() { + StringBuilder tooLong = new StringBuilder(); + for (int i = 0; i <= InviteRequest.MAX_PAYLOAD_LENGTH; i++) { + tooLong.append('x'); + } + final String payload = tooLong.toString(); + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + new org.junit.jupiter.api.function.Executable() { + public void execute() { + InviteRequest.create().payload(payload).build(); + } + }); + assertTrue(e.getMessage().contains("payload"), e.getMessage()); + + IllegalArgumentException e2 = assertThrows(IllegalArgumentException.class, + new org.junit.jupiter.api.function.Executable() { + public void execute() { + InviteRequest.create().campaign("spring sale!").build(); + } + }); + assertTrue(e2.getMessage().contains("campaign"), e2.getMessage()); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java new file mode 100644 index 00000000000..c07741294db --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java @@ -0,0 +1,77 @@ +/* + * 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.analytics.invite; + +import com.codename1.analytics.Analytics; +import com.codename1.analytics.AnalyticsConsent; +import com.codename1.analytics.ConsentMode; +import com.codename1.io.Preferences; + +/** + * Puts the static invite state back to a fresh-install baseline. Invites keeps + * process-wide state on purpose -- it models one device -- so every case has to + * start from a known point or the order tests run in changes their result. + */ +final class InviteTestSupport { + private InviteTestSupport() { + } + + static RecordingProvider freshInstall() { + Analytics.clearProviders(); + Analytics.clearDimensions(); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.granted()); + Invites.setInviteListener(null); + Invites.setLinkBase(null); + Invites.setReattribution(false); + Invites.setAttributionWindow(Invites.DEFAULT_ATTRIBUTION_WINDOW); + Invites.registerInstallReferrerSource(null); + Invites.reset(); + Preferences.delete(Invites.PREF_SLUG); + Preferences.delete(Invites.PREF_CONSUMED_ARG); + // reset() clears the records; clearProviders() above dropped the + // provider Invites registers, and the next facade call re-adds it. + RecordingProvider recorder = new RecordingProvider(); + Analytics.addProvider(recorder); + return recorder; + } + + static void tearDown() { + Invites.setInviteListener(null); + Invites.registerInstallReferrerSource(null); + Invites.reset(); + Analytics.clearProviders(); + Analytics.clearDimensions(); + Analytics.setConsent(null); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Preferences.delete(Invites.PREF_SLUG); + Preferences.delete(Invites.PREF_CONSUMED_ARG); + } + + /** A canned server answer, in the shape the link service returns. */ + static String resolvedJson(String code, String campaign, String channel) { + return "{\"resolved\":true,\"code\":\"" + code + "\",\"campaign\":\"" + + campaign + "\",\"channel\":\"" + channel + + "\",\"score\":100,\"clickTs\":1700000000000}"; + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java new file mode 100644 index 00000000000..caf5f415be7 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java @@ -0,0 +1,100 @@ +/* + * 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.analytics.invite; + +import com.codename1.io.Preferences; +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; +import org.junit.jupiter.api.AfterEach; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class InviteUrlParsingTest extends UITestBase { + + @AfterEach + void cleanUp() { + InviteTestSupport.tearDown(); + } + + @FormTest + void recognisesTheSluggedAndBareLinkForms() { + InviteTestSupport.freshInstall(); + assertEquals("ABC123", + Invites.extractCode("https://cloud.codenameone.com/i/acme/ABC123")); + // The slug is remembered so later invites mint the precise form, which + // is what keeps two enrolled apps on one device from claiming each + // other's links. + assertEquals("acme", Preferences.get(Invites.PREF_SLUG, "")); + + InviteTestSupport.freshInstall(); + assertEquals("ABC123", + Invites.extractCode("https://cloud.codenameone.com/i/ABC123")); + } + + @FormTest + void ignoresAForeignHostEvenWhenThePathMatches() { + InviteTestSupport.freshInstall(); + assertNull(Invites.extractCode("https://evil.example.com/i/acme/ABC123")); + // A prefix of our host is not our host. Matching on startsWith here + // would accept a look-alike domain. + assertNull(Invites.extractCode("https://cloud.codenameone.com.evil.test/i/ABC123")); + assertNull(Invites.extractCode("https://staging.cloud.codenameone.com/i/ABC123")); + } + + @FormTest + void hostComparisonIsCaseInsensitiveWithoutCaseFolding() { + InviteTestSupport.freshInstall(); + assertEquals("ABC123", + Invites.extractCode("https://CLOUD.CodenameOne.COM/i/ABC123")); + } + + @FormTest + void readsTheCodeOutOfAReferrerQueryString() { + InviteTestSupport.freshInstall(); + assertEquals("ABC123", Invites.codeFromQuery( + "utm_source=cn1_invite&utm_medium=referral&cn1_invite=ABC123")); + assertEquals("ABC123", Invites.codeFromQuery("cn1_invite=ABC123")); + assertNull(Invites.codeFromQuery("utm_source=cn1_invite&utm_medium=referral")); + assertNull(Invites.codeFromQuery("")); + assertNull(Invites.codeFromQuery(null)); + } + + @FormTest + void theReferrerKeyIsMatchedExactlyAndNeverCaseFolded() { + InviteTestSupport.freshInstall(); + // String.toLowerCase is locale sensitive and has no root-locale + // overload in this runtime, so under a Turkish default locale the 'i' + // in "invite" folds to a dotless i and a folded comparison silently + // stops matching. The key is therefore compared with equals, and a + // differently cased key is simply not our key. + assertNull(Invites.codeFromQuery("CN1_INVITE=ABC123")); + assertNull(Invites.codeFromQuery("Cn1_Invite=ABC123")); + } + + @FormTest + void valueIsSplitOnTheFirstEqualsOnly() { + InviteTestSupport.freshInstall(); + assertEquals("a=b", Invites.codeFromQuery("cn1_invite=a%3Db")); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/RecordingProvider.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/RecordingProvider.java new file mode 100644 index 00000000000..b4229b01026 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/RecordingProvider.java @@ -0,0 +1,88 @@ +/* + * 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.analytics.invite; + +import com.codename1.analytics.AbstractAnalyticsProvider; +import com.codename1.analytics.AnalyticsCapability; +import com.codename1.analytics.AnalyticsEvent; +import java.util.ArrayList; +import java.util.List; + +/** + * Captures whole AnalyticsEvent objects rather than the rendered strings + * LoggingAnalyticsProvider keeps, so a test can assert on the category and on + * individual parameter values. + */ +class RecordingProvider extends AbstractAnalyticsProvider { + private final List events = new ArrayList(); + + @Override + public String getName() { + return "recording"; + } + + @Override + public void trackEvent(AnalyticsEvent event) { + events.add(event); + } + + @Override + public boolean supports(AnalyticsCapability capability) { + return true; + } + + List events() { + return events; + } + + void clear() { + events.clear(); + } + + AnalyticsEvent first(String name) { + for (AnalyticsEvent e : events) { + if (name.equals(e.getName())) { + return e; + } + } + return null; + } + + int count(String name) { + int n = 0; + for (AnalyticsEvent e : events) { + if (name.equals(e.getName())) { + n++; + } + } + return n; + } + + List names() { + List out = new ArrayList(); + for (AnalyticsEvent e : events) { + out.add(e.getName()); + } + return out; + } +} From 5b44d6bea89a37c955a6c5a028b21c659005f55e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 06:01:15 +0300 Subject: [PATCH 03/99] Invite attribution: declare the build hints the link plumbing will read Three hints, all in the catalog rather than on the annotations, so each stays beside the hint it composes with -- ios.associatedDomains and android.xintent_filter are both catalog-only today, and splitting one feature's hints across two declaration files is how they drift. invite.domain is deliberately platform-general: both builders read it, and the link service it names has to agree with the apple-app-site-association and assetlinks.json served from that host. There is no hint to turn invites on. The class scan is the switch, through the PlatformFeatureCatalog entry -- a second source of truth for the same fact is a second thing to keep in sync. android.invite.signingFingerprint carries the Play App Signing warning in its own doc text because the failure has no other surface: Google re-signs the app, so verifying against the upload key the build holds means autoVerify fails on every Play install, the link opens Chrome, and nothing reports an error. --- .../build/shared/BuildHintsAndroid.java | 23 +++++++++++++++++++ .../build/shared/BuildHintsGeneral.java | 11 +++++++++ .../codename1/build/shared/BuildHintsIos.java | 12 ++++++++++ 3 files changed, 46 insertions(+) diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java index 0b9e29a3239..9645eeb6ef0 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsAndroid.java @@ -1232,6 +1232,29 @@ static void register(List h) { .platform("android") .doc("Allows adding an intent filter to the main android activity")); + h.add(new Hint("android.invite.appLinks") + .group(HintGroup.ANDROID) + .type(HintType.BOOLEAN) + .def("true") + .platform("android") + .doc("Whether the build injects the `android:autoVerify` intent filter for the " + + "invite link domain into the main activity. Set it to `false` only when " + + "declaring the filter yourself through `android.xintent_filter`; with no " + + "filter at all an invite link opens the browser instead of the app.")); + + h.add(new Hint("android.invite.signingFingerprint") + .group(HintGroup.ANDROID) + .type(HintType.STRING_LIST) + .separator(",") + .platform("android") + .doc("Comma separated SHA-256 signing certificate fingerprints, in colon separated " + + "hex, enrolled in the shared `assetlinks.json` alongside the one derived " + + "from the build's keystore. Apps distributed through Play App Signing " + + "must add the app signing certificate fingerprint from the Play Console " + + "here: Google re-signs the app, so the upload key the build holds is not " + + "the certificate Android verifies against, and App Links verification " + + "fails silently on every Play install without it.")); + h.add(new Hint("android.xlargeScreens") .group(HintGroup.ANDROID) .type(HintType.BOOLEAN) diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsGeneral.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsGeneral.java index 0735e8d6afe..bd16e6abb16 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsGeneral.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsGeneral.java @@ -52,6 +52,17 @@ static void register(List h) { .doc("Whether video calls are offered, on both platforms. `ios.call.video` and " + "`android.call.video` override it per platform.")); + h.add(new Hint("invite.domain") + .group(HintGroup.GENERAL) + .type(HintType.STRING) + .def("cloud.codenameone.com") + .platform("general") + .doc("The host that serves Codename One invite links " + + "(`https:///i//`). Changing it points the generated " + + "Android App Link intent filter and the iOS associated domain at a " + + "different link service; the matching `apple-app-site-association` and " + + "`assetlinks.json` must be served from that host.")); + h.add(new Hint("KeepScreenOn") .group(HintGroup.GENERAL) .type(HintType.BOOLEAN) diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java index 2bb6c9baed6..ded5926d606 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java @@ -209,6 +209,18 @@ static void register(List h) { .doc("Objective-C code that can be injected into the iOS callback method (message) " + "`applicationDidEnterBackground`.")); + h.add(new Hint("ios.invite.universalLinks") + .group(HintGroup.IOS) + .type(HintType.BOOLEAN) + .def("true") + .platform("ios") + .doc("Whether the build appends `applinks:` for the invite link domain to " + + "`ios.associatedDomains` and requests the matching " + + "`com.apple.developer.associated-domains` entitlement. Set it to `false` " + + "to manage both yourself. The provisioning profile must grant the " + + "Associated Domains capability either way, or invite links silently " + + "open Safari instead of the app.")); + h.add(new Hint("ios.associatedDomains") .group(HintGroup.IOS) .type(HintType.STRING) From 8f64c9299ffb6b87a68dae672554f32084e8ed00 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 06:10:16 +0300 Subject: [PATCH 04/99] Invite attribution: make an invite link open the app, on both platforms Both builders now detect com.codename1.analytics.invite (and the InviteButton that fronts it) in the class scan, and wire the platform side. Android gets an autoVerify App Links intent filter, appended to android.xintent_filter rather than emitted at a new manifest site. That hint is already rendered inside the main , and rendered a second time into the wear companion manifest, so one append reaches both and cannot drift the way two injection sites would. It is the repo's first use of autoVerify. The build is REFUSED when android.activity.launchMode is "standard", rather than warned. With singleTop (the default) or singleTask a link reaches the running activity through onNewIntent; with standard it starts a second activity and the invite is simply lost. A warning in a build log is the thing nobody reads, and the symptom on the device is a feature that silently never fires. iOS appends applinks: to ios.associatedDomains. The placement is load-bearing and commented as such: the block that uncomments CN1_HANDLE_UNIVERSAL_LINKS tests only whether that hint is non-null, so appending one line later would leave the define commented out -- entitlement present, handler not compiled in, every link opening Safari. The matching associated-domains entitlement is derived from the same hint downstream, so it is deliberately not written separately: a duplicate key fails codesigning. Both duplicate-suppression checks compare whole delimited tokens rather than substrings, because the failure is asymmetric and silent -- a developer's staging entry for a longer host would otherwise read as declaring the production one, and they would ship an app whose invite links open the browser. Thirteen tests cover it, and the staging case was confirmed to fail against a naive contains() implementation. --- .../builders/AndroidGradleBuilder.java | 50 ++++++++ .../com/codename1/builders/IPhoneBuilder.java | 65 ++++++++++ .../builders/InviteManifestFragments.java | 120 ++++++++++++++++++ .../builders/InviteAssociatedDomainTest.java | 85 +++++++++++++ .../builders/InviteManifestFragmentsTest.java | 110 ++++++++++++++++ 5 files changed, 430 insertions(+) create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteManifestFragments.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteAssociatedDomainTest.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteManifestFragmentsTest.java diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index f5b03dba893..11e91f5c04e 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -808,6 +808,10 @@ private java.util.Set foldInCallAndVpnLibraryUsage( /// Whether the app referenced com.codename1.vpn.tunnel. private boolean usesCustomTunnel; + /// Whether the app referenced the invite attribution API, and therefore + /// needs the App Links filter that lets an invite link open it. + private boolean usesInvites; + private boolean integrateMoPub = false; private static final boolean isMac; @@ -2302,6 +2306,13 @@ public void usesClass(String cls) { if (cls.indexOf("com/codename1/vpn/tunnel/") == 0) { usesCustomTunnel = true; } + // Both entry points, because an app can reference either + // one alone: the button without the facade, or the facade + // without the button. + if (cls.indexOf("com/codename1/analytics/invite/") == 0 + || "com/codename1/components/InviteButton".equals(cls)) { + usesInvites = true; + } if (cls.indexOf("com/codename1/nearby/ranging/") == 0) { usesNearbyRanging = true; } @@ -2948,6 +2959,45 @@ public void usesClassMethod(String cls, String method) { } } + // The App Links filter that lets an invite link open the app instead + // of the browser (com.codename1.analytics.invite). + // + // AFTER the class scan, beside the call fragments, because the flag it + // reads is set BY that scan -- the same ordering the tunnel block below + // documents the hard way. + // + // Appended to android.xintent_filter rather than emitted at a new + // manifest site. That hint is already rendered inside the main + // , and rendered again into the wear companion manifest, so + // one append reaches both and cannot drift. + if (usesInvites && "true".equals(request.getArg("android.invite.appLinks", "true"))) { + String inviteHost = request.getArg("invite.domain", "cloud.codenameone.com"); + String existingFilter = request.getArg("android.xintent_filter", ""); + String withAppLinks = + InviteManifestFragments.injectAppLinks(existingFilter, inviteHost); + if (!withAppLinks.equals(existingFilter)) { + debug("Invite attribution: adding the App Links filter for " + inviteHost); + request.putArgument("android.xintent_filter", withAppLinks); + } + // launchMode decides whether a link reaching an app that is + // already running is delivered to it at all. singleTop (the + // default) and singleTask both route through onNewIntent; + // "standard" starts a SECOND activity and a second lifecycle, and + // the invite is simply lost. Refused rather than warned: a warning + // in a build log is exactly the thing nobody reads, and the + // symptom on the device is a feature that silently never fires. + String launchMode = request.getArg("android.activity.launchMode", "singleTop"); + if ("standard".equals(launchMode)) { + throw new BuildException("This app uses invite attribution " + + "(com.codename1.analytics.invite), which needs an invite link to reach " + + "the running activity, but android.activity.launchMode is \"standard\". " + + "A link then starts a second activity instead of being delivered to the " + + "running one, and the invite is lost. Use singleTop (the default) or " + + "singleTask, or set android.invite.appLinks=false and handle the link " + + "yourself."); + } + } + // A packet tunnel the app implements (com.codename1.vpn.tunnel). // // AFTER the class scan, beside the call fragments, because the flag diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index af84a03d582..3f1a257539d 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -1351,6 +1351,11 @@ private java.util.Set foldInCallAndVpnLibraryUsage( // other hand would fail its codesigning for a capability it never asked for. private boolean usesContinuitySync; + // Set when the app references com.codename1.analytics.invite (or the + // InviteButton that fronts it). Gates the associated domain and the + // entitlement that let an invite link open the app instead of Safari. + private boolean usesInvites; + // Set when the app references com.codename1.documents. Gates the CN1_USE_DOCUMENTS native // define, the CN1Documents file provider extension and the app group that lets the two // processes meet. @@ -1492,6 +1497,28 @@ public void cleanup() { /// Records a boolean CarPlay entitlement (e.g. com.apple.developer.carplay-audio) unless the /// project already set it explicitly, mirroring how the App Attest / Apple Sign-In entitlements /// are injected. The downstream entitlements generator emits these as <true/>. + /// Whether a comma delimited ios.associatedDomains value already declares + /// `domain`. + /// + /// Compared element by element after trimming, never as a substring: an + /// existing `applinks:staging.cloud.codenameone.com` CONTAINS + /// `applinks:cloud.codenameone.com` is false, but the reverse containment + /// -- an existing entry for a longer host reading as the shorter one -- + /// is exactly the mistake the surfaces url-scheme code documents, and the + /// same shape of bug applies here. + static boolean declaresAssociatedDomain(String existing, String domain) { + if (existing == null || domain == null) { + return false; + } + StringTokenizer tok = new StringTokenizer(existing, ","); + while (tok.hasMoreTokens()) { + if (tok.nextToken().trim().equals(domain)) { + return true; + } + } + return false; + } + private void putCarPlayEntitlement(BuildRequest request, String key) { if (request.getArg("ios.entitlements." + key, null) == null) { request.putArgument("ios.entitlements." + key, "true"); @@ -2733,6 +2760,14 @@ public void usesClass(String cls) { if (!usesDocuments && cls.indexOf("com/codename1/documents/") == 0) { usesDocuments = true; } + // Invite attribution (com.codename1.analytics.invite). Both entry points, + // because an app can reference either alone: the button without the facade, + // or the facade without the button. + if (!usesInvites + && (cls.indexOf("com/codename1/analytics/invite/") == 0 + || "com/codename1/components/InviteButton".equals(cls))) { + usesInvites = true; + } // State restoration and continuity (com.codename1.continuity.*). Gated on // actual usage so the CN1_USE_CONTINUITY natives and the NSUserActivityTypes // entry are only added for apps that hand work between devices. @@ -4345,6 +4380,36 @@ public void usesClassMethod(String cls, String method) { File CodenameOne_GLViewController_m = new File(buildinRes, "CodenameOne_GLViewController.m"); replaceInFile(CodenameOne_GLViewController_m, "BOOL vkbAlwaysOpen = NO;", "BOOL vkbAlwaysOpen = YES;"); } + // Invite attribution needs an invite link to open the app rather + // than Safari, which on iOS means a universal link, which means the + // invite host has to be an associated domain. + // + // This MUST run before the block below. That block's only test is + // whether ios.associatedDomains is non-null, and it is what + // uncomments CN1_HANDLE_UNIVERSAL_LINKS in + // CodenameOne_GLViewController.h. Appending one line later would + // leave the define commented out: the entitlement would be present, + // application:continueUserActivity:restorationHandler: would not be + // compiled in, and every invite link would silently open the + // browser. + // + // The matching com.apple.developer.associated-domains entitlement + // is derived from this same hint by the entitlements generator, so + // it is not written separately here -- doing that would risk a + // duplicate key, which fails codesigning. + if (usesInvites + && "true".equals(request.getArg("ios.invite.universalLinks", "true"))) { + String inviteHost = request.getArg("invite.domain", "cloud.codenameone.com"); + String want = "applinks:" + inviteHost; + String existingDomains = request.getArg("ios.associatedDomains", ""); + if (!declaresAssociatedDomain(existingDomains, want)) { + String merged = existingDomains.trim().length() == 0 + ? want : existingDomains + "," + want; + debug("Invite attribution: adding the associated domain " + want); + request.putArgument("ios.associatedDomains", merged); + } + } + if (request.getArg("ios.associatedDomains", null) != null) { // If the user has provided the ios.associatedDomains build hint, then we will need to // enable handling for these events. diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteManifestFragments.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteManifestFragments.java new file mode 100644 index 00000000000..f398222fa4f --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteManifestFragments.java @@ -0,0 +1,120 @@ +/* + * 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.builders; + +/** + * Builds the App Links intent filter injected into the main activity when the + * bytecode scanner detects usage of {@code com.codename1.analytics.invite}. + * + *

Extracted into a pure static helper for the reason + * {@link CallManifestFragments} gives: the nuances are unit-testable here and + * the BuildDaemon copy stays trivially diffable -- keep this file in sync + * with {@code com.codename1.build.daemon.InviteManifestFragments}.

+ * + *

Why this is here rather than in {@code PlatformFeatureCatalog}: the + * catalog has no manifest-fragment channel at all. It can name a permission, a + * feature or a meta-data pair, and an {@code } carrying + * {@code android:autoVerify} is none of those.

+ * + *

The fragment is appended to the {@code android.xintent_filter} build hint + * rather than emitted at a new manifest site. That hint is already rendered + * inside the main {@code }, and it is rendered a second time into + * the wear companion manifest -- so appending to it reaches both, and cannot + * drift the way two separate injection sites would.

+ */ +final class InviteManifestFragments { + + /** + * Bumped when the fragment changes, so a build log names which version + * produced a manifest. + */ + static final int FRAGMENT_VERSION = 1; + + private InviteManifestFragments() { + } + + /** + * Returns {@code existing} with the invite App Links filter appended, or + * {@code existing} unchanged when the host is already declared. + * + * @param existing the current {@code android.xintent_filter} value + * @param host the invite link host, for example + * {@code cloud.codenameone.com} + * @return the value to put back on the hint + */ + static String injectAppLinks(String existing, String host) { + String current = existing == null ? "" : existing; + if (host == null || host.length() == 0) { + return current; + } + if (declaresHost(current, host)) { + return current; + } + return current + filter(host); + } + + /** + * Whether {@code existing} already declares an intent filter for + * {@code host}. + * + *

Matched as a whole quoted attribute rather than as a substring. A + * plain {@code contains(host)} would read + * {@code android:host="staging.cloud.codenameone.com"} as already + * declaring {@code cloud.codenameone.com}, and the developer's staging + * filter would suppress the production one.

+ * + * @param existing the current hint value + * @param host the host to look for + * @return true when the host is already declared + */ + static boolean declaresHost(String existing, String host) { + if (existing == null || host == null) { + return false; + } + return existing.indexOf("android:host=\"" + host + "\"") >= 0; + } + + /** + * The filter itself. + * + *

{@code android:autoVerify="true"} is what makes Android open the app + * instead of the browser without a disambiguation dialog. It only takes + * effect if the host serves an {@code assetlinks.json} naming this + * application's package and the SHA-256 of the certificate the installed + * APK is really signed with -- which, under Play App Signing, is Google's + * key and not the upload key.

+ * + * @param host the invite link host + * @return the intent filter XML + */ + static String filter(String host) { + return "\n \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n"; + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteAssociatedDomainTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteAssociatedDomainTest.java new file mode 100644 index 00000000000..a42d1e2e567 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteAssociatedDomainTest.java @@ -0,0 +1,85 @@ +/* + * 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.builders; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the element-wise comparison used before appending the invite + * associated domain to {@code ios.associatedDomains}. + * + *

The value is a comma delimited list, and a substring test on it gets the + * wrong answer in both directions: a developer's entry for a longer host would + * read as declaring ours, and ours would read as declaring theirs.

+ */ +class InviteAssociatedDomainTest { + + private static final String WANT = "applinks:cloud.codenameone.com"; + + @Test + void anExactElementIsRecognised() { + assertTrue(IPhoneBuilder.declaresAssociatedDomain(WANT, WANT)); + assertTrue(IPhoneBuilder.declaresAssociatedDomain( + "webcredentials:example.com," + WANT, WANT)); + assertTrue(IPhoneBuilder.declaresAssociatedDomain( + WANT + ",applinks:example.com", WANT)); + } + + @Test + void whitespaceAroundAnElementDoesNotHideIt() { + assertTrue(IPhoneBuilder.declaresAssociatedDomain( + "applinks:example.com, " + WANT + " ", WANT)); + } + + @Test + void aLongerHostDoesNotReadAsOurs() { + // The trap: applinks:staging.cloud.codenameone.com must not suppress + // the production domain, or a developer with a staging entry ships an + // app whose invite links open Safari. + assertFalse(IPhoneBuilder.declaresAssociatedDomain( + "applinks:staging.cloud.codenameone.com", WANT)); + } + + @Test + void aShorterHostDoesNotReadAsOursEither() { + assertFalse(IPhoneBuilder.declaresAssociatedDomain( + "applinks:codenameone.com", WANT)); + } + + @Test + void aDifferentPrefixForTheSameHostIsNotTheSameDeclaration() { + // webcredentials: on our host grants password autofill, not links. + assertFalse(IPhoneBuilder.declaresAssociatedDomain( + "webcredentials:cloud.codenameone.com", WANT)); + } + + @Test + void emptyAndNullAreHandled() { + assertFalse(IPhoneBuilder.declaresAssociatedDomain("", WANT)); + assertFalse(IPhoneBuilder.declaresAssociatedDomain(null, WANT)); + assertFalse(IPhoneBuilder.declaresAssociatedDomain(WANT, null)); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteManifestFragmentsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteManifestFragmentsTest.java new file mode 100644 index 00000000000..efec377b326 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteManifestFragmentsTest.java @@ -0,0 +1,110 @@ +/* + * 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.builders; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies the App Links intent filter injected for the + * {@code com.codename1.analytics.invite} API, and in particular the + * quote-delimited duplicate suppression that keeps a developer's own filter + * for a DIFFERENT host from suppressing ours. + */ +class InviteManifestFragmentsTest { + + private static final String HOST = "cloud.codenameone.com"; + + @Test + void filterCarriesAutoVerifyAndTheInvitePath() { + String out = InviteManifestFragments.injectAppLinks("", HOST); + // autoVerify is what makes Android open the app rather than showing a + // disambiguation dialog, and it is the whole point of the filter. + assertTrue(out.contains("android:autoVerify=\"true\""), out); + assertTrue(out.contains("android:name=\"android.intent.action.VIEW\""), out); + assertTrue(out.contains("android:name=\"android.intent.category.BROWSABLE\""), out); + assertTrue(out.contains("android:scheme=\"https\""), out); + assertTrue(out.contains("android:host=\"" + HOST + "\""), out); + assertTrue(out.contains("android:pathPrefix=\"/i/\""), out); + } + + @Test + void aDevelopersOwnFilterIsPreservedAndOursIsAppended() { + String existing = "" + + "" + + "" + + ""; + String out = InviteManifestFragments.injectAppLinks(existing, HOST); + assertTrue(out.startsWith(existing), "the developer's filter must survive verbatim"); + assertTrue(out.contains("android:host=\"" + HOST + "\""), out); + } + + @Test + void anAlreadyDeclaredHostIsNotDeclaredTwice() { + String existing = InviteManifestFragments.injectAppLinks("", HOST); + String out = InviteManifestFragments.injectAppLinks(existing, HOST); + assertEquals(existing, out, "the host was declared a second time"); + } + + @Test + void aDifferentHostThatContainsOursDoesNotSuppressIt() { + // The trap this test exists for: a plain contains(host) check reads + // android:host="staging.cloud.codenameone.com" as already declaring + // cloud.codenameone.com, so a developer with a staging filter would + // silently ship without the production one and every invite link would + // open the browser. + String existing = "" + + "" + + ""; + assertFalse(InviteManifestFragments.declaresHost(existing, HOST), + "a longer host must not read as ours"); + String out = InviteManifestFragments.injectAppLinks(existing, HOST); + assertTrue(out.contains("android:host=\"" + HOST + "\""), + "the production filter was suppressed by a staging one"); + } + + @Test + void aHostThatIsAPrefixOfOursDoesNotSuppressItEither() { + String existing = ""; + assertFalse(InviteManifestFragments.declaresHost(existing, HOST)); + } + + @Test + void anEmptyHostInjectsNothing() { + assertEquals("", InviteManifestFragments.injectAppLinks("", "")); + assertEquals("", InviteManifestFragments.injectAppLinks("", null)); + assertEquals("x", InviteManifestFragments.injectAppLinks("x", null)); + } + + @Test + void aCustomHostIsHonoured() { + String out = InviteManifestFragments.injectAppLinks("", "links.example.com"); + assertTrue(out.contains("android:host=\"links.example.com\""), out); + assertFalse(out.contains(HOST), out); + } +} From 76b058b35220ba92ae98e08c65741a6f98817b9b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 06:22:18 +0300 Subject: [PATCH 05/99] Invite attribution: read the Play Install Referrer, and drop a floor that was never there Adds the deterministic Android path. The link service puts cn1_invite= on the Play url, the store hands it back on first launch, and the code is claimed verbatim -- no matching, no guessing. The implementation is a port source excluded from the port jar's compile and compiled inside the generated app, the mechanism ar/ai/cipher/nearby already use, with the builder deleting the package for apps that did not ask. Deliberately not a generated string literal like the Firebase bridge: this owns a connection lifecycle, a reconnect path, a bounded retry and once-only bookkeeping, and as a literal it would be invisible to review and to SpotBugs. Registration is spliced beside the Firebase one as a direct symbol reference, so R8 renames call site and target together and there is no keep rule to forget. FEATURE_NOT_SUPPORTED -- no Play Store, a sideload, another vendor's store -- is surfaced as an ordinary "no referral" answer, not an error and not silence. The correction: the plan asserted this dependency carries a minSdk 21 floor, and it does not. Reading the actual artifact rather than trusting the assumption, installreferrer 2.2 (the newest release) declares minSdkVersion 8 in its own manifest. The catalog entry now sets no floor, because adding one would have dropped API 19 and 20 devices from every invite app's Play listing for no reason. The aar also contributes its own BIND_GET_INSTALL_REFERRER_SERVICE permission, so none is declared here. The package boundary still earns its keep -- it keeps the dependency and that permission off every app that merely reports analytics. --- .../analytics/invite/package-info.java | 7 +- .../referrer/AndroidInstallReferrer.java | 159 ++++++++++++++++++ maven/android/pom.xml | 10 ++ .../builders/AndroidGradleBuilder.java | 34 ++++ .../build/shared/PlatformFeatureCatalog.java | 30 ++-- .../shared/PlatformFeatureCatalogTest.java | 9 +- 6 files changed, 232 insertions(+), 17 deletions(-) create mode 100644 Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java diff --git a/CodenameOne/src/com/codename1/analytics/invite/package-info.java b/CodenameOne/src/com/codename1/analytics/invite/package-info.java index d0b1827c0eb..af9d25cd49c 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/package-info.java +++ b/CodenameOne/src/com/codename1/analytics/invite/package-info.java @@ -58,7 +58,8 @@ /// /// This package is deliberately separate from /// {@link com.codename1.analytics}. The Android half of the attribution links -/// the Play Install Referrer library, which raises the application's minimum -/// API level, and the build only does that for applications that actually -/// reference this package. +/// the Play Install Referrer library and declares the permission that binds to +/// it, and the build only does that for applications that actually reference +/// this package -- an application that merely reports analytics carries +/// neither. package com.codename1.analytics.invite; diff --git a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java new file mode 100644 index 00000000000..8125fecbaa5 --- /dev/null +++ b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java @@ -0,0 +1,159 @@ +/* + * 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.referrer; + +import android.content.Context; +import com.android.installreferrer.api.InstallReferrerClient; +import com.android.installreferrer.api.InstallReferrerStateListener; +import com.android.installreferrer.api.ReferrerDetails; +import com.codename1.analytics.invite.InstallReferrerCallback; +import com.codename1.analytics.invite.InstallReferrerSource; +import com.codename1.analytics.invite.Invites; +import com.codename1.impl.android.AndroidNativeUtil; +import com.codename1.io.Log; +import com.codename1.io.Preferences; + +/// Reads the Play Store install referrer, which is the deterministic half of +/// invite attribution on Android: the invite code makes the whole round trip +/// through the store and comes back verbatim, so nothing has to be matched or +/// guessed. +/// +/// Compiled inside the generated application rather than into the port jar, +/// because it names `com.android.installreferrer`, which +/// `PlatformFeatureCatalog` adds only for an application that referenced the +/// invite package. `AndroidGradleBuilder` deletes this package for every other +/// application, and splices the registration call for the ones that kept it. +public class AndroidInstallReferrer implements InstallReferrerSource { + // The referrer is retained by Google for the life of the install and + // returns the same answer every time, so one successful read is enough + // and the flag is what stops a service bind on every launch. + private static final String PREF_ATTEMPTED = "cn1$invite$referrerAttempted"; + + private boolean retried; + + @Override + public boolean isSupported() { + return AndroidNativeUtil.getContext() != null + && !Preferences.get(PREF_ATTEMPTED, false); + } + + @Override + public void requestReferrer(InstallReferrerCallback callback) { + Context context = AndroidNativeUtil.getContext(); + if (context == null) { + callback.onUnavailable(Invites.REASON_UNSUPPORTED); + return; + } + try { + connect(InstallReferrerClient.newBuilder(context).build(), callback); + } catch (Throwable t) { + // A missing or broken store client must read as "no referral", + // never as a crash: the application still works, it simply has no + // invite behind it. + Log.e(t); + finish(callback, Invites.REASON_UNSUPPORTED); + } + } + + private void connect(final InstallReferrerClient client, + final InstallReferrerCallback callback) { + client.startConnection(new InstallReferrerStateListener() { + @Override + public void onInstallReferrerSetupFinished(int responseCode) { + try { + switch (responseCode) { + case InstallReferrerClient.InstallReferrerResponse.OK: + deliver(client, callback); + break; + case InstallReferrerClient.InstallReferrerResponse.SERVICE_UNAVAILABLE: + // Transient. Exactly one retry: a loop here would + // bind the service repeatedly on a device that is + // never going to answer. + if (!retried) { + retried = true; + close(client); + requestReferrer(callback); + return; + } + finish(callback, Invites.REASON_NO_MATCH); + break; + default: + // FEATURE_NOT_SUPPORTED is the ordinary answer on a + // device with no Play Store -- a sideload, an + // emulator without store services, another vendor's + // store. Terminal, and not an error. + finish(callback, Invites.REASON_UNSUPPORTED); + break; + } + } catch (Throwable t) { + Log.e(t); + finish(callback, Invites.REASON_UNSUPPORTED); + } finally { + close(client); + } + } + + @Override + public void onInstallReferrerServiceDisconnected() { + // Deliberately not reconnecting. The one retry above is the + // whole allowance; an automatic reconnect here is how a + // background service bind loop starts. + } + }); + } + + private void deliver(InstallReferrerClient client, InstallReferrerCallback callback) { + String referrer = ""; + long clickSeconds = 0; + long beginSeconds = 0; + try { + ReferrerDetails details = client.getInstallReferrer(); + if (details != null) { + referrer = details.getInstallReferrer(); + clickSeconds = details.getReferrerClickTimestampSeconds(); + beginSeconds = details.getInstallBeginTimestampSeconds(); + } + } catch (Throwable t) { + Log.e(t); + } + Preferences.set(PREF_ATTEMPTED, true); + if (referrer == null || referrer.length() == 0) { + callback.onUnavailable(Invites.REASON_NO_MATCH); + return; + } + callback.onReferrer(referrer, clickSeconds, beginSeconds); + } + + private void finish(InstallReferrerCallback callback, String reason) { + Preferences.set(PREF_ATTEMPTED, true); + callback.onUnavailable(reason); + } + + private void close(InstallReferrerClient client) { + try { + client.endConnection(); + } catch (Throwable t) { + Log.e(t); + } + } +} diff --git a/maven/android/pom.xml b/maven/android/pom.xml index adabb3acc30..fabd5551db3 100644 --- a/maven/android/pom.xml +++ b/maven/android/pom.xml @@ -124,6 +124,16 @@ the builder deletes whichever halves the app did not ask for. --> com/codename1/impl/android/nearby/** + + com/codename1/impl/android/referrer/** + entry in nbproject/project.properties and in maven/android/pom.xml. + + The referrer package is excluded for the ordinary reason: it names + com.android.installreferrer, a Play library the generated + application pulls in through PlatformFeatureCatalog and the port + jar never has. + + THE LIST LIVES IN THREE FILES: here, in + nbproject/project.properties, and in maven/android/pom.xml. The + Maven build stays green with only its own copy updated, so adding + 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/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 e4d6e4ef090..2b0451f5250 100644 --- a/Ports/Android/nbproject/project.properties +++ b/Ports/Android/nbproject/project.properties @@ -32,8 +32,11 @@ endorsed.classpath= # The BiometricPrompt backend is the same arrangement against a newer SDK # rather than a missing dependency: android.hardware.biometrics is API 28 to 30 # and the cn1-binaries android.jar is API 27. -# Mirrors the maven-compiler excludes in maven/android/pom.xml. -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/biometrics/**,com/codename1/impl/android/BillingSupport.java +# Mirrors the maven-compiler excludes in maven/android/pom.xml AND the +# 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/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 From 5a71e814dbb44010723c86131863799d6f7c71e3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:56:51 +0300 Subject: [PATCH 16/99] Invites: six answers that reached nobody, or reached the wrong conclusion flush() retried the deferred lookup under the same epoch, so a fingerprint answer left outstanding by the previous attempt could land after the retried referrer resolved exactly and overwrite it. Genuinely concurrent on an application with more than one NetworkManager thread. The retry advances the epoch, as handleUrl now does. Granting consent after a refusal left the lookup stopped. STATE_DECLINED carries a reopenable marker precisely because granting afterwards is a real answer, but onConsentChanged only restarted STATE_PENDING -- so nothing happened until the application happened to call checkForInvite() again, by which time the attribution window may have closed. A terminal "no invite" answer reached before a listener was registered was dropped. The state prevents another lookup and setInviteListener only replays a resolved attribution, so the listener got neither callback for the whole install -- against the documented promise that an early answer is held and delivered on registration. It is held for the run now; the state itself is durable, so a later launch reaches the same answer through the ordinary path. A direct link refused on consent told the listener nothing, and checkForInvite marks the url consumed and skips the deferred path afterwards, so that was the only chance it had. On Android, a getInstallReferrer() that throws after the connection came up -- a service-side RemoteException -- still burned the once-only attempted flag, so isSupported() was false for ever and a statistical no-match could settle the install as organic for a referrer that was there all along. A throwing read is transient and is no longer recorded as an attempt. The manifest filter check ignored the scheme, so an http-only filter on the invite host and path suppressed the generated https one and every invite link kept opening the browser. The scheme is required in the same intent-filter, and a filter naming no scheme covers nothing, which is what Android does. Two more PMD NonThreadSafeSingleton shapes avoided the same way as loadState: the field is read into a local before the branch. Not a lock -- this facade runs on the EDT. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 60 ++++++++++++- .../referrer/AndroidInstallReferrer.java | 16 ++++ .../builders/InviteManifestFragments.java | 9 ++ .../builders/InviteManifestFragmentsTest.java | 30 ++++++- .../invite/InviteResilienceTest.java | 85 +++++++++++++++++++ 5 files changed, 194 insertions(+), 6 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 14efbeeb5a8..24fcbe95418 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -209,6 +209,11 @@ public final class Invites { private static int state = STATE_NONE; private static boolean stateLoaded; private static boolean deliveredThisRun; + + // A terminal "no invite" answer reached before a listener was registered. + // Held for the run rather than persisted: the state itself is durable, and + // a later launch reaches this answer again through the ordinary path. + private static String undelivered; private static boolean deferredStarted; // Bumped whenever the identity or the permission behind an outstanding @@ -460,7 +465,12 @@ public static boolean handleUrl(String url) { // this a refused user who opened an invite link still had a profile // persisted -- by a different route to the one that was fixed. if (explicitlyDenied()) { + // Told, not silently dropped. checkForInvite() records the url as + // consumed and skips the deferred path after this, so this is the + // only chance the listener gets for this install -- and a + // registered one heard nothing at all. markTerminal(STATE_DECLINED, REASON_CONSENT_DENIED); + notifyUnavailable(REASON_CONSENT_DENIED); return true; } if (getState() == STATE_RESOLVED && !reattribution) { @@ -520,6 +530,7 @@ private static void loadAttribution() { // private and test-only: the whole point of the durable records is that // the answer survives a relaunch, and nothing else can check that. static void forgetLoadedState() { + undelivered = null; stateLoaded = false; attributionLoaded = false; resolved = null; @@ -717,6 +728,14 @@ public static void flush() { // until the next cold start. The persisted attempt counter still // bounds the retries. if (getState() == STATE_PENDING) { + // The retry supersedes whatever the last attempt left outstanding. + // Without the bump, a fingerprint answer still on the wire from the + // earlier attempt passes the guard and can land AFTER the retried + // referrer resolved exactly -- overwriting the exact attribution + // with a statistical one. Not hypothetical on an application with + // more than one NetworkManager thread, where the two are genuinely + // concurrent. + lookupEpoch++; deferredStarted = false; beginDeferred(); } @@ -743,6 +762,7 @@ public static void reset() { stateLoaded = true; deliveredThisRun = false; deferredStarted = false; + undelivered = null; unacknowledged.clear(); } @@ -769,10 +789,18 @@ static void eraseInternal() { // Package private: called from the provider when consent changes. static void onConsentChanged(boolean allowed) { if (allowed) { - if (getState() == STATE_PENDING) { + // STATE_DECLINED belongs here too. It is the state a refusal during + // a pending lookup leaves behind, and its marker is reopenable + // precisely because granting consent afterwards is a real answer -- + // but nothing restarted the lookup until the application happened + // to call checkForInvite() again, by which time the attribution + // window may well have closed. beginDeferred() reopens the marker + // itself, so calling it is the whole fix. + int s = getState(); + if (s == STATE_PENDING || s == STATE_DECLINED) { deferredStarted = false; beginDeferred(); - } else if (getState() == STATE_RESOLVED) { + } else if (s == STATE_RESOLVED) { // Re-granting restores the dimensions from the record we kept, // without re-reporting the install or telling the app again. InviteAttribution a = getAttribution(); @@ -1635,6 +1663,15 @@ private static void deliverPending() { if (listener == null || deliveredThisRun) { return; } + // Taken into a local and cleared unconditionally, rather than + // null-checked in place and cleared inside the branch. Same reason as + // notifyUnavailable above. + String held = undelivered; + undelivered = null; + if (held != null) { + notifyUnavailable(held); + return; + } Map r = InviteStore.read(InviteStore.ATTRIBUTION); if (r == null || InviteStore.getBoolean(r, "delivered", false)) { return; @@ -1654,12 +1691,27 @@ private static void deliverPending() { } private static void notifyUnavailable(String reason) { - if (listener == null || deliveredThisRun) { + if (deliveredThisRun) { + return; + } + // Read into a local before the branch, for the same reason loadState() + // does: null-checking a static field and then assigning one inside the + // branch is the shape PMD reads as an unsynchronized lazy singleton, + // and the answer is not a lock -- this facade runs on the EDT. + InviteListener target = listener; + if (target == null) { + // Held, not dropped. The answer is terminal, so no later lookup + // will produce it again, and setInviteListener() only replays a + // resolved attribution -- so an application that answers the + // deferred question before registering its listener got neither + // callback for the whole install, against the documented promise + // that an early answer is delivered on registration. + undelivered = reason; return; } deliveredThisRun = true; try { - listener.attributionUnavailable(reason); + target.attributionUnavailable(reason); } catch (Throwable t) { Log.e(t); } diff --git a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java index e223106975e..798405f07f9 100644 --- a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java +++ b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java @@ -137,6 +137,7 @@ private void deliver(InstallReferrerClient client, InstallReferrerCallback callb String referrer = ""; long clickSeconds = 0; long beginSeconds = 0; + boolean threw = false; try { ReferrerDetails details = client.getInstallReferrer(); if (details != null) { @@ -145,8 +146,23 @@ private void deliver(InstallReferrerClient client, InstallReferrerCallback callb beginSeconds = details.getInstallBeginTimestampSeconds(); } } catch (Throwable t) { + // The connection came up and the read failed -- a RemoteException + // from the service, most often. That is the same kind of transient + // failure as a bind that never succeeded, and it is not evidence + // about whether a referrer exists. + threw = true; Log.e(t); } + if (threw) { + // Deliberately NOT recorded as attempted. Burning the once-only + // flag here makes isSupported() false for ever, so a later + // Invites.flush() skips the deterministic path entirely and a + // statistical no-match settles the install as organic -- for a + // referrer that was there all along and simply could not be read + // this once. + callback.onUnavailable(Invites.REASON_NO_MATCH); + return; + } Preferences.set(PREF_ATTEMPTED, true); if (referrer == null || referrer.length() == 0) { callback.onUnavailable(Invites.REASON_NO_MATCH); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteManifestFragments.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteManifestFragments.java index 34b38344a9e..9606aca748f 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteManifestFragments.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteManifestFragments.java @@ -166,6 +166,15 @@ private static boolean coversInviteLinks(String block, String host, String slug) if (!declaresHost(block, host)) { return false; } + // The scheme too, in this same filter. An http-only filter on the + // invite host and path claims nothing about https, and the links this + // builder generates are https -- so treating it as coverage suppressed + // the generated filter and left every invite link opening the browser. + // A filter that names no scheme at all matches none of ours: Android + // requires a scheme before a host is considered. + if (block.indexOf("android:scheme=\"https\"") < 0) { + return false; + } String prefix = pathPrefix(slug); // Any pathPrefix that is a prefix of ours covers our links: a filter // on "/i/" accepts "/i//". The reverse is not true, and a diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteManifestFragmentsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteManifestFragmentsTest.java index a7bc6cd5ded..4983e01132c 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteManifestFragmentsTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteManifestFragmentsTest.java @@ -113,7 +113,7 @@ void anUnrelatedPathOnTheSameHostDoesNotSuppressTheInviteFilter() { @Test void aBroaderPrefixOnTheSameHostDoesCoverTheInviteLinks() { - String existing = ""; assertTrue(InviteManifestFragments.declaresInviteLinks(existing, HOST, "acme"), "/i/ accepts /i/acme/ and needs no second filter"); @@ -122,7 +122,7 @@ void aBroaderPrefixOnTheSameHostDoesCoverTheInviteLinks() { @Test void anotherAppsSlugDoesNotCoverOurs() { - String existing = ""; assertFalse(InviteManifestFragments.declaresInviteLinks(existing, HOST, "acme")); } @@ -196,4 +196,30 @@ void aCustomHostIsHonoured() { assertTrue(out.contains("android:host=\"links.example.com\""), out); assertFalse(out.contains(HOST), out); } + + @Test + void anHttpOnlyFilterDoesNotCoverOurHttpsLinks() { + // The links this builder generates are https. An http-only filter on + // the same host and path claims nothing about them, and treating it as + // coverage suppressed the generated filter -- so every invite link kept + // opening the browser, which is the exact symptom the filter exists to + // prevent. + String existing = "" + + ""; + assertFalse(InviteManifestFragments.declaresInviteLinks(existing, HOST, "acme")); + String out = InviteManifestFragments.injectAppLinks(existing, HOST, "acme"); + assertTrue(out.contains("android:scheme=\"https\""), + "the https filter was suppressed by an http-only one"); + } + + @Test + void aFilterNamingNoSchemeAtAllCoversNothing() { + // Android requires a scheme before a host is considered, so a data + // element without one matches no url whatsoever. + String existing = "" + + ""; + assertFalse(InviteManifestFragments.declaresInviteLinks(existing, HOST, "acme")); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index d49514e86de..69e1cb28922 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -360,4 +360,89 @@ void aRefusalIsDurableAndIsReopenedByALaterGrant() { assertEquals(Invites.STATE_PENDING, Invites.getState(), "granting consent afterwards did not reopen the lookup"); } + + @Test + @EdtTest + void flushSupersedesWhateverTheLastAttemptLeftOutstanding() { + // Retrying under the same epoch let a fingerprint answer from the + // earlier attempt land after the retried referrer resolved exactly, and + // overwrite it. Genuinely concurrent on an app with more than one + // NetworkManager thread. + Invites.checkForInvite(); + int stale = Invites.currentLookupEpochForTest(); + + Invites.flush(); + Invites.handleResolution(InviteTestSupport.resolvedJson("EXACT1", "c1", "sms"), + Invites.MATCH_REFERRER, true); + + Invites.handleResolution(InviteTestSupport.resolvedJson("GUESS", "c2", "unknown"), + Invites.MATCH_FINGERPRINT, true, stale); + + InviteAttribution a = Invites.getAttribution(); + assertNotNull(a); + assertEquals("EXACT1", a.getCode(), + "a stale statistical answer overwrote the retried exact one"); + } + + @Test + @EdtTest + void grantingConsentResumesADeclinedLookupWithoutWaitingForTheApp() { + // The refusal leaves STATE_DECLINED with a reopenable marker, and + // nothing restarted the lookup until the application happened to call + // checkForInvite() again -- by which time the attribution window may + // have closed. + Invites.checkForInvite(); + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + assertEquals(Invites.STATE_DECLINED, Invites.getState()); + + Analytics.setConsent(AnalyticsConsent.granted()); + + assertEquals(Invites.STATE_PENDING, Invites.getState(), + "granting consent did not resume the declined lookup"); + } + + @Test + @EdtTest + void anUnavailableAnswerReachedBeforeRegistrationIsStillDelivered() { + // The answer is terminal, so no later lookup produces it again, and + // setInviteListener only replays a resolved attribution -- so an app + // that answered the deferred question before registering its listener + // got neither callback for the entire install. + Invites.setAttributionWindow(0); + Invites.checkForInvite(); + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState()); + + final String[] told = new String[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0] = reason; + } + }); + assertEquals(Invites.REASON_UNSUPPORTED, told[0], + "the answer reached before registration was dropped"); + } + + @Test + @EdtTest + void aRefusedDirectLinkTellsTheListener() { + // checkForInvite marks the url consumed and skips the deferred path + // after this, so it is the only chance the listener gets -- and a + // registered one heard nothing at all. + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + final String[] told = new String[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0] = reason; + } + }); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/CODE1"); + assertEquals(Invites.REASON_CONSENT_DENIED, told[0], + "a refused direct link told the listener nothing"); + } } From 37706e660615075466b64bee7aef7b7d4cf6991c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:11:16 +0300 Subject: [PATCH 17/99] Invites: an erasure that does not depend on who is registered Analytics.resetClientId now clears dimensions under the reserved cn1_ prefix. The invite provider is the ordinary route and does more -- it drops the durable records too -- but a provider can be absent: Analytics.clearProviders() is public and the deprecated AnalyticsService.init() calls it. In that window an erasure left the referral dimensions attached to the new id, and the next provider the application registered transmitted them. An erasure cannot depend on who happens to be registered when it runs. Scoped to the reserved prefix rather than clearing everything, because an application's own plan or role dimension describes the app and not the person, and losing it silently on an erasure would be its own surprise. The prefix is named and documented on the method. The Android referrer code is persisted before the claim goes out. The source has already burned its once-only flag by the time the callback runs, so a claim that failed left the exact code nowhere but that callback, and the next flush() fell back to a statistical match for an answer that had been read exactly. The failed-outbox fallback is gated on consent. drainOutbox carries that guard and this path had none, so a storage failure was the one way an undecided or refused user's client id and invite metadata reached the server. The registration is lost instead, which is the correct trade: the link still attributes through the click, and only the campaign, channel and preview metadata go with it. The outbox-failure paths now have a test seam, because a full or read-only store cannot be produced from a test and those paths are the ones most worth pinning. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/analytics/Analytics.java | 40 ++++++++++++++++++ .../analytics/invite/InviteStore.java | 14 +++++++ .../codename1/analytics/invite/Invites.java | 32 ++++++++++++++- .../invite/InviteConsentAndErasureTest.java | 25 +++++++++++ .../invite/InviteResilienceTest.java | 41 +++++++++++++++++++ 5 files changed, 150 insertions(+), 2 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/Analytics.java b/CodenameOne/src/com/codename1/analytics/Analytics.java index e907cc077d1..f54c290a913 100644 --- a/CodenameOne/src/com/codename1/analytics/Analytics.java +++ b/CodenameOne/src/com/codename1/analytics/Analytics.java @@ -27,6 +27,7 @@ import com.codename1.ui.Display; import java.util.ArrayList; +import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; @@ -463,6 +464,13 @@ public static String clientId() { /// every provider with the new identity. Use this to honour a "right to be /// forgotten" / erasure request from the user. /// + /// Custom dimensions your application set are kept -- a `plan` or `role` + /// dimension describes the app, not the person, and losing it silently on + /// an erasure would surprise you. Dimensions under the reserved `cn1_` + /// prefix are cleared, because those are written for you by framework + /// features that identify the user across installs, and carrying them onto + /// a fresh id would re-link the two. + /// /// #### Returns /// /// the new client id @@ -471,6 +479,15 @@ public static String resetClientId() { synchronized (LOCK) { clientId = newClientId(); Preferences.set(PREF_CLIENT_ID, clientId); + // Cleared here rather than left to whichever feature wrote them. + // The feature's provider is the ordinary route and does more -- + // it drops its own durable records too -- but a provider can be + // absent: Analytics.clearProviders() is public and the deprecated + // AnalyticsService.init() calls it. In that window an erasure left + // the reserved dimensions attached to the new id, and the next + // provider the application registered transmitted them. An erasure + // cannot depend on who happens to be registered when it runs. + clearReservedDimensions(); snapshot = new ArrayList(PROVIDERS); } AnalyticsContext ctx = context(); @@ -484,6 +501,29 @@ public static String resetClientId() { return clientId; } + /// The prefix reserved for dimensions the framework writes on your behalf. + /// Do not use it for your own dimensions: everything under it is cleared by + /// [#resetClientId]. + public static final String RESERVED_DIMENSION_PREFIX = "cn1_"; + + // Must be called while holding LOCK. + private static void clearReservedDimensions() { + loadDimensions(); + boolean changed = false; + Iterator> it = DIMENSIONS.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry e = it.next(); + String key = e.getKey(); + if (key != null && key.startsWith(RESERVED_DIMENSION_PREFIX)) { + it.remove(); + changed = true; + } + } + if (changed) { + persistDimensions(); + } + } + // Must be called while holding LOCK. Lazily loads the persisted dimensions // from a tab/newline delimited string: rows are newline separated, key and // value within a row are tab separated. Values had tabs/newlines replaced diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java index 4af26b53620..c55400cecfa 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java @@ -151,7 +151,21 @@ static List readOutbox() { /// read-only store. The caller has to know: an entry that never reached /// the outbox carries the campaign, channel, payload and preview of a link /// that has already been handed out, and nothing can reconstruct it later. + // Package private test seam. A full or read-only store cannot be produced + // from a test, and the paths that only run when the write fails are the + // ones most worth pinning -- they are what happens when the durable queue + // is gone. + private static boolean failNextWrite; + + static void failNextOutboxWriteForTest() { + failNextWrite = true; + } + static boolean writeOutbox(List entries) { + if (failNextWrite) { + failNextWrite = false; + return false; + } try { Storage s = Storage.getInstance(); if (s == null) { diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 24fcbe95418..130adb89fcb 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -275,8 +275,24 @@ public static Invite create(InviteRequest request) { // registered with everything it carries; if it does not, nothing // is worse than the alternative. There is deliberately no retry, // because the queue that would drive one is the thing that failed. - unacknowledged.add(invite.getCode()); - postRegistration(pendingRegistration); + if (allowed()) { + unacknowledged.add(invite.getCode()); + postRegistration(pendingRegistration); + } else { + // Nothing leaves the device without consent, and that outranks + // saving the registration. drainOutbox() carries the same guard; + // this path had none, so a storage failure was the one way an + // undecided or refused user's client id and invite metadata + // reached the server. + // + // The registration is lost, because the queue that would have + // held it is the thing that failed. That is the correct trade: + // the link still attributes through the click, and only the + // campaign, channel and preview metadata go with it. + Log.p("invite: the registration outbox could not be written and consent " + + "does not permit sending, so this invite's campaign and preview " + + "metadata are lost", Log.WARNING); + } } Map p = new HashMap(); p.put("invite_code", code); @@ -1206,6 +1222,18 @@ public void run() { fallBackToMatch(false); return; } + // Persisted BEFORE the claim goes out. The source + // has already burned its once-only flag by the time + // this runs, so if the claim fails -- a timeout, a + // dead network -- the exact code exists nowhere but + // this callback, and the next flush() falls back to + // a statistical match for an answer we had read + // exactly. Written into the pending record, the + // ordinary retry path resends it. + Map pending = pendingRecord(); + pending.put("code", code); + pending.remove("referrerRetry"); + InviteStore.write(InviteStore.PENDING, pending); claim(code, "install_referrer", rawReferrer == null ? "" : rawReferrer, MATCH_REFERRER, true); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java index 6499f0b3c2f..651dba3c650 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java @@ -329,4 +329,29 @@ private void assertNoProfileHeld(String message) { assertFalse(record.containsKey(key), message + " (held " + key + ")"); } } + + @FormTest + void anErasureClearsTheReferralDimensionsEvenWithNoProviderRegistered() { + // The invite provider is the ordinary route and does more -- it drops + // the durable records too -- but a provider can be absent: + // Analytics.clearProviders() is public and the deprecated + // AnalyticsService.init() calls it. In that window an erasure left the + // reserved dimensions on the new id, and the next provider the app + // registered transmitted them. An erasure cannot depend on who happens + // to be registered when it runs. + InviteTestSupport.freshInstall(); + Invites.handleResolution(InviteTestSupport.resolvedJson("CODE1", "spring", "sms"), + Invites.MATCH_DIRECT, false); + assertNotNull(Analytics.getDimensions().get("cn1_campaign")); + Analytics.setDimension("plan", "pro"); + + Analytics.clearProviders(); + Analytics.resetClientId(); + + assertNull(Analytics.getDimensions().get("cn1_campaign"), + "an erasure left the referral dimensions on the new client id"); + assertNull(Analytics.getDimensions().get("cn1_invite_code")); + assertEquals("pro", Analytics.getDimensions().get("plan"), + "the application's own dimension must survive an erasure"); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 69e1cb28922..a1d4b074fef 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -25,6 +25,7 @@ import com.codename1.analytics.Analytics; import com.codename1.analytics.AnalyticsConsent; import com.codename1.junit.EdtTest; +import com.codename1.junit.FormTest; import java.io.ByteArrayInputStream; import java.io.IOException; import com.codename1.junit.UITestBase; @@ -445,4 +446,44 @@ public void attributionUnavailable(String reason) { assertEquals(Invites.REASON_CONSENT_DENIED, told[0], "a refused direct link told the listener nothing"); } + + @Test + @EdtTest + void theReferrerCodeIsPersistedBeforeTheClaimGoesOut() { + // The source has already burned its once-only flag by the time the + // callback runs, so a claim that fails leaves the exact code nowhere + // but that callback and the next flush() falls back to a statistical + // match for an answer that had been read exactly. + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + callback.onReferrer("utm_source=cn1_invite&cn1_invite=EXACT9", 0L, 0L); + } + }); + Invites.checkForInvite(); + + Map pending = InviteStore.read(InviteStore.PENDING); + assertNotNull(pending, "the pending record was not kept at all"); + assertEquals("EXACT9", InviteStore.get(pending, "code", null), + "the exact referrer code was not persisted before the claim"); + } + + @FormTest + void aFailedOutboxWriteStillTransmitsNothingWithoutConsent() { + // drainOutbox carries the consent guard and this fallback had none, so + // a storage failure was the one way an undecided user's client id and + // invite metadata reached the server. + Analytics.setConsent(null); + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + InviteStore.failNextOutboxWriteForTest(); + + Invite invite = Invites.create(InviteRequest.create().campaign("launch").build()); + assertNotNull(invite, "minting is offline and must still work"); + assertEquals(0, implementation.getQueuedRequests().size(), + "a registration was transmitted before consent was given"); + } } From 81c7aa83e25dcd0b9f75c5b13fa6147655339466 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 17:43:59 +0300 Subject: [PATCH 18/99] Invites: five consequences of the last two rounds The Play referrer callback read lookupEpoch when it fired rather than when the read was issued, so an outstanding read inherited the epoch a direct link had just advanced, passed the guard, and could overwrite the direct attribution. Incrementing an epoch cannot invalidate a callback that does not remember which epoch it belongs to; it captures the epoch at issue now. flush() restarted the lookup on every call, spending an attempt with no failure observed -- and create() calls flush() unconditionally, so five invites minted in a row exhausted MAX_ATTEMPTS and the last one settled the install as terminal while its own answer was still on the wire. It restarts only once the previous attempt has aged out. Bounded by a timestamp rather than a flag cleared by a response, because these requests are fail-silent: a failure produces no callback at all, so a flag would never be cleared for exactly the request a retry exists for and flush() could wedge for the rest of the process. A refusal held for a listener that had not registered yet was not cleared when consent was granted and the lookup resumed, so an attribution that went on to resolve was reported to that listener as unavailable. The held answer lived only in a static field, and the contract says "exactly one of the two methods per install, and the answer is remembered". A resolved attribution has carried a durable delivered flag from the start; the unavailable answer had nothing, so an application whose deferred question was settled before it registered a listener, in a process that then exited, got neither callback for the life of the install. The terminal marker carries the reason and its own delivered flag now -- which also keeps the other half of the contract, since it is not delivered twice. Under ConsentMode.OPT_OUT a null recorded choice is the mode's implicit allow, not an unanswered prompt. Ignoring it meant clearing an explicit denial resumed ordinary analytics while a declined invite lookup stayed stopped and a resolved attribution's dimensions stayed cleared -- the two disagreeing about the same user. The mode is consulted when there is no recorded choice. Co-Authored-By: Claude Opus 5 (1M context) --- .../invite/InviteAttributionProvider.java | 16 +- .../codename1/analytics/invite/Invites.java | 102 ++++++++++- .../invite/InviteResilienceTest.java | 165 ++++++++++++++++++ .../analytics/invite/InviteTestSupport.java | 1 + 4 files changed, 274 insertions(+), 10 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java b/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java index 0358e659713..2a3aa624850 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java @@ -27,6 +27,7 @@ import com.codename1.analytics.AnalyticsCapability; import com.codename1.analytics.AnalyticsConsent; import com.codename1.analytics.AnalyticsContext; +import com.codename1.analytics.ConsentMode; import com.codename1.io.Preferences; // The seam that lets invite attribution honour an erasure request and a @@ -93,10 +94,21 @@ public void onConsentChanged(AnalyticsConsent consent) { // Analytics.getConsent() returns null until there is a real choice on // record, so ask it instead of believing the argument. AnalyticsConsent recorded = Analytics.getConsent(); - if (recorded == null) { + if (recorded != null) { + Invites.onConsentChanged(recorded.isAnalytics()); return; } - Invites.onConsentChanged(recorded.isAnalytics()); + // No choice on record. Under OPT_IN that means the prompt has not been + // answered and there is nothing to act on -- returning is the whole + // point of the paragraph above. Under OPT_OUT it means something else + // entirely: the mode's implicit allow is back in force, which is a real + // transition. Clearing an explicit denial there resumed ordinary + // analytics while a declined invite lookup stayed stopped and a + // resolved attribution's dimensions stayed cleared, so the two + // disagreed about the same user. + if (Analytics.getConsentMode() == ConsentMode.OPT_OUT) { + Invites.onConsentChanged(true); + } } @Override diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 130adb89fcb..58593d6b1b1 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -216,6 +216,25 @@ public final class Invites { private static String undelivered; private static boolean deferredStarted; + // When the last claim or match was issued. flush() restarts only once this + // has aged out: retrying a request that is still outstanding spends an + // attempt without a failure having been observed, and the attempt budget is + // what decides when the install is settled. + // + // A timestamp rather than a boolean, because the requests are fail-silent + // -- a failure produces no callback at all -- so a flag cleared by a + // response would never be cleared for exactly the request a retry exists + // for, and flush() could wedge for the rest of the process. + private static long lookupIssuedAt; + + // Package private so a test can retry without waiting. + static long lookupRetryDelay = 30000L; + + private static boolean lookupInFlight() { + return lookupIssuedAt != 0 + && System.currentTimeMillis() - lookupIssuedAt < lookupRetryDelay; + } + // Bumped whenever the identity or the permission behind an outstanding // lookup changes -- an erasure, or consent being withdrawn. A response // carries the epoch it was issued under and is dropped if it no longer @@ -547,6 +566,7 @@ private static void loadAttribution() { // the answer survives a relaunch, and nothing else can check that. static void forgetLoadedState() { undelivered = null; + lookupIssuedAt = 0; stateLoaded = false; attributionLoaded = false; resolved = null; @@ -743,7 +763,13 @@ public static void flush() { // drain registrations and silently leave the attribution unresolved // until the next cold start. The persisted attempt counter still // bounds the retries. - if (getState() == STATE_PENDING) { + if (getState() == STATE_PENDING && !lookupInFlight()) { + // Only when nothing is outstanding. Restarting on every call burned + // the attempt budget without a single observed failure -- and + // create() calls flush() unconditionally, so five invites minted in + // a row exhausted MAX_ATTEMPTS and the last one settled the install + // as terminal while its own answer was still on the wire. + // // The retry supersedes whatever the last attempt left outstanding. // Without the bump, a fingerprint answer still on the wire from the // earlier attempt passes the guard and can land AFTER the retried @@ -778,6 +804,7 @@ public static void reset() { stateLoaded = true; deliveredThisRun = false; deferredStarted = false; + lookupIssuedAt = 0; undelivered = null; unacknowledged.clear(); } @@ -814,6 +841,11 @@ static void onConsentChanged(boolean allowed) { // itself, so calling it is the whole fix. int s = getState(); if (s == STATE_PENDING || s == STATE_DECLINED) { + // The refusal may have been recorded for a listener that had + // not registered yet. It is not the answer any more, and + // leaving it held meant a lookup that went on to resolve was + // reported to that listener as unavailable instead. + undelivered = null; deferredStarted = false; beginDeferred(); } else if (s == STATE_RESOLVED) { @@ -1083,6 +1115,13 @@ private static void markTerminal(int terminalState, String reason) { Map done = new LinkedHashMap(); done.put("state", String.valueOf(terminalState)); if (reason != null) { + // Recorded on the marker, not only in memory. The listener contract + // is "exactly one of the two methods per install, and the answer is + // remembered": a resolved attribution has carried a durable + // delivered flag from the start and the unavailable answer had + // nothing, so an application whose deferred question was settled + // before it registered its listener, in a process that then exited, + // got neither callback for the life of the install. done.put("reason", reason); } InviteStore.write(InviteStore.PENDING, done); @@ -1090,6 +1129,31 @@ private static void markTerminal(int terminalState, String reason) { stateLoaded = true; } + // The terminal answer this install reached, if it was never delivered. + // Null once a listener has heard it, so the contract's "exactly one per + // install" holds across launches exactly as it does for a resolved + // attribution. + private static String undeliveredFromMarker() { + int s = getState(); + if (s != STATE_NONE_FOUND && s != STATE_DECLINED) { + return null; + } + Map marker = InviteStore.read(InviteStore.PENDING); + if (marker == null || InviteStore.getBoolean(marker, "delivered", false)) { + return null; + } + return InviteStore.get(marker, "reason", REASON_NO_MATCH); + } + + private static void markUnavailableDelivered() { + Map marker = InviteStore.read(InviteStore.PENDING); + if (marker == null) { + return; + } + marker.put("delivered", "true"); + InviteStore.write(InviteStore.PENDING, marker); + } + private static Map pendingRecord() { Map pending = InviteStore.read(InviteStore.PENDING); if (pending != null) { @@ -1207,6 +1271,13 @@ private static boolean safeSupported(InstallReferrerSource source) { } private static void requestReferrer(InstallReferrerSource source) { + // The epoch this read was ISSUED under, captured here. The platform + // callback below can run long after a direct link arrived and advanced + // the epoch, and reading the field at callback time made the old read + // inherit the new epoch -- so it passed the guard and could overwrite + // the direct attribution. Incrementing the epoch cannot invalidate a + // callback that does not remember which epoch it belongs to. + final int issued = lookupEpoch; try { source.requestReferrer(new InstallReferrerCallback() { @Override @@ -1215,6 +1286,9 @@ public void onReferrer(final String rawReferrer, final long clickSeconds, onEdt(new Runnable() { @Override public void run() { + if (issued != lookupEpoch) { + return; + } String code = codeFromQuery(rawReferrer); if (code == null) { // The referrer was read and carries no invite. @@ -1246,6 +1320,9 @@ public void onUnavailable(final String reason) { onEdt(new Runnable() { @Override public void run() { + if (issued != lookupEpoch) { + return; + } // REASON_UNSUPPORTED is the store saying this // device will never have a referrer. Anything else // is transient -- the store was busy, the bind @@ -1328,6 +1405,7 @@ private static void requestMatch(Map pending) { body.put("locale", InviteStore.get(pending, "locale", "")); body.put("screenWidth", Integer.valueOf(InviteStore.getInt(pending, "screenWidth", 0))); body.put("screenHeight", Integer.valueOf(InviteStore.getInt(pending, "screenHeight", 0))); + lookupIssuedAt = System.currentTimeMillis(); post(getLinkBase() + PATH_MATCH, body, MATCH_FINGERPRINT, true); } @@ -1344,6 +1422,7 @@ private static void claim(String code, String source, String rawReferrer, body.put("code", code); body.put("source", source); body.put("rawReferrer", rawReferrer == null ? "" : rawReferrer); + lookupIssuedAt = System.currentTimeMillis(); post(getLinkBase() + PATH_CLAIM, body, matchType, deferred); } @@ -1483,6 +1562,9 @@ static void handleResolution(String payload, String matchType, boolean deferred) } static void handleResolution(String payload, String matchType, boolean deferred, int epoch) { + if (epoch == lookupEpoch) { + lookupIssuedAt = 0; + } // A response that was already on the wire when consent was withdrawn or // the identity was erased must not be acted on. Both of those delete the // pending record and clear the dimensions; resolving anyway would write @@ -1693,9 +1775,14 @@ private static void deliverPending() { } // Taken into a local and cleared unconditionally, rather than // null-checked in place and cleared inside the branch. Same reason as - // notifyUnavailable above. + // notifyUnavailable above. The durable half comes second: an answer + // reached in an earlier process left nothing in memory, and the + // contract says the answer is remembered. String held = undelivered; undelivered = null; + if (held == null) { + held = undeliveredFromMarker(); + } if (held != null) { notifyUnavailable(held); return; @@ -1728,16 +1815,15 @@ private static void notifyUnavailable(String reason) { // and the answer is not a lock -- this facade runs on the EDT. InviteListener target = listener; if (target == null) { - // Held, not dropped. The answer is terminal, so no later lookup - // will produce it again, and setInviteListener() only replays a - // resolved attribution -- so an application that answers the - // deferred question before registering its listener got neither - // callback for the whole install, against the documented promise - // that an early answer is delivered on registration. + // Held for this run, and durably by the marker markTerminal wrote. + // Either way it is not dropped: the answer is terminal, so no later + // lookup produces it again, and setInviteListener() would otherwise + // replay only a resolved attribution. undelivered = reason; return; } deliveredThisRun = true; + markUnavailableDelivered(); try { target.attributionUnavailable(reason); } catch (Throwable t) { diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index a1d4b074fef..4b3f06a196a 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -24,6 +24,7 @@ import com.codename1.analytics.Analytics; import com.codename1.analytics.AnalyticsConsent; +import com.codename1.analytics.ConsentMode; import com.codename1.junit.EdtTest; import com.codename1.junit.FormTest; import java.io.ByteArrayInputStream; @@ -39,7 +40,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -372,6 +375,9 @@ void flushSupersedesWhateverTheLastAttemptLeftOutstanding() { Invites.checkForInvite(); int stale = Invites.currentLookupEpochForTest(); + // The first attempt has aged out; flush() deliberately does nothing + // while one is still outstanding, which is the sibling case below. + Invites.lookupRetryDelay = 0L; Invites.flush(); Invites.handleResolution(InviteTestSupport.resolvedJson("EXACT1", "c1", "sms"), Invites.MATCH_REFERRER, true); @@ -486,4 +492,163 @@ void aFailedOutboxWriteStillTransmitsNothingWithoutConsent() { assertEquals(0, implementation.getQueuedRequests().size(), "a registration was transmitted before consent was given"); } + + @Test + @EdtTest + void flushDoesNotSpendAnAttemptOnALookupThatIsStillOutstanding() { + // create() calls flush() unconditionally, so minting five invites in a + // row exhausted MAX_ATTEMPTS without a single observed failure -- and + // the last one settled the install as terminal while its own answer was + // still on the wire. + Invites.checkForInvite(); + Map after = InviteStore.read(InviteStore.PENDING); + int attempts = InviteStore.getInt(after, "attempts", 0); + + for (int i = 0; i < 8; i++) { + Invites.flush(); + } + + Map now = InviteStore.read(InviteStore.PENDING); + assertEquals(attempts, InviteStore.getInt(now, "attempts", 0), + "flush() spent the attempt budget on a lookup that had not failed"); + assertEquals(Invites.STATE_PENDING, Invites.getState(), + "the install was settled while its answer was still on the wire"); + } + + @Test + @EdtTest + void aReferrerCallbackThatArrivesAfterADirectLinkIsIgnored() { + // The platform callback used to read lookupEpoch at callback time, so + // an outstanding referrer read inherited the epoch a direct link had + // just advanced, passed the guard, and could overwrite the direct + // attribution. Incrementing an epoch cannot invalidate a callback that + // does not remember which epoch it belongs to. + final InstallReferrerCallback[] held = new InstallReferrerCallback[1]; + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + held[0] = callback; + } + }); + Invites.checkForInvite(); + assertNotNull(held[0], "the referrer read was never issued"); + + Invites.handleUrl("https://cloud.codenameone.com/i/acme/DIRECT2"); + Invites.handleResolution(InviteTestSupport.resolvedJson("DIRECT2", "c1", "sms"), + Invites.MATCH_DIRECT, false); + + // The referrer finally answers, carrying a different code. It must be + // dropped where it arrives -- before it writes its code into the + // pending record and issues a claim -- because once a claim goes out + // under the current epoch nothing downstream can tell it apart from a + // legitimate one. + held[0].onReferrer("utm_source=cn1_invite&cn1_invite=LATE2", 0L, 0L); + + Map pending = InviteStore.read(InviteStore.PENDING); + String recorded = pending == null ? null : InviteStore.get(pending, "code", null); + assertNotEquals("LATE2", recorded, + "a stale referrer callback wrote its code and issued a claim"); + InviteAttribution a = Invites.getAttribution(); + assertNotNull(a); + assertEquals("DIRECT2", a.getCode()); + } + + @Test + @EdtTest + void aRefusalHeldForALateListenerIsDiscardedWhenTheLookupResumes() { + // The refusal was recorded for a listener that had not registered yet. + // Once consent is granted it is not the answer any more, and leaving it + // held reported a lookup that went on to resolve as unavailable. + Invites.checkForInvite(); + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + Analytics.setConsent(AnalyticsConsent.granted()); + Invites.handleResolution(InviteTestSupport.resolvedJson("RESOLVED1", "c1", "sms"), + Invites.MATCH_FINGERPRINT, true); + + final String[] unavailable = new String[1]; + final InviteAttribution[] received = new InviteAttribution[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + received[0] = a; + } + + public void attributionUnavailable(String reason) { + unavailable[0] = reason; + } + }); + assertNull(unavailable[0], "a stale refusal was reported over a resolved attribution"); + assertNotNull(received[0], "the resolved attribution was never delivered"); + } + + @Test + @EdtTest + void anUnavailableAnswerSurvivesTheProcessThatReachedIt() { + // The contract is "exactly one of the two methods per install, and the + // answer is remembered". A resolved attribution has carried a durable + // delivered flag from the start; the unavailable answer had nothing, so + // an application whose deferred question was settled before it + // registered a listener, in a process that then exited, got neither + // callback for the life of the install. + Invites.setAttributionWindow(0); + Invites.checkForInvite(); + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState()); + + Invites.forgetLoadedState(); + + final String[] told = new String[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0] = reason; + } + }); + assertEquals(Invites.REASON_UNSUPPORTED, told[0], + "the answer did not survive the process that reached it"); + } + + @Test + @EdtTest + void anAnswerAlreadyDeliveredIsNotDeliveredAgainOnALaterLaunch() { + // The other half of the same contract: exactly one, not one per launch. + Invites.setAttributionWindow(0); + Invites.checkForInvite(); + final int[] told = new int[1]; + InviteListener l = new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0]++; + } + }; + Invites.setInviteListener(l); + assertEquals(1, told[0]); + + Invites.forgetLoadedState(); + Invites.setInviteListener(l); + assertEquals(1, told[0], "the answer was delivered twice across launches"); + } + + @Test + @EdtTest + void clearingAnExplicitDenialUnderOptOutResumesAttribution() { + // Under OPT_OUT a null recorded choice is the mode's implicit allow, not + // an unanswered prompt. Ignoring it resumed ordinary analytics while a + // declined invite lookup stayed stopped, so the two disagreed about the + // same user. + Analytics.setConsentMode(ConsentMode.OPT_OUT); + Invites.checkForInvite(); + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + assertEquals(Invites.STATE_DECLINED, Invites.getState()); + + Analytics.setConsent(null); + + assertEquals(Invites.STATE_PENDING, Invites.getState(), + "clearing the denial under opt-out did not resume the lookup"); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java index c07741294db..3e9b7b6b649 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java @@ -46,6 +46,7 @@ static RecordingProvider freshInstall() { Invites.setReattribution(false); Invites.setAttributionWindow(Invites.DEFAULT_ATTRIBUTION_WINDOW); Invites.registerInstallReferrerSource(null); + Invites.lookupRetryDelay = 30000L; Invites.reset(); Preferences.delete(Invites.PREF_SLUG); Preferences.delete(Invites.PREF_CONSUMED_ARG); From 15460753d44e853e1b61efcb930946d3183b1c42 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:22:45 +0300 Subject: [PATCH 19/99] Invites: re-attribution no longer contradicts the answer it already gave A re-attribution claim that found nothing terminalized an install that already had an attribution. That contradicted the durable record -- which still says RESOLVED and puts the state back on the next launch -- and told the listener "no invite" as a second, opposite callback after it had already been given one. The earlier attribution stands. Replacing an attribution reset the durable delivered flag, so the replacement was delivered as a second inviteReceived(): immediately if the first had happened in an earlier process, on the next launch if it had happened in this one. Re-attribution rewrites the attribution, not the fact that the listener has already been told about this install. The flag is carried across. The expiry marker carried no reason, so once the process that reached it exited, a late listener was told the marker's default -- no_match -- rather than that the window had expired. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 22 ++++- .../invite/InviteResilienceTest.java | 95 +++++++++++++++++++ 2 files changed, 115 insertions(+), 2 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 58593d6b1b1..1637924af83 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1232,7 +1232,7 @@ private static void beginDeferred() { Map pending = pendingRecord(); long expires = InviteStore.getLong(pending, "expiresAt", 0); if (expires > 0 && System.currentTimeMillis() > expires) { - markTerminal(); + markTerminal(REASON_EXPIRED); notifyUnavailable(REASON_EXPIRED); return; } @@ -1606,6 +1606,16 @@ static void handleResolution(String payload, String matchType, boolean deferred, setState(STATE_PENDING); return; } + if (getAttribution() != null) { + // A re-attribution claim that found nothing. The earlier + // attribution is still the answer for this install, so + // nothing is terminal here -- terminalizing it contradicted + // the durable record, which still says RESOLVED and puts + // the state back on the next launch, and told the listener + // "no invite" as a second, opposite callback after it had + // already been given one. + return; + } // Terminal, and it has to be durable. Deleting the record is // not enough: loadState() reads an absent record as STATE_NONE, // so the next launch built a fresh profile and asked again, and @@ -1675,7 +1685,15 @@ private static void resolve(InviteAttribution a, String confidence) { InviteStore.put(record, "params", JSONParser.mapToJson(new LinkedHashMap(a.getParameters()))); } - record.put("delivered", "false"); + // Carried across from the record this one replaces. Re-attribution + // rewrites the attribution but not the fact that the listener has + // already been told about this install, and the contract is exactly one + // callback per install -- resetting the flag delivered inviteReceived() + // a second time, immediately if the first had happened in an earlier + // process and on the next launch if it had happened in this one. + Map previous = InviteStore.read(InviteStore.ATTRIBUTION); + record.put("delivered", + String.valueOf(InviteStore.getBoolean(previous, "delivered", false))); // Storage was chosen over Preferences precisely because it reports a // failed write, so the result is checked. Deleting the pending record // after a failed write would leave neither an attribution nor any retry diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 4b3f06a196a..a2bb713ac11 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -651,4 +651,99 @@ void clearingAnExplicitDenialUnderOptOutResumesAttribution() { assertEquals(Invites.STATE_PENDING, Invites.getState(), "clearing the denial under opt-out did not resume the lookup"); } + + @Test + @EdtTest + void aReattributionThatFindsNothingLeavesTheEarlierAnswerStanding() { + // The earlier attribution is still the answer for this install, so a + // failed replacement is not terminal. Terminalizing it contradicted the + // durable record -- which still says RESOLVED and puts the state back on + // the next launch -- and told the listener "no invite" as a second, + // opposite callback after it had already been given one. + Invites.handleResolution(InviteTestSupport.resolvedJson("FIRST1", "c1", "sms"), + Invites.MATCH_DIRECT, false); + Invites.setReattribution(true); + + final int[] told = new int[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0]++; + } + }); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_DIRECT, false); + + assertEquals(Invites.STATE_RESOLVED, Invites.getState(), + "a failed re-attribution terminalized an attributed install"); + assertEquals(0, told[0], "the listener was told the opposite of what it had heard"); + assertNotNull(Invites.getAttribution()); + } + + @Test + @EdtTest + void areplacementAttributionIsNotDeliveredASecondTime() { + // Re-attribution rewrites the attribution but not the fact that the + // listener has already been told about this install, and the contract is + // exactly one callback per install. + final int[] received = new int[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + received[0]++; + } + + public void attributionUnavailable(String reason) { + } + }); + Invites.handleResolution(InviteTestSupport.resolvedJson("FIRST2", "c1", "sms"), + Invites.MATCH_DIRECT, false); + assertEquals(1, received[0]); + + Invites.setReattribution(true); + Invites.handleResolution(InviteTestSupport.resolvedJson("SECOND2", "c2", "email"), + Invites.MATCH_DIRECT, false); + Invites.forgetLoadedState(); + Invites.setInviteListener(null); + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + received[0]++; + } + + public void attributionUnavailable(String reason) { + } + }); + + assertEquals(1, received[0], "the replacement was delivered as a second callback"); + } + + @Test + @EdtTest + void anExpiredWindowReportsExpiryToALateListener() { + // The expiry marker carried no reason, so after the process that + // reached it exited, a late listener was told REASON_NO_MATCH -- the + // marker's default -- instead of what actually happened. + Invites.checkForInvite(); + Map pending = InviteStore.read(InviteStore.PENDING); + assertNotNull(pending); + pending.put("expiresAt", String.valueOf(System.currentTimeMillis() - 1000L)); + InviteStore.write(InviteStore.PENDING, pending); + + Invites.forgetLoadedState(); + Invites.checkForInvite(); + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState()); + + Invites.forgetLoadedState(); + final String[] told = new String[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0] = reason; + } + }); + assertEquals(Invites.REASON_EXPIRED, told[0], + "the late listener was told the wrong reason"); + } } From 7a767caae3f8ba6a747437ad612401dbe93a3d54 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:50:56 +0300 Subject: [PATCH 20/99] Invites: three more that the last two rounds' fixes opened The Play referrer read did not count as a lookup in flight -- only claim() and requestMatch() set the timestamp -- so a flush() during the read, which create() issues unconditionally, treated it as stale, advanced the epoch, and the epoch guard added last round then discarded the exact answer when it arrived. Worse than an ordinary lost retry: the source has already burned its once-only flag by then, so the deterministic result is gone for good and a statistical guess takes its place. A re-attribution claim that found nothing stopped terminalizing the install last round, but returning was not enough: handleUrl had already written a PENDING record for the replacement, so the install stayed pending and every later flush and launch retried the failed replacement until the attempt cap reported unavailable -- with the durable attribution sitting beside it the whole time. The replacement attempt is dropped and the install goes back to resolved. Reopening a terminal marker deleted the record of whether the listener had already been told, so a refusal that had been delivered was followed by a resumed lookup whose attribution was written as undelivered, and inviteReceived() arrived as a second callback on the next launch. The fact rides the pending record across the reopen, and the attribution reads it from there when there is no earlier attribution to inherit from. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 54 ++++++++++- .../invite/InviteResilienceTest.java | 91 +++++++++++++++++++ 2 files changed, 141 insertions(+), 4 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 1637924af83..dcba82b0f9f 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -214,6 +214,12 @@ public final class Invites { // Held for the run rather than persisted: the state itself is durable, and // a later launch reaches this answer again through the ordinary path. private static String undelivered; + + // Set when a terminal marker that had already been delivered is reopened, + // so the attribution the resumed lookup writes inherits that fact rather + // than announcing itself a second time. Carried onto the pending record as + // soon as one exists, which is what makes it survive the process. + private static boolean reopenedAlreadyDelivered; private static boolean deferredStarted; // When the last claim or match was issued. flush() restarts only once this @@ -806,6 +812,7 @@ public static void reset() { deferredStarted = false; lookupIssuedAt = 0; undelivered = null; + reopenedAlreadyDelivered = false; unacknowledged.clear(); } @@ -1165,6 +1172,10 @@ private static Map pendingRecord() { pending.put("expiresAt", String.valueOf(now + attributionWindow)); pending.put("attempts", "0"); pending.put("state", String.valueOf(STATE_PENDING)); + if (reopenedAlreadyDelivered) { + pending.put("delivered", "true"); + reopenedAlreadyDelivered = false; + } Display d = Display.getInstance(); if (d != null) { InviteStore.put(pending, "platform", d.getPlatformName()); @@ -1197,6 +1208,13 @@ private static void beginDeferred() { boolean reopen = (REASON_UNSUPPORTED.equals(why) && attributionWindow != 0) || (REASON_CONSENT_DENIED.equals(why) && !explicitlyDenied()); if (reopen) { + // The listener may already have been told about this install, + // and the marker is where that fact lives. Deleting it lost it, + // so the resumed lookup's attribution was written as + // undelivered and inviteReceived() arrived as a second callback + // on the next launch. It rides the pending record instead. + reopenedAlreadyDelivered = + InviteStore.getBoolean(marker, "delivered", false); InviteStore.delete(InviteStore.PENDING); state = STATE_NONE; s = STATE_NONE; @@ -1278,6 +1296,15 @@ private static void requestReferrer(InstallReferrerSource source) { // the direct attribution. Incrementing the epoch cannot invalidate a // callback that does not remember which epoch it belongs to. final int issued = lookupEpoch; + // A referrer read IS a lookup in flight, and only claim() and + // requestMatch() were saying so. A flush() during the read -- create() + // issues one unconditionally -- therefore treated it as stale, advanced + // the epoch and started again, and the guard above then discarded the + // exact answer when it arrived. Worse than an ordinary lost retry, + // because the source has already burned its once-only flag by then, so + // the deterministic result is gone for good and the replacement falls + // back to a statistical guess. + lookupIssuedAt = System.currentTimeMillis(); try { source.requestReferrer(new InstallReferrerCallback() { @Override @@ -1610,10 +1637,23 @@ static void handleResolution(String payload, String matchType, boolean deferred, // A re-attribution claim that found nothing. The earlier // attribution is still the answer for this install, so // nothing is terminal here -- terminalizing it contradicted - // the durable record, which still says RESOLVED and puts - // the state back on the next launch, and told the listener - // "no invite" as a second, opposite callback after it had - // already been given one. + // the durable record, which still says RESOLVED, and told + // the listener "no invite" as a second, opposite callback + // after it had already been given one. + // + // Returning is not enough either: handleUrl wrote a PENDING + // record for the replacement before issuing this claim, so + // leaving it there kept the install pending, and every + // later flush and launch retried the failed replacement + // until the attempt cap finally reported unavailable -- + // still with the durable attribution sitting beside it. The + // replacement attempt is dropped and the install goes back + // to what it was. + InviteStore.delete(InviteStore.PENDING); + state = STATE_RESOLVED; + stateLoaded = true; + deferredStarted = false; + lookupIssuedAt = 0; return; } // Terminal, and it has to be durable. Deleting the record is @@ -1691,7 +1731,13 @@ private static void resolve(InviteAttribution a, String confidence) { // callback per install -- resetting the flag delivered inviteReceived() // a second time, immediately if the first had happened in an earlier // process and on the next launch if it had happened in this one. + // The attribution being replaced, or -- when there is none, because + // this lookup was resumed after a delivered refusal -- the pending + // record that carried the fact across the reopen. Map previous = InviteStore.read(InviteStore.ATTRIBUTION); + if (previous == null) { + previous = InviteStore.read(InviteStore.PENDING); + } record.put("delivered", String.valueOf(InviteStore.getBoolean(previous, "delivered", false))); // Storage was chosen over Preferences precisely because it reports a diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index a2bb713ac11..709f9642101 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -746,4 +746,95 @@ public void attributionUnavailable(String reason) { assertEquals(Invites.REASON_EXPIRED, told[0], "the late listener was told the wrong reason"); } + + @Test + @EdtTest + void aReferrerReadCountsAsALookupInFlight() { + // Only claim() and requestMatch() said so, so a flush() during the read + // -- create() issues one unconditionally -- treated it as stale, + // advanced the epoch, and the epoch guard then discarded the exact + // answer when it arrived. Worse than a lost retry: the source has + // already burned its once-only flag, so the deterministic result is + // gone and a statistical guess replaces it. + final InstallReferrerCallback[] held = new InstallReferrerCallback[1]; + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + held[0] = callback; + } + }); + Invites.checkForInvite(); + assertNotNull(held[0]); + int issued = Invites.currentLookupEpochForTest(); + + Invites.flush(); + assertEquals(issued, Invites.currentLookupEpochForTest(), + "flush() superseded a referrer read that was still outstanding"); + + held[0].onReferrer("utm_source=cn1_invite&cn1_invite=KEPT1", 0L, 0L); + Map pending = InviteStore.read(InviteStore.PENDING); + assertEquals("KEPT1", InviteStore.get(pending, "code", null), + "the exact referrer answer was discarded"); + } + + @Test + @EdtTest + void aFailedReplacementPutsTheInstallBackWhereItWas() { + // handleUrl writes a PENDING record for the replacement before issuing + // the claim, so simply returning left the install pending: every later + // flush and launch retried the failed replacement until the attempt cap + // reported unavailable, with the durable attribution sitting beside it + // the whole time. + Invites.handleResolution(InviteTestSupport.resolvedJson("FIRST3", "c1", "sms"), + Invites.MATCH_DIRECT, false); + Invites.setReattribution(true); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/SECOND3"); + assertEquals(Invites.STATE_PENDING, Invites.getState()); + + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_DIRECT, false); + + assertEquals(Invites.STATE_RESOLVED, Invites.getState()); + assertNull(InviteStore.read(InviteStore.PENDING), + "the failed replacement's pending record was left behind"); + Invites.forgetLoadedState(); + assertEquals(Invites.STATE_RESOLVED, Invites.getState(), + "the install came back pending on the next launch"); + } + + @Test + @EdtTest + void aResumedLookupDoesNotAnnounceItselfToAListenerAlreadyTold() { + // The refusal was delivered, so the listener has had its one callback + // for this install. Reopening deleted the marker that recorded that, + // and the resumed lookup's attribution was written as undelivered -- + // arriving as a second callback on the next launch. + final int[] told = new int[1]; + final int[] received = new int[1]; + InviteListener l = new InviteListener() { + public void inviteReceived(InviteAttribution a) { + received[0]++; + } + + public void attributionUnavailable(String reason) { + told[0]++; + } + }; + Invites.setInviteListener(l); + Invites.checkForInvite(); + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + assertEquals(1, told[0], "the refusal was not delivered, so this proves nothing"); + + Analytics.setConsent(AnalyticsConsent.granted()); + Invites.handleResolution(InviteTestSupport.resolvedJson("LATER3", "c1", "sms"), + Invites.MATCH_FINGERPRINT, true); + + Invites.forgetLoadedState(); + Invites.setInviteListener(null); + Invites.setInviteListener(l); + assertEquals(0, received[0], + "the resumed lookup announced itself to a listener already told"); + } } From abfb082689a8a6534b996d6f2581c6570dcc37c2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:24:04 +0300 Subject: [PATCH 21/99] Invites: provenance, abandonment, and a fragment that became part of the code A referrer claim that timed out is persisted and retried, and the retry hard-coded the direct-link metadata -- so the answer came back with isDeferred() false and was recorded as invite_opened rather than invite_install, corrupting the install funnel for exactly the deterministic results that persistence exists to save. The pending record carries the provenance now and the retry resends what it was. Abandoning a re-attribution replacement is now one helper used by every way of giving up on it. The server no-match learned to do it last round; the attempt cap and the window expiry did not, so they wrote a terminal marker the durable attribution contradicts and told the listener "no invite" after it had already been given one. A denied invite URL arriving for an install that was already attributed did the same thing from the other direction, because the consent guard ran before the resolved check. Those installs keep their answer. An empty but successful Play referrer read burns the source's once-only flag, so it is definitive -- but it reports the same reason a transient failure does, and the lookup stayed pending until the attempt budget ran out for an answer that had already arrived. Retryability is read from whether the source would try again, not from the reason alone. A URI fragment was never stripped, so an App Link arriving as /i/acme/ABC123#section claimed a code called "ABC123#section". Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 78 +++++++++- .../invite/InviteResilienceTest.java | 137 ++++++++++++++++++ .../invite/InviteUrlParsingTest.java | 26 ++++ 3 files changed, 238 insertions(+), 3 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index dcba82b0f9f..b1d5994edd5 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -506,6 +506,15 @@ public static boolean handleUrl(String url) { // this a refused user who opened an invite link still had a profile // persisted -- by a different route to the one that was fixed. if (explicitlyDenied()) { + if (getAttribution() != null) { + // Already attributed, and the listener has had its callback. + // Writing a fresh DECLINED marker here contradicted the durable + // attribution -- which is still there and makes the state + // RESOLVED again on the next launch -- and delivered + // attributionUnavailable() as a second, opposite answer for an + // install that had already been given one. + return true; + } // Told, not silently dropped. checkForInvite() records the url as // consumed and skips the deferred path after this, so this is the // only chance the listener gets for this install -- and a @@ -526,6 +535,10 @@ public static boolean handleUrl(String url) { } Map pending = pendingRecord(); pending.put("code", code); + pending.put("codeSource", "universal_link"); + pending.put("codeMatch", MATCH_DIRECT); + pending.put("codeDeferred", "false"); + pending.put("codeReferrer", ""); // The referrer question is settled: this install came from a link we // are holding the code for, so a referrer read is no longer a better // answer waiting to happen. @@ -997,6 +1010,14 @@ static String extractCode(String url) { path = path.substring(0, rel); } } + // A fragment is not part of the path and is not part of the code, and + // an App Link commonly arrives with one still attached -- so + // /i/acme/ABC123#section claimed a code called "ABC123#section", which + // exists nowhere. + int hash = path.indexOf('#'); + if (hash >= 0) { + path = path.substring(0, hash); + } if (!path.startsWith("/i/")) { return null; } @@ -1105,6 +1126,27 @@ private static void setState(int s) { // answer was no". Durable, so no later launch repeats the lookup, and it // carries none of the device profile the pending record held -- the profile // exists to be matched, and there is nothing left to match it against. + // A pending record that sits BESIDE a resolved attribution is a + // re-attribution replacement, not this install's only answer. Every way of + // giving up on it -- a server no-match, the attempt cap, the window + // expiring -- has to drop the replacement and leave the install resolved, + // rather than writing a terminal marker the durable attribution contradicts + // and telling the listener "no invite" after it has already been told + // otherwise. + // + // Returns true when it handled the outcome. + private static boolean abandonReplacement() { + if (getAttribution() == null) { + return false; + } + InviteStore.delete(InviteStore.PENDING); + state = STATE_RESOLVED; + stateLoaded = true; + deferredStarted = false; + lookupIssuedAt = 0; + return true; + } + private static void markTerminal() { markTerminal(null); } @@ -1250,11 +1292,17 @@ private static void beginDeferred() { Map pending = pendingRecord(); long expires = InviteStore.getLong(pending, "expiresAt", 0); if (expires > 0 && System.currentTimeMillis() > expires) { + if (abandonReplacement()) { + return; + } markTerminal(REASON_EXPIRED); notifyUnavailable(REASON_EXPIRED); return; } if (InviteStore.getInt(pending, "attempts", 0) >= MAX_ATTEMPTS) { + if (abandonReplacement()) { + return; + } markTerminal(); notifyUnavailable(REASON_NO_MATCH); return; @@ -1268,7 +1316,17 @@ private static void beginDeferred() { deferredStarted = true; String code = InviteStore.get(pending, "code", null); if (code != null && code.length() > 0) { - claim(code, "universal_link", "", MATCH_DIRECT, false); + // Resent as what it was, not as a direct link. A referrer claim + // that timed out is persisted here and retried, and hard-coding + // the direct-link metadata reported it as invite_opened rather than + // invite_install and handed the app an attribution whose + // isDeferred() said false -- corrupting the install funnel for + // exactly the deterministic answers this retry exists to save. + String source = InviteStore.get(pending, "codeSource", "universal_link"); + String matchType = InviteStore.get(pending, "codeMatch", MATCH_DIRECT); + boolean deferred = InviteStore.getBoolean(pending, "codeDeferred", false); + claim(code, source, InviteStore.get(pending, "codeReferrer", ""), + matchType, deferred); return; } InstallReferrerSource source = referrerSource; @@ -1288,7 +1346,7 @@ private static boolean safeSupported(InstallReferrerSource source) { } } - private static void requestReferrer(InstallReferrerSource source) { + private static void requestReferrer(final InstallReferrerSource source) { // The epoch this read was ISSUED under, captured here. The platform // callback below can run long after a direct link arrived and advanced // the epoch, and reading the field at callback time made the old read @@ -1333,6 +1391,11 @@ public void run() { // ordinary retry path resends it. Map pending = pendingRecord(); pending.put("code", code); + pending.put("codeSource", "install_referrer"); + pending.put("codeMatch", MATCH_REFERRER); + pending.put("codeDeferred", "true"); + InviteStore.put(pending, "codeReferrer", + rawReferrer == null ? "" : rawReferrer); pending.remove("referrerRetry"); InviteStore.write(InviteStore.PENDING, pending); claim(code, "install_referrer", @@ -1360,7 +1423,16 @@ public void run() { // no-match answer to it must not be allowed to // settle the install as organic while a // deterministic answer is still reachable. - fallBackToMatch(!REASON_UNSUPPORTED.equals(reason)); + // Retryable only while the SOURCE would try + // again. Reading the reason alone was not enough: + // a successful read that returns an empty referrer + // reports REASON_NO_MATCH and burns the once-only + // flag, so it is definitive -- and treating it as + // transient left the lookup pending until the + // attempt budget ran out, for an answer that had + // already arrived. + fallBackToMatch(!REASON_UNSUPPORTED.equals(reason) + && safeSupported(source)); } }); } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 709f9642101..7968a82e040 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -25,6 +25,7 @@ import com.codename1.analytics.Analytics; import com.codename1.analytics.AnalyticsConsent; import com.codename1.analytics.ConsentMode; +import com.codename1.io.ConnectionRequest; import com.codename1.junit.EdtTest; import com.codename1.junit.FormTest; import java.io.ByteArrayInputStream; @@ -837,4 +838,140 @@ public void attributionUnavailable(String reason) { assertEquals(0, received[0], "the resumed lookup announced itself to a listener already told"); } + + @FormTest + void aRetriedReferrerClaimIsStillAReferrerClaim() { + // The persisted code was resent as a direct link, so the answer came + // back with isDeferred() false and was recorded as invite_opened rather + // than invite_install -- corrupting the install funnel for exactly the + // deterministic answers this retry exists to save. + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + callback.onReferrer("utm_source=cn1_invite&cn1_invite=PROV1", 0L, 0L); + } + }); + Invites.checkForInvite(); + + // The retry itself, on the wire: what the record holds only matters if + // the resend uses it. + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + Invites.lookupRetryDelay = 0L; + Invites.flush(); + + String body = null; + for (ConnectionRequest r : implementation.getQueuedRequests()) { + if (r.getUrl() != null && r.getUrl().indexOf("/claim") >= 0) { + body = r.getRequestBody(); + } + } + assertNotNull(body, "the persisted referrer code was never resent"); + assertTrue(body.contains("PROV1"), body); + assertTrue(body.replace(" ", "").contains("\"source\":\"install_referrer\""), + "a referrer answer was resent as a direct link: " + body); + } + + @Test + @EdtTest + void anExhaustedReplacementLeavesTheEarlierAnswerStanding() { + // Every way of giving up on a replacement has to abandon it, not just + // the server no-match: the attempt cap wrote a terminal marker the + // durable attribution contradicts, and told the listener "no invite" + // after it had already been given one. + Invites.handleResolution(InviteTestSupport.resolvedJson("FIRST4", "c1", "sms"), + Invites.MATCH_DIRECT, false); + Invites.setReattribution(true); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/SECOND4"); + + Map pending = InviteStore.read(InviteStore.PENDING); + pending.put("attempts", "99"); + InviteStore.write(InviteStore.PENDING, pending); + + final int[] told = new int[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0]++; + } + }); + Invites.forgetLoadedState(); + Invites.checkForInvite(); + + assertEquals(0, told[0], "an exhausted replacement told the listener the opposite"); + assertEquals(Invites.STATE_RESOLVED, Invites.getState()); + } + + @Test + @EdtTest + void aDeniedLinkDoesNotOverwriteAnAttributionAlreadyGiven() { + // Writing a fresh DECLINED marker contradicted the durable attribution, + // which is still there and makes the state RESOLVED again on the next + // launch, and delivered a second, opposite callback for one install. + final int[] delivered = new int[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + delivered[0]++; + } + + public void attributionUnavailable(String reason) { + } + }); + Invites.handleResolution(InviteTestSupport.resolvedJson("FIRST5", "c1", "sms"), + Invites.MATCH_DIRECT, false); + assertEquals(1, delivered[0], "the attribution was not delivered, so this proves nothing"); + + // A later process: the callback has been given, and only the durable + // records remain. + Invites.forgetLoadedState(); + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + + final int[] told = new int[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0]++; + } + }); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/SECOND5"); + + assertEquals(0, told[0], "a denied link told an attributed install it had no invite"); + Invites.forgetLoadedState(); + assertEquals(Invites.STATE_RESOLVED, Invites.getState(), + "the state contradicted the durable attribution"); + } + + @Test + @EdtTest + void anEmptyButSuccessfulReferrerReadIsDefinitive() { + // The source burns its once-only flag for this case, so isSupported() + // can never read a referrer again -- but the reason it reports is the + // same one a transient failure uses, so the lookup stayed pending until + // the attempt budget ran out for an answer that had already arrived. + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + private boolean spent; + + public boolean isSupported() { + return !spent; + } + + public void requestReferrer(InstallReferrerCallback callback) { + spent = true; + callback.onUnavailable(Invites.REASON_NO_MATCH); + } + }); + Invites.checkForInvite(); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_FINGERPRINT, true); + + Invites.forgetLoadedState(); + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState(), + "a definitive empty referrer read was treated as retryable"); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java index caf5f415be7..e1136d6a30f 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java @@ -23,12 +23,17 @@ package com.codename1.analytics.invite; import com.codename1.io.Preferences; +import java.util.Map; +import com.codename1.junit.EdtTest; import com.codename1.junit.FormTest; import com.codename1.junit.UITestBase; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; class InviteUrlParsingTest extends UITestBase { @@ -97,4 +102,25 @@ void valueIsSplitOnTheFirstEqualsOnly() { InviteTestSupport.freshInstall(); assertEquals("a=b", Invites.codeFromQuery("cn1_invite=a%3Db")); } + + @Test + @EdtTest + void aFragmentIsNotPartOfTheCode() { + // An App Link commonly arrives with the fragment still attached, and it + // is not part of the path -- so this claimed a code called + // "ABC123#section", which exists nowhere. + assertTrue(Invites.handleUrl("https://cloud.codenameone.com/i/acme/ABC123#section")); + Map pending = InviteStore.read(InviteStore.PENDING); + assertNotNull(pending); + assertEquals("ABC123", InviteStore.get(pending, "code", null)); + } + + @Test + @EdtTest + void aFragmentAfterAQueryIsAlsoStripped() { + assertTrue(Invites.handleUrl( + "https://cloud.codenameone.com/i/acme/ABC124?utm_source=x#top")); + Map pending = InviteStore.read(InviteStore.PENDING); + assertEquals("ABC124", InviteStore.get(pending, "code", null)); + } } From cdb508df4ff5fee4dc2f48d8e38e2b4da681173d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:41:38 +0300 Subject: [PATCH 22/99] Invites: a direct link is its own question, and a reopened one keeps its clock A direct link reused whatever window and attempt budget an older deferred lookup had left on the pending record. Opened after that lookup had expired, or after its retries were spent, the exact code was persisted and then marked expired by beginDeferred() before it was ever looked at -- so an answer we were holding was never sent. It gets its own window and a fresh budget. Reopening a terminal marker after consent restarted the attribution window from the moment of the grant, because the marker kept no timing. A user answering the prompt a week later would then have run a fresh fingerprint lookup and reported invite_install for an unrelated click. The marker carries firstLaunch and expiresAt across, and the resumed record restores them. Those two fields are deliberately not treated as profile data by the tests that assert a refused profile is deleted: they are clock readings, they describe no device, and the marker never leaves it. Keeping them is what prevents the mismatch above, so dropping them would cost privacy rather than protect it. That assertion now names the profile fields instead of counting them, which is how it came to be arguing against this. The fragment is stripped once, before either branch parses the url. Doing it on the path branch alone left the query branch -- which runs first -- claiming "ABC123#section" from a query-style link. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 57 ++++++++++++++--- .../invite/InviteConsentAndErasureTest.java | 8 ++- .../invite/InviteResilienceTest.java | 62 ++++++++++++++++++- .../invite/InviteUrlParsingTest.java | 12 ++++ 4 files changed, 127 insertions(+), 12 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index b1d5994edd5..55b089618d4 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -220,6 +220,13 @@ public final class Invites { // than announcing itself a second time. Carried onto the pending record as // soon as one exists, which is what makes it survive the process. private static boolean reopenedAlreadyDelivered; + + // The original clock readings a reopened terminal marker carried, so the + // resumed lookup keeps the window it started with rather than restarting it + // from the moment consent was granted. + private static long reopenedFirstLaunch; + + private static long reopenedExpiresAt; private static boolean deferredStarted; // When the last claim or match was issued. flush() restarts only once this @@ -535,6 +542,15 @@ public static boolean handleUrl(String url) { } Map pending = pendingRecord(); pending.put("code", code); + // The window and the budget are reset, because this is a new question. + // Inheriting them from an older deferred lookup meant a link opened + // after that lookup had expired, or after its retries were spent, was + // marked expired by beginDeferred() before the saved code was ever + // looked at -- so an exact answer we were holding was never sent. + long now = System.currentTimeMillis(); + pending.put("firstLaunch", String.valueOf(now)); + pending.put("expiresAt", String.valueOf(now + attributionWindow)); + pending.put("attempts", "0"); pending.put("codeSource", "universal_link"); pending.put("codeMatch", MATCH_DIRECT); pending.put("codeDeferred", "false"); @@ -826,6 +842,8 @@ public static void reset() { lookupIssuedAt = 0; undelivered = null; reopenedAlreadyDelivered = false; + reopenedFirstLaunch = 0; + reopenedExpiresAt = 0; unacknowledged.clear(); } @@ -978,6 +996,15 @@ static String extractCode(String url) { if (url == null || url.length() == 0) { return null; } + // Stripped once, here, before either branch reads the url. Doing it on + // the path branch alone left the query branch -- which runs first -- + // parsing "?cn1_invite=ABC123#section" and claiming a code with the + // fragment glued to it. A fragment is client-side and part of neither + // the path nor the query. + int frag = url.indexOf('#'); + if (frag >= 0) { + url = url.substring(0, frag); + } int q = url.indexOf('?'); if (q >= 0) { String code = codeFromQuery(url.substring(q + 1)); @@ -1010,14 +1037,7 @@ static String extractCode(String url) { path = path.substring(0, rel); } } - // A fragment is not part of the path and is not part of the code, and - // an App Link commonly arrives with one still attached -- so - // /i/acme/ABC123#section claimed a code called "ABC123#section", which - // exists nowhere. - int hash = path.indexOf('#'); - if (hash >= 0) { - path = path.substring(0, hash); - } + if (!path.startsWith("/i/")) { return null; } @@ -1163,6 +1183,15 @@ private static void markTerminal(String reason) { private static void markTerminal(int terminalState, String reason) { Map done = new LinkedHashMap(); done.put("state", String.valueOf(terminalState)); + // The timing is carried, and only the timing. firstLaunch and expiresAt + // say nothing about the device -- they are two clock readings -- and + // without them a reopened marker started the window again from the + // moment consent was granted. A user who answers the prompt a week + // later would then have run a fresh fingerprint lookup and reported + // invite_install for somebody else's click. + Map before = InviteStore.read(InviteStore.PENDING); + InviteStore.put(done, "firstLaunch", InviteStore.get(before, "firstLaunch", null)); + InviteStore.put(done, "expiresAt", InviteStore.get(before, "expiresAt", null)); if (reason != null) { // Recorded on the marker, not only in memory. The listener contract // is "exactly one of the two methods per install, and the answer is @@ -1210,8 +1239,14 @@ private static Map pendingRecord() { } pending = new LinkedHashMap(); long now = System.currentTimeMillis(); - pending.put("firstLaunch", String.valueOf(now)); - pending.put("expiresAt", String.valueOf(now + attributionWindow)); + // Restored from the marker a reopen carried them on, when there is one, + // so granting consent late does not restart the attribution window. + pending.put("firstLaunch", String.valueOf(reopenedFirstLaunch > 0 + ? reopenedFirstLaunch : now)); + pending.put("expiresAt", String.valueOf(reopenedExpiresAt > 0 + ? reopenedExpiresAt : now + attributionWindow)); + reopenedFirstLaunch = 0; + reopenedExpiresAt = 0; pending.put("attempts", "0"); pending.put("state", String.valueOf(STATE_PENDING)); if (reopenedAlreadyDelivered) { @@ -1257,6 +1292,8 @@ private static void beginDeferred() { // on the next launch. It rides the pending record instead. reopenedAlreadyDelivered = InviteStore.getBoolean(marker, "delivered", false); + reopenedFirstLaunch = InviteStore.getLong(marker, "firstLaunch", 0); + reopenedExpiresAt = InviteStore.getLong(marker, "expiresAt", 0); InviteStore.delete(InviteStore.PENDING); state = STATE_NONE; s = STATE_NONE; diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java index 651dba3c650..dd887fc16bd 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java @@ -324,8 +324,14 @@ private void assertNoProfileHeld(String message) { if (record == null) { return; } + // firstLaunch and expiresAt are NOT in this list. They are two clock + // readings, they describe no device, and they never leave it -- the + // marker is local. Keeping them is what stops a consent grant arriving + // a week later from restarting the attribution window and matching an + // unrelated click, so dropping them would cost privacy rather than + // protect it. for (String key : new String[] {"platform", "osVersion", "deviceModel", - "screenWidth", "screenHeight", "locale", "firstLaunch"}) { + "screenWidth", "screenHeight", "locale"}) { assertFalse(record.containsKey(key), message + " (held " + key + ")"); } } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 7968a82e040..9cc068f245f 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -134,8 +134,16 @@ void theTerminalMarkerKeepsNoDeviceProfile() { Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_FINGERPRINT, true); Map marker = InviteStore.read(InviteStore.PENDING); assertNotNull(marker, "the answer has to be durable"); - assertEquals(1, marker.size(), "the marker kept fields beyond the state: " + marker); assertTrue(marker.containsKey("state")); + // The state, the reason, and the two clock readings that enforce the + // original window across a reopen -- and nothing that describes the + // device. Asserting a field count instead would fail the next time the + // marker legitimately carries one more, which is how this assertion + // came to be arguing against a privacy improvement. + for (String key : new String[] {"platform", "osVersion", "deviceModel", + "screenWidth", "screenHeight", "locale", "code"}) { + assertFalse(marker.containsKey(key), "the marker held " + key + ": " + marker); + } } @Test @@ -974,4 +982,56 @@ public void requestReferrer(InstallReferrerCallback callback) { assertEquals(Invites.STATE_NONE_FOUND, Invites.getState(), "a definitive empty referrer read was treated as retryable"); } + + @Test + @EdtTest + void aDirectLinkGetsItsOwnWindowAndBudget() { + // Inheriting them from an older deferred lookup meant a link opened + // after that lookup had expired, or after its retries were spent, was + // marked expired by beginDeferred() before the saved code was ever + // looked at -- so an exact answer we were holding was never sent. + Invites.checkForInvite(); + Map stale = InviteStore.read(InviteStore.PENDING); + assertNotNull(stale); + stale.put("expiresAt", String.valueOf(System.currentTimeMillis() - 1000L)); + stale.put("attempts", String.valueOf(99)); + InviteStore.write(InviteStore.PENDING, stale); + + Invites.handleUrl("https://cloud.codenameone.com/i/acme/FRESH1"); + + Map pending = InviteStore.read(InviteStore.PENDING); + assertEquals("FRESH1", InviteStore.get(pending, "code", null)); + assertTrue(InviteStore.getLong(pending, "expiresAt", 0) > System.currentTimeMillis(), + "the direct claim inherited an expired window"); + // One, not zero: the reset puts it back to zero and the claim this + // call issues counts as the first attempt against the new budget. + assertEquals(1, InviteStore.getInt(pending, "attempts", -1), + "the direct claim inherited a spent retry budget"); + } + + @Test + @EdtTest + void reopeningAfterConsentKeepsTheOriginalWindow() { + // Without the original timings a reopened marker started the window + // again from the moment consent was granted, so a user answering the + // prompt a week later ran a fresh fingerprint lookup and could report + // invite_install for somebody else's click. + Invites.checkForInvite(); + Map first = InviteStore.read(InviteStore.PENDING); + assertNotNull(first); + // A distinctive value rather than whatever the clock produced a + // millisecond ago: a fresh window computed at grant time would land on + // almost the same number, and the test would pass by coincidence. + long originalExpiry = System.currentTimeMillis() + 123_456_789L; + first.put("expiresAt", String.valueOf(originalExpiry)); + InviteStore.write(InviteStore.PENDING, first); + + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + Analytics.setConsent(AnalyticsConsent.granted()); + + Map resumed = InviteStore.read(InviteStore.PENDING); + assertNotNull(resumed); + assertEquals(originalExpiry, InviteStore.getLong(resumed, "expiresAt", 0), + "granting consent restarted the attribution window"); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java index e1136d6a30f..ba0f5657da0 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java @@ -123,4 +123,16 @@ void aFragmentAfterAQueryIsAlsoStripped() { Map pending = InviteStore.read(InviteStore.PENDING); assertEquals("ABC124", InviteStore.get(pending, "code", null)); } + + @Test + @EdtTest + void aFragmentOnAQueryStyleLinkIsAlsoStripped() { + // The query branch runs first, so stripping on the path branch alone + // left it parsing "?cn1_invite=ABC125#section" and claiming a code with + // the fragment glued to it. + assertTrue(Invites.handleUrl( + "https://cloud.codenameone.com/i/acme?cn1_invite=ABC125#section")); + Map pending = InviteStore.read(InviteStore.PENDING); + assertEquals("ABC125", InviteStore.get(pending, "code", null)); + } } From 8bfb8ebecc2d733328bb01fa44ac59a0955b7604 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:13:22 +0300 Subject: [PATCH 23/99] Invites: four places the state machine forgot what it already knew setAttributionWindow(0) turns off the deferred lookup -- the statistical one that needs a window to mean anything -- and it was also discarding an exact code we were already holding, reporting "unsupported" for an invite the user really did open. The kill switch no longer applies when there is a saved code. setReattribution did not invalidate the cached state, and loadState reads the pending record only when re-attribution is on. A process that cached STATE_RESOLVED before the setter ran therefore never looked at a durable replacement again -- and setInviteListener, which most applications call first, is enough to cache it. The terminal marker inherited the timings a reopen carried but not the delivery state, so a resumed lookup that then expired or found nothing told a listener in the next process a second time. The reopen protection covered a successful resolve and not this. A direct link reopening the lookup left the held terminal answer in place, so a listener registered after the link resolved was handed the stale unavailable result -- and deliveredThisRun then suppressed the correct one. The consent resume already cleared it; this path did not. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 34 +++++- .../invite/InviteResilienceTest.java | 111 ++++++++++++++++++ 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 55b089618d4..c927e9e9d16 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -540,6 +540,12 @@ public static boolean handleUrl(String url) { Analytics.autoEvent("invite_opened", CATEGORY, p); return true; } + // The held answer is not the answer any more. A no-match or an expiry + // that became terminal with no listener is remembered in `undelivered`, + // and leaving it there handed a listener registered after this link + // resolved the stale unavailable result -- with deliveredThisRun then + // suppressing the correct one. + undelivered = null; Map pending = pendingRecord(); pending.put("code", code); // The window and the budget are reset, because this is a new question. @@ -773,6 +779,12 @@ public static long getAttributionWindow() { /// - `value`: true for last touch public static void setReattribution(boolean value) { reattribution = value; + // The cached state was derived under the old value. loadState() reads + // the pending record only when re-attribution is on, so a process that + // cached STATE_RESOLVED before this call would never look at a durable + // replacement again -- and setInviteListener(), which most applications + // call first, is enough to cache it. + stateLoaded = false; } /// Whether last touch attribution is enabled. @@ -1167,6 +1179,12 @@ private static boolean abandonReplacement() { return true; } + private static boolean hasSavedCode() { + Map pending = InviteStore.read(InviteStore.PENDING); + String code = InviteStore.get(pending, "code", null); + return code != null && code.length() > 0; + } + private static void markTerminal() { markTerminal(null); } @@ -1192,6 +1210,14 @@ private static void markTerminal(int terminalState, String reason) { Map before = InviteStore.read(InviteStore.PENDING); InviteStore.put(done, "firstLaunch", InviteStore.get(before, "firstLaunch", null)); InviteStore.put(done, "expiresAt", InviteStore.get(before, "expiresAt", null)); + // And the delivery state, for the same reason the resolved record + // inherits it: a reopened lookup that ends terminally has still been + // answered once, and dropping the flag here delivered a second + // attributionUnavailable() to a listener registered afterwards. The + // reopen protection covered a successful resolve and not this. + if (InviteStore.getBoolean(before, "delivered", false)) { + done.put("delivered", "true"); + } if (reason != null) { // Recorded on the marker, not only in memory. The listener contract // is "exactly one of the two methods per install, and the answer is @@ -1302,7 +1328,13 @@ private static void beginDeferred() { if (s == STATE_RESOLVED || s == STATE_NONE_FOUND || s == STATE_DECLINED) { return; } - if (attributionWindow == 0) { + if (attributionWindow == 0 && !hasSavedCode()) { + // The kill switch turns off DEFERRED attribution -- the statistical + // lookup that needs a window to mean anything. A code we are + // already holding is an exact answer that needs none, and refusing + // to send it reported "unsupported" for an invite the user really + // did open. + // // setState() only rewrites a record that already exists, and on a // fresh install none does -- so this answer was purely in memory // and the listener heard it again on every launch, breaking the diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 9cc068f245f..06cf22f223a 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -1034,4 +1034,115 @@ void reopeningAfterConsentKeepsTheOriginalWindow() { assertEquals(originalExpiry, InviteStore.getLong(resumed, "expiresAt", 0), "granting consent restarted the attribution window"); } + + @Test + @EdtTest + void theZeroWindowDoesNotDiscardAnExactCodeWeAreHolding() { + // setAttributionWindow(0) turns off the DEFERRED lookup, which is the + // one that needs a window to mean anything. A code already in hand is + // an exact answer that needs none, and refusing to send it reported + // "unsupported" for an invite the user really did open. + Invites.handleUrl("https://cloud.codenameone.com/i/acme/EXACT9"); + assertEquals("EXACT9", InviteStore.get( + InviteStore.read(InviteStore.PENDING), "code", null)); + + Invites.setAttributionWindow(0); + Invites.forgetLoadedState(); + final String[] told = new String[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0] = reason; + } + }); + Invites.checkForInvite(); + + assertNull(told[0], "the kill switch discarded an exact code we were holding"); + assertEquals(Invites.STATE_PENDING, Invites.getState()); + } + + @Test + @EdtTest + void turningOnReattributionLetsTheStateBeReadAgain() { + // loadState() reads the pending record only when re-attribution is on, + // so a process that cached STATE_RESOLVED before the setter ran would + // never look at a durable replacement again -- and setInviteListener, + // which most applications call first, is enough to cache it. + Invites.handleResolution(InviteTestSupport.resolvedJson("FIRST6", "c1", "sms"), + Invites.MATCH_DIRECT, false); + Invites.setReattribution(true); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/SECOND6"); + Invites.setReattribution(false); + + // A later process: the listener is registered first, caching the state + // under the default, and only then is re-attribution turned on. + Invites.forgetLoadedState(); + Invites.setInviteListener(null); + assertEquals(Invites.STATE_RESOLVED, Invites.getState()); + Invites.setReattribution(true); + + assertEquals(Invites.STATE_PENDING, Invites.getState(), + "the cached state hid the durable replacement"); + } + + @Test + @EdtTest + void aResumedLookupThatEndsTerminallyIsNotAnnouncedTwice() { + // The reopen carries the delivery state onto the pending record, and + // the terminal rewrite dropped it -- so a listener registered in the + // next process was told a second time. + final int[] told = new int[1]; + InviteListener l = new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0]++; + } + }; + Invites.setInviteListener(l); + Invites.checkForInvite(); + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + assertEquals(1, told[0], "the refusal was not delivered, so this proves nothing"); + + Analytics.setConsent(AnalyticsConsent.granted()); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_FINGERPRINT, true); + + Invites.forgetLoadedState(); + Invites.setInviteListener(null); + Invites.setInviteListener(l); + assertEquals(1, told[0], "the resumed lookup announced its end a second time"); + } + + @Test + @EdtTest + void aDirectLinkDiscardsAHeldAnswerThatIsNoLongerTrue() { + // A no-match that became terminal with no listener is remembered, and + // leaving it there handed a listener registered after this link + // resolved the stale unavailable result -- with deliveredThisRun then + // suppressing the correct one. + Invites.checkForInvite(); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_FINGERPRINT, true); + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState()); + + Invites.handleUrl("https://cloud.codenameone.com/i/acme/LATER6"); + Invites.handleResolution(InviteTestSupport.resolvedJson("LATER6", "c1", "sms"), + Invites.MATCH_DIRECT, false); + + final String[] unavailable = new String[1]; + final InviteAttribution[] received = new InviteAttribution[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + received[0] = a; + } + + public void attributionUnavailable(String reason) { + unavailable[0] = reason; + } + }); + assertNull(unavailable[0], "a stale held answer was reported over a resolved one"); + assertNotNull(received[0], "the resolved attribution was suppressed by it"); + } } From 9dbd5957753a59272521e613ae4e0bbd2a538b34 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:30:38 +0300 Subject: [PATCH 24/99] Invites: the window bounds the guess, not the answer The attribution window bounds the DEFERRED lookup, and the expiry check ran before the saved-code branch -- so an exact code we were already holding was marked expired whenever the two coexist: a zero window, where handleUrl records an expiry of "now", or a first claim that failed and is retried after the window ran out. Same reasoning as the kill switch last round, applied to the other gate. Analytics.setConsentMode now dispatches onConsentChanged. The mode decides what an absent choice means, so changing it changes what is permitted for a user who has answered nothing -- and without a dispatch ordinary events resumed on the switch while a feature that had stopped under the old mode stayed stopped, the two disagreeing about the same user with nothing to reconcile them. It hands over the effective consent, exactly as setConsent does, so a provider needs no second rule for this path. The invite provider keeps its early return for an unanswered OPT_IN prompt. That distinction is load-bearing and I broke it in the first version of this change: "no choice under opt-in" is not a refusal, and reporting one deletes the profile captured on the first launch and moves to DECLINED for a user who refused nothing. An existing test caught it. dispatchNewIntentUrl now consumes the intent's data, as the lazy getAppArg() path already does. CodenameOneActivity.onStop() clears the app arg, so leaving the data on the intent meant the next read after a resume rebuilt the same url and an application handling AppArg in start() saw the deep link a second time. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/analytics/Analytics.java | 26 ++++++++++ .../invite/InviteAttributionProvider.java | 12 +++++ .../codename1/analytics/invite/Invites.java | 8 ++- .../impl/android/AndroidImplementation.java | 7 +++ .../invite/InviteResilienceTest.java | 51 +++++++++++++++++++ 5 files changed, 103 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/analytics/Analytics.java b/CodenameOne/src/com/codename1/analytics/Analytics.java index f54c290a913..ccb45770a32 100644 --- a/CodenameOne/src/com/codename1/analytics/Analytics.java +++ b/CodenameOne/src/com/codename1/analytics/Analytics.java @@ -148,8 +148,34 @@ public static void setConsentMode(ConsentMode mode) { if (mode == null) { return; } + List snapshot; synchronized (LOCK) { + if (mode == consentMode) { + return; + } consentMode = mode; + snapshot = new ArrayList(PROVIDERS); + } + // Providers are told, because the mode decides what an absent choice + // means: under OPT_IN nothing is permitted until the user answers, and + // under OPT_OUT everything is until they refuse. Changing it therefore + // changes what is allowed for a user who has answered nothing, and + // without this dispatch ordinary events resumed while a feature that + // had stopped on the old mode stayed stopped -- the two disagreeing + // about the same user with nothing to reconcile them. + // + // The consent handed over is the effective one, exactly as + // setConsent() does, so a provider needs no second rule for this path. + AnalyticsConsent recorded = getConsent(); + AnalyticsConsent effective = recorded != null ? recorded + : (mode == ConsentMode.OPT_OUT + ? AnalyticsConsent.granted() : AnalyticsConsent.denied()); + for (AnalyticsProvider p : snapshot) { + try { + p.onConsentChanged(effective); + } catch (Throwable t) { + Log.e(t); + } } } diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java b/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java index 2a3aa624850..d553ebfeeec 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java @@ -106,6 +106,18 @@ public void onConsentChanged(AnalyticsConsent consent) { // analytics while a declined invite lookup stayed stopped and a // resolved attribution's dimensions stayed cleared, so the two // disagreed about the same user. + // No recorded choice, so the MODE decides -- and the two answers are + // not "allowed" and "refused". Under OPT_IN the prompt is simply + // unanswered and NOTHING happens: reporting a refusal there would + // delete the profile captured on the first launch and move to DECLINED + // for a user who has refused nothing, which is the paragraph above. + // Under OPT_OUT the implicit allow is in force and that is a real + // transition. + // + // Analytics.setConsentMode now dispatches here when the mode changes, + // which is what lets a switch to OPT_OUT reach this at all -- ordinary + // analytics used to resume on that switch while a declined lookup + // stayed stopped and an attribution's dimensions stayed cleared. if (Analytics.getConsentMode() == ConsentMode.OPT_OUT) { Invites.onConsentChanged(true); } diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index c927e9e9d16..e71dc9e4863 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1359,7 +1359,13 @@ private static void beginDeferred() { return; } Map pending = pendingRecord(); - long expires = InviteStore.getLong(pending, "expiresAt", 0); + // The window bounds the DEFERRED lookup, and a code we are holding is + // not one -- it is an exact answer. Applying the expiry to it lost that + // answer for the two cases where a saved code coexists with an expired + // window: a zero window, where handleUrl records an expiry of "now", + // and a first claim that failed and is being retried after the window + // ran out. Same reasoning as the kill switch above. + long expires = hasSavedCode() ? 0 : InviteStore.getLong(pending, "expiresAt", 0); if (expires > 0 && System.currentTimeMillis() > expires) { if (abandonReplacement()) { return; diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 4c4ae25dbcc..3552619336f 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -1697,6 +1697,13 @@ static void dispatchNewIntentUrl(Intent intent) { // rather than whatever the previous intent left cached. instance.setAppArg(null); clearIntentProperties(); + // And the intent's data is consumed, exactly as the lazy + // getAppArg() path consumes it. CodenameOneActivity.onStop() clears + // the app arg, so leaving the data on the intent meant the next + // read after a resume rebuilt the same url from it, and an + // application that handles AppArg in start() saw the deep link a + // second time -- opening the same invite twice for one tap. + intent.setData(null); Display.getInstance().setProperty("AppArg", data.toString()); } catch (Throwable t) { com.codename1.io.Log.e(t); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 06cf22f223a..f1f54d9a862 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -1145,4 +1145,55 @@ public void attributionUnavailable(String reason) { assertNull(unavailable[0], "a stale held answer was reported over a resolved one"); assertNotNull(received[0], "the resolved attribution was suppressed by it"); } + + @Test + @EdtTest + void aSavedExactCodeIsNotSubjectToTheDeferredWindow() { + // The window bounds the deferred lookup, and a code we are holding is + // an exact answer rather than one. Applying the expiry to it lost that + // answer whenever the two coexist -- a zero window, where handleUrl + // records an expiry of "now", or a first claim that failed and is + // retried after the window ran out. + Invites.handleUrl("https://cloud.codenameone.com/i/acme/SAVED9"); + Map pending = InviteStore.read(InviteStore.PENDING); + assertNotNull(pending); + pending.put("expiresAt", String.valueOf(System.currentTimeMillis() - 1000L)); + InviteStore.write(InviteStore.PENDING, pending); + + final String[] told = new String[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0] = reason; + } + }); + Invites.forgetLoadedState(); + Invites.checkForInvite(); + + assertNull(told[0], "an exact code we were holding was marked expired"); + assertEquals(Invites.STATE_PENDING, Invites.getState()); + } + + @Test + @EdtTest + void switchingToOptOutResumesADeclinedLookup() { + // setConsentMode changes what an absent choice means, so it changes + // what is allowed -- and it dispatched to no provider, so ordinary + // analytics resumed while a declined lookup stayed stopped and an + // attribution's dimensions stayed cleared. + Analytics.setConsentMode(ConsentMode.OPT_IN); + Invites.checkForInvite(); + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + assertEquals(Invites.STATE_DECLINED, Invites.getState()); + Analytics.setConsent(null); + assertEquals(Invites.STATE_DECLINED, Invites.getState(), + "clearing the choice under opt-in must change nothing"); + + Analytics.setConsentMode(ConsentMode.OPT_OUT); + + assertEquals(Invites.STATE_PENDING, Invites.getState(), + "switching to opt-out did not resume the declined lookup"); + } } From 8e45b87689f3b86ef2fbd6dcc762b0697dad9ec6 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:03:44 +0300 Subject: [PATCH 25/99] Invites: one delivery is not one url, and a failed write is not a resolution The launch argument was deduplicated for the life of the install, so tapping the same link again -- which delivers the identical string -- was ignored for ever: the install lost its invite_opened re-engagement event, and under re-attribution the later open could never win. It is deduplicated for the run instead, which is what the guard was actually for: ignoring repeated reads of one delivery by an application that calls checkForInvite from more than one place. What made the durable guard necessary was Android handing the same launch intent back on a later start, and both paths that read it now consume the intent's data -- the lazy getAppArg() always did, and dispatchNewIntentUrl does as of the previous commit -- so a stale intent no longer reproduces the argument. writeAttribution set the resolved state even when the durable write failed. Everything after that write assumes the record is on disk: deliverPending() re-reads it and finds nothing, and flush() will not retry because the state says resolved, so a valid answer was neither delivered nor asked for again until the process restarted. It returns instead, leaving the pending record in place for the next flush or launch. The test for that needed a seam: a full or read-only store cannot be produced from a test, and the paths that only run when a write fails are the ones most worth pinning. Co-Authored-By: Claude Opus 5 (1M context) --- .../analytics/invite/InviteStore.java | 13 ++++++ .../codename1/analytics/invite/Invites.java | 40 ++++++++++++++--- .../invite/InviteResilienceTest.java | 43 +++++++++++++++++++ .../analytics/invite/InviteTestSupport.java | 12 ++++++ 4 files changed, 103 insertions(+), 5 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java index c55400cecfa..158a8ada13e 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java @@ -101,7 +101,20 @@ static Map read(String record) { // Returns false when the record did not reach the disk. Callers that care // about exactly-once behaviour check this; the rest may ignore it. + // The same seam for a named record. A full or read-only store cannot be + // produced from a test, and the paths that only run when a write fails are + // the ones most worth pinning. + private static String failNextNamed; + + static void failNextWriteForTest(String name) { + failNextNamed = name; + } + static boolean write(String record, Map values) { + if (record != null && record.equals(failNextNamed)) { + failNextNamed = null; + return false; + } try { Storage s = Storage.getInstance(); if (s == null) { diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index e71dc9e4863..e3d85cfa958 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -188,6 +188,8 @@ public final class Invites { static final String PROPERTY_SLUG = "invite.slug"; static final String PREF_SLUG = "cn1$inviteSlug"; + // Kept only so reset() can clear what earlier versions of this class + // persisted. Nothing writes it any more -- see checkForInvite. static final String PREF_CONSUMED_ARG = "cn1$inviteConsumedArg"; // The referrer key the link service puts on the store url. Compared with @@ -215,6 +217,9 @@ public final class Invites { // a later launch reaches this answer again through the ordinary path. private static String undelivered; + // The launch argument already handled in THIS run. See checkForInvite. + private static String consumedArg; + // Set when a terminal marker that had already been delivered is reopened, // so the attribution the resumed lookup writes inherits that fact rather // than announcing itself a second time. Carried onto the pending record as @@ -477,12 +482,25 @@ public static boolean checkForInvite() { if (d != null) { appArg = d.getProperty("AppArg", null); } + // Deduplicated for this RUN, not for the life of the install. + // + // The point is to ignore repeated reads of one delivery -- an app that + // calls this from start() and again from a form -- and a durable record + // could not tell those apart from a second tap on the same link, which + // delivers the identical string. So the same link tapped again was + // ignored for ever: the install lost its invite_opened re-engagement + // event, and under re-attribution the later open could never win. + // + // What made the durable guard necessary was Android handing the same + // launch intent back on a later start. Both paths that read it now + // consume the intent's data -- the lazy getAppArg() always did, and + // dispatchNewIntentUrl does as well -- so a stale intent no longer + // reproduces the argument. boolean consumed = false; - if (appArg != null && appArg.length() > 0 - && !appArg.equals(Preferences.get(PREF_CONSUMED_ARG, ""))) { + if (appArg != null && appArg.length() > 0 && !appArg.equals(consumedArg)) { consumed = handleUrl(appArg); if (consumed) { - Preferences.set(PREF_CONSUMED_ARG, appArg); + consumedArg = appArg; } } if (!consumed) { @@ -607,6 +625,7 @@ private static void loadAttribution() { // the answer survives a relaunch, and nothing else can check that. static void forgetLoadedState() { undelivered = null; + consumedArg = null; lookupIssuedAt = 0; stateLoaded = false; attributionLoaded = false; @@ -853,6 +872,7 @@ public static void reset() { deferredStarted = false; lookupIssuedAt = 0; undelivered = null; + consumedArg = null; reopenedAlreadyDelivered = false; reopenedFirstLaunch = 0; reopenedExpiresAt = 0; @@ -1891,9 +1911,19 @@ private static void resolve(InviteAttribution a, String confidence) { // failed write, so the result is checked. Deleting the pending record // after a failed write would leave neither an attribution nor any retry // information, losing the resolution permanently at the next restart. - if (InviteStore.write(InviteStore.ATTRIBUTION, record)) { - InviteStore.delete(InviteStore.PENDING); + if (!InviteStore.write(InviteStore.ATTRIBUTION, record)) { + // The store is full or read-only. Everything below assumes the + // record is on disk: deliverPending() re-reads it before calling + // the listener and finds nothing, and flush() will not retry + // because the state says resolved -- so a valid answer was neither + // delivered nor asked for again until the process restarted. The + // pending record is deliberately left in place, so the next flush + // or launch resends the lookup. + Log.p("invite: the attribution could not be persisted, so the lookup stays " + + "pending and will be retried", Log.WARNING); + return; } + InviteStore.delete(InviteStore.PENDING); resolved = a; attributionLoaded = true; state = STATE_RESOLVED; diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index f1f54d9a862..d9f60a2fa29 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -26,6 +26,7 @@ import com.codename1.analytics.AnalyticsConsent; import com.codename1.analytics.ConsentMode; import com.codename1.io.ConnectionRequest; +import com.codename1.ui.Display; import com.codename1.junit.EdtTest; import com.codename1.junit.FormTest; import java.io.ByteArrayInputStream; @@ -1196,4 +1197,46 @@ void switchingToOptOutResumesADeclinedLookup() { assertEquals(Invites.STATE_PENDING, Invites.getState(), "switching to opt-out did not resume the declined lookup"); } + + @Test + @EdtTest + void tappingTheSameLinkAgainInALaterRunIsProcessed() { + // Through checkForInvite, which is where the deduplication lives -- a + // test calling handleUrl directly never reaches it and proves nothing. + // + // The durable guard could not tell a repeated read of one delivery from + // a second tap, which delivers the identical string, so the same link + // was ignored for ever: the install lost its invite_opened + // re-engagement event, and under re-attribution the later open could + // never win. + String url = "https://cloud.codenameone.com/i/acme/TAP1"; + Display.getInstance().setProperty("AppArg", url); + assertTrue(Invites.checkForInvite(), "the first delivery was not handled"); + + // Repeated reads within one run are still ignored, which is what the + // deduplication is for. + assertFalse(Invites.checkForInvite(), "one delivery was handled twice"); + + // A later run: the same url arrives again from a second tap. + Invites.forgetLoadedState(); + Display.getInstance().setProperty("AppArg", url); + assertTrue(Invites.checkForInvite(), "a second tap on the same link was ignored"); + } + + @FormTest + void aFailedAttributionWriteLeavesTheLookupPending() { + // Everything after the write assumes the record is on disk: + // deliverPending() re-reads it and finds nothing, and flush() will not + // retry because the state says resolved -- so a valid answer was + // neither delivered nor asked for again until a restart. + Invites.checkForInvite(); + InviteStore.failNextWriteForTest(InviteStore.ATTRIBUTION); + Invites.handleResolution(InviteTestSupport.resolvedJson("NOSPACE1", "c1", "sms"), + Invites.MATCH_DIRECT, false); + + assertEquals(Invites.STATE_PENDING, Invites.getState(), + "a failed write still reported the install as resolved"); + assertNotNull(InviteStore.read(InviteStore.PENDING), + "the retry information was thrown away with it"); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java index 3e9b7b6b649..ca6510d232b 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java @@ -26,6 +26,7 @@ import com.codename1.analytics.AnalyticsConsent; import com.codename1.analytics.ConsentMode; import com.codename1.io.Preferences; +import com.codename1.ui.Display; /** * Puts the static invite state back to a fresh-install baseline. Invites keeps @@ -37,6 +38,7 @@ private InviteTestSupport() { } static RecordingProvider freshInstall() { + clearAppArg(); Analytics.clearProviders(); Analytics.clearDimensions(); Analytics.setConsentMode(ConsentMode.OPT_IN); @@ -58,6 +60,7 @@ static RecordingProvider freshInstall() { } static void tearDown() { + clearAppArg(); Invites.setInviteListener(null); Invites.registerInstallReferrerSource(null); Invites.reset(); @@ -69,6 +72,15 @@ static void tearDown() { Preferences.delete(Invites.PREF_CONSUMED_ARG); } + // The launch argument is process-wide, so a test that sets one and does + // not clear it sends every later test down the direct-link path. + private static void clearAppArg() { + Display d = Display.getInstance(); + if (d != null) { + d.setProperty("AppArg", null); + } + } + /** A canned server answer, in the shape the link service returns. */ static String resolvedJson(String code, String campaign, String channel) { return "{\"resolved\":true,\"code\":\"" + code + "\",\"campaign\":\"" From 9324a403e9d890ec1a11b996d38ff05c4be8c02c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:20:29 +0300 Subject: [PATCH 26/99] Invites: consuming the argument, and three restarts that should not have happened Remembering the last argument still could not tell two deliveries of one url apart from two reads of one delivery, and a live process really does span both -- an Android onNewIntent after the app is backgrounded is the ordinary case. So the property is consumed instead: a later read sees nothing, and a genuine second delivery sets it again and is handled. Only an invite is consumed, so an application routing its own deep links finds its argument exactly as it arrived, and the read happens after Display.setProperty has already fired the external-url dispatch. A consent update with analytics still allowed -- changing only personalization or ad storage -- restarted the lookup, queueing a second whose answer was as valid as the first, so the funnel event fired twice and repeated updates spent the retry budget with nothing having failed. It restarts only when nothing is outstanding, as flush() already did. STATE_DECLINED is exempt from that check: the withdrawal that produced it discarded whatever was in flight, which is now said explicitly so a later grant resumes at once rather than waiting out a retry delay for a request nobody can act on. Withdrawing consent during a re-attribution replacement wrote a DECLINED marker instead of abandoning it, telling a registered listener "no invite" as a second contradictory callback for an install that is still attributed and goes back to resolved on the next launch. A failed attribution write gives the attempt back. Leaving the counter at the cap meant the next flush marked the install terminal instead of performing the retry the previous commit promised -- so the very last response, the one most likely to be the only one left, could never be stored. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 68 +++++++++++-- .../invite/InviteResilienceTest.java | 98 ++++++++++++++++++- 2 files changed, 155 insertions(+), 11 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index e3d85cfa958..08ccc336145 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -199,7 +199,9 @@ public final class Invites { // the devices nobody can reproduce on. private static final String REFERRER_KEY = "cn1_invite"; - private static final int MAX_ATTEMPTS = 5; + // Package private so a test can drive the attempt cap without five round + // trips. + static final int MAX_ATTEMPTS = 5; private static String linkBase; private static long attributionWindow = DEFAULT_ATTRIBUTION_WINDOW; @@ -217,9 +219,6 @@ public final class Invites { // a later launch reaches this answer again through the ordinary path. private static String undelivered; - // The launch argument already handled in THIS run. See checkForInvite. - private static String consumedArg; - // Set when a terminal marker that had already been delivered is reopened, // so the attribution the resumed lookup writes inherits that fact rather // than announcing itself a second time. Carried onto the pending record as @@ -496,11 +495,25 @@ public static boolean checkForInvite() { // consume the intent's data -- the lazy getAppArg() always did, and // dispatchNewIntentUrl does as well -- so a stale intent no longer // reproduces the argument. + // The argument is CONSUMED, not remembered. + // + // Remembering the last value cannot tell two deliveries of one url + // apart from two reads of one delivery -- and a live process really can + // span both, an Android onNewIntent after the app is backgrounded being + // the ordinary case. So the property is cleared instead: a later read + // sees nothing, and a genuine second delivery sets it again and is + // handled. + // + // Only when this really is an invite. Anything else is left exactly as + // it arrived, so an application routing its own deep links is + // unaffected -- and the property is read here after + // Display.setProperty has already fired the external-url dispatch, so + // a router that consumes it has done so before this runs. boolean consumed = false; - if (appArg != null && appArg.length() > 0 && !appArg.equals(consumedArg)) { + if (appArg != null && appArg.length() > 0) { consumed = handleUrl(appArg); - if (consumed) { - consumedArg = appArg; + if (consumed && d != null) { + d.setProperty("AppArg", null); } } if (!consumed) { @@ -625,7 +638,6 @@ private static void loadAttribution() { // the answer survives a relaunch, and nothing else can check that. static void forgetLoadedState() { undelivered = null; - consumedArg = null; lookupIssuedAt = 0; stateLoaded = false; attributionLoaded = false; @@ -872,7 +884,6 @@ public static void reset() { deferredStarted = false; lookupIssuedAt = 0; undelivered = null; - consumedArg = null; reopenedAlreadyDelivered = false; reopenedFirstLaunch = 0; reopenedExpiresAt = 0; @@ -910,7 +921,18 @@ static void onConsentChanged(boolean allowed) { // window may well have closed. beginDeferred() reopens the marker // itself, so calling it is the whole fix. int s = getState(); - if (s == STATE_PENDING || s == STATE_DECLINED) { + // STATE_DECLINED has nothing outstanding by definition -- the + // withdrawal that produced it discarded whatever was -- so only + // STATE_PENDING is gated on the in-flight check. + if (s == STATE_DECLINED || (s == STATE_PENDING && !lookupInFlight())) { + // Only when nothing is outstanding, for the reason flush() + // checks the same thing. An application may call setConsent() + // again with analytics still allowed -- to change only + // personalization or ad storage -- and restarting on that + // queued a second lookup whose answer was every bit as valid as + // the first, so the funnel event fired twice; repeated updates + // also spent the retry budget without a failure. + // // The refusal may have been recorded for a listener that had // not registered yet. It is not the answer any more, and // leaving it held meant a lookup that went on to resolve was @@ -934,6 +956,21 @@ static void onConsentChanged(boolean allowed) { // end of the window. The epoch bump additionally discards any response // already in flight. lookupEpoch++; + // Nothing is outstanding once the epoch has moved: any response still + // on the wire fails the guard. Saying so here is what lets a later + // grant resume immediately rather than waiting out a retry delay for a + // request that can no longer be acted on. + lookupIssuedAt = 0; + if (abandonReplacement()) { + // A replacement running beside an existing attribution. Withdrawing + // consent stops the replacement; it does not un-attribute the + // install, whose record is still there and makes the state resolved + // again on the next launch. Writing a DECLINED marker here told a + // registered listener "no invite" as a second, contradictory + // callback for an install it had already been told about. + clearDimensions(); + return; + } if (getState() == STATE_PENDING) { // The profile goes and the answer stays. Deleting the record left // STATE_DECLINED in memory only -- setState() has nothing to @@ -1919,6 +1956,17 @@ private static void resolve(InviteAttribution a, String confidence) { // delivered nor asked for again until the process restarted. The // pending record is deliberately left in place, so the next flush // or launch resends the lookup. + // And the attempt is given back. Leaving the counter at the cap + // meant the next flush took the attempt-cap branch and marked the + // install terminal instead of performing the retry this promises -- + // so the very last response, the one most likely to be the only one + // left, could never be stored. + Map retry = InviteStore.read(InviteStore.PENDING); + if (retry != null) { + int spent = InviteStore.getInt(retry, "attempts", 0); + retry.put("attempts", String.valueOf(spent > 0 ? spent - 1 : 0)); + InviteStore.write(InviteStore.PENDING, retry); + } Log.p("invite: the attribution could not be persisted, so the lookup stays " + "pending and will be retried", Log.WARNING); return; diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index d9f60a2fa29..88bfd39ac09 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -1217,12 +1217,38 @@ void tappingTheSameLinkAgainInALaterRunIsProcessed() { // deduplication is for. assertFalse(Invites.checkForInvite(), "one delivery was handled twice"); - // A later run: the same url arrives again from a second tap. + // And a second delivery IN THE SAME RUN -- an Android onNewIntent + // after the app is backgrounded, which is the ordinary case -- is a new + // delivery, not a repeated read. + Display.getInstance().setProperty("AppArg", url); + assertTrue(Invites.checkForInvite(), + "a second delivery in the same run was ignored"); + + // A later run behaves the same way. Invites.forgetLoadedState(); Display.getInstance().setProperty("AppArg", url); assertTrue(Invites.checkForInvite(), "a second tap on the same link was ignored"); } + @Test + @EdtTest + void anInviteArgumentIsConsumedAndAnythingElseIsLeftAlone() { + // Consuming it is what distinguishes a delivery from a read. Only an + // invite is consumed: an application routing its own deep links must + // find its argument exactly as it arrived. + Display.getInstance().setProperty("AppArg", + "https://cloud.codenameone.com/i/acme/EATEN1"); + assertTrue(Invites.checkForInvite()); + assertNull(Display.getInstance().getProperty("AppArg", null), + "the invite argument was left behind for the next read"); + + Display.getInstance().setProperty("AppArg", "https://example.com/some/other/link"); + assertFalse(Invites.checkForInvite()); + assertEquals("https://example.com/some/other/link", + Display.getInstance().getProperty("AppArg", null), + "an argument that is not an invite was consumed"); + } + @FormTest void aFailedAttributionWriteLeavesTheLookupPending() { // Everything after the write assumes the record is on disk: @@ -1239,4 +1265,74 @@ void aFailedAttributionWriteLeavesTheLookupPending() { assertNotNull(InviteStore.read(InviteStore.PENDING), "the retry information was thrown away with it"); } + + @Test + @EdtTest + void aConsentUpdateThatChangesNothingDoesNotQueueASecondLookup() { + // An application may call setConsent again with analytics still allowed + // -- to change only personalization or ad storage -- and restarting on + // that queued a second lookup whose answer was as valid as the first, + // so the funnel event fired twice and the retry budget was spent + // without a failure. + Invites.checkForInvite(); + Map pending = InviteStore.read(InviteStore.PENDING); + int attempts = InviteStore.getInt(pending, "attempts", 0); + + for (int i = 0; i < 5; i++) { + Analytics.setConsent(AnalyticsConsent.builder().analytics(true) + .personalization(i % 2 == 0).build()); + } + + Map now = InviteStore.read(InviteStore.PENDING); + assertEquals(attempts, InviteStore.getInt(now, "attempts", 0), + "consent updates queued lookups for a request that had not failed"); + } + + @Test + @EdtTest + void withdrawingConsentDuringAReplacementAbandonsIt() { + // Withdrawing consent stops the replacement; it does not un-attribute + // the install, whose record is still there and makes the state resolved + // again on the next launch. Writing a DECLINED marker told a registered + // listener "no invite" as a second, contradictory callback. + Invites.handleResolution(InviteTestSupport.resolvedJson("FIRST7", "c1", "sms"), + Invites.MATCH_DIRECT, false); + Invites.setReattribution(true); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/SECOND7"); + + final int[] told = new int[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0]++; + } + }); + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + + assertEquals(0, told[0], "a withdrawal told an attributed install it had no invite"); + assertEquals(Invites.STATE_RESOLVED, Invites.getState()); + } + + @FormTest + void aFailedWriteOnTheLastAttemptCanStillBeRetried() { + // Leaving the counter at the cap meant the next flush took the + // attempt-cap branch and marked the install terminal instead of + // performing the retry -- so the very last response, the one most + // likely to be the only one left, could never be stored. + Invites.checkForInvite(); + Map pending = InviteStore.read(InviteStore.PENDING); + pending.put("attempts", String.valueOf(Invites.MAX_ATTEMPTS)); + InviteStore.write(InviteStore.PENDING, pending); + + InviteStore.failNextWriteForTest(InviteStore.ATTRIBUTION); + Invites.handleResolution(InviteTestSupport.resolvedJson("LAST1", "c1", "sms"), + Invites.MATCH_DIRECT, false); + + Map after = InviteStore.read(InviteStore.PENDING); + assertNotNull(after); + assertTrue(InviteStore.getInt(after, "attempts", 0) < Invites.MAX_ATTEMPTS, + "the promised retry could never happen: the budget was still exhausted"); + } } From e7c14df15e1a0235cc926c902c32efdfd5a76d29 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:02:02 +0300 Subject: [PATCH 27/99] Invites: an erasure has to reach the durable records, not just the dimensions Analytics.resetClientId clears the reserved dimensions itself, which needs no provider -- but the durable attribution and the registration outbox are ours, and only InviteAttributionProvider.init drops them. clearProviders() is public and the deprecated AnalyticsService.init() calls it, so an erasure really can run with the provider absent, after which getAttribution(), conversion() and flush() could read or transmit the old referral identity under the new client id. Every entry point that reads or transmits stored data now re-registers the provider first, which re-runs that hook. Analytics deliberately does not do it for us: a reference from com.codename1.analytics to the invite package would match the platform feature catalog's prefix and put a Play dependency and an API floor on every application that logs a single event -- the DatabaseConfig scar this design was shaped around. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 19 ++++++++++++++++ .../invite/InviteConsentAndErasureTest.java | 22 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 08ccc336145..770384456f0 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -615,6 +615,7 @@ public static boolean handleUrl(String url) { /// /// the attribution public static InviteAttribution getAttribution() { + ensureProvider(); loadAttribution(); return resolved; } @@ -653,6 +654,7 @@ static void forgetLoadedState() { /// /// the current state public static int getState() { + ensureProvider(); loadState(); return state; } @@ -833,6 +835,7 @@ public static boolean isReattribution() { /// deferred match. Called for you on the paths that matter; exposed for /// an application that knows it has just regained connectivity. public static void flush() { + ensureProvider(); drainOutbox(); // A deferred lookup that failed because the first launch was offline // leaves deferredStarted set, and nothing else clears it inside the @@ -988,6 +991,22 @@ static void onConsentChanged(boolean allowed) { // Registers the provider that gives us the erasure and consent hooks. // Analytics.clearProviders() can drop it, so this re-registers on facade // entry rather than only once; the provider list is a handful of entries. + // Called by EVERY entry point that reads or transmits stored invite data, + // not only the ones that start something. + // + // Analytics.clearProviders() is public and the deprecated + // AnalyticsService.init() calls it, so this provider can be absent when an + // erasure runs. Analytics.resetClientId() clears the reserved dimensions + // itself, which needs no provider -- but the durable records are ours, and + // only this provider's init() hook drops them. Registering here re-runs + // that hook (addProvider calls init immediately), so the identity change is + // noticed before anything reads the old attribution or sends the old + // registration outbox under the new id. + // + // Analytics deliberately does not do this for us: a reference from + // com.codename1.analytics to this package would match the platform feature + // catalog's prefix and put a Play dependency and an API floor on every + // application that logs a single event. private static void ensureProvider() { try { List providers = Analytics.getProviders(); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java index dd887fc16bd..d42af34ae9c 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java @@ -360,4 +360,26 @@ void anErasureClearsTheReferralDimensionsEvenWithNoProviderRegistered() { assertEquals("pro", Analytics.getDimensions().get("plan"), "the application's own dimension must survive an erasure"); } + + @FormTest + void anErasureDropsTheDurableRecordsEvenWithNoProviderRegistered() { + // resetClientId clears the reserved dimensions itself, which needs no + // provider -- but the durable records are ours and only the provider's + // init hook drops them. Analytics.clearProviders() is public and the + // deprecated AnalyticsService.init() calls it, so an erasure really can + // run with the provider absent; every entry point that reads or + // transmits stored data re-registers first, which re-runs that hook. + InviteTestSupport.freshInstall(); + Invites.handleResolution(InviteTestSupport.resolvedJson("ERASE1", "spring", "sms"), + Invites.MATCH_DIRECT, false); + assertNotNull(Invites.getAttribution()); + + Analytics.clearProviders(); + Analytics.resetClientId(); + + assertNull(Invites.getAttribution(), + "the old referral identity survived an erasure and can be read under the new id"); + assertNull(InviteStore.read(InviteStore.ATTRIBUTION), + "the durable attribution record was left on the device"); + } } From b3667c20d6ebafd1398af847a79dfd1d1515bacf Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:18:26 +0300 Subject: [PATCH 28/99] Invites: the reopened marker is converted, not rebuilt A refusal is reopenable, so everything the marker holds has to survive the reopening -- and rebuilding the pending record from scratch lost each thing in turn, one review round at a time: the original window, then the delivered flag, now the direct-link code. A user who denied consent when the link arrived and granted it afterwards had the exact claim replaced by a referrer read or a statistical match, which can miss or credit a different click. So the marker is converted in place: state back to PENDING, reason dropped, everything else untouched. Three carry-over fields go with the rebuild they existed to compensate for, and the next thing the marker learns to hold will survive a reopening without anyone having to remember to add it. markTerminal reports whether its write landed, and no caller commits or delivers until it has. An unchecked write meant a storage failure still set the in-memory state and told the listener -- so the same lookup and the same callback repeated after every restart, or the delivery flag landed on the old pending record and left the state at PENDING, leaving a settled lookup running again with no way to deliver its answer. The refusal path in handleUrl records the code before it writes the marker, because that branch runs before the pending record exists and there would otherwise be nothing to carry. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 141 ++++++++++-------- .../invite/InviteResilienceTest.java | 44 ++++++ 2 files changed, 126 insertions(+), 59 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 770384456f0..6182243d67f 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -219,18 +219,6 @@ public final class Invites { // a later launch reaches this answer again through the ordinary path. private static String undelivered; - // Set when a terminal marker that had already been delivered is reopened, - // so the attribution the resumed lookup writes inherits that fact rather - // than announcing itself a second time. Carried onto the pending record as - // soon as one exists, which is what makes it survive the process. - private static boolean reopenedAlreadyDelivered; - - // The original clock readings a reopened terminal marker carried, so the - // resumed lookup keeps the window it started with rather than restarting it - // from the moment consent was granted. - private static long reopenedFirstLaunch; - - private static long reopenedExpiresAt; private static boolean deferredStarted; // When the last claim or match was issued. flush() restarts only once this @@ -553,12 +541,27 @@ public static boolean handleUrl(String url) { // install that had already been given one. return true; } + // The code is recorded on the way to the marker, which carries it + // across the refusal. This branch runs BEFORE the pending record is + // written, so without this there is nothing for markTerminal to + // carry, and a user who grants consent afterwards has the exact + // claim replaced by a referrer read or a statistical match. + Map denied = InviteStore.read(InviteStore.PENDING); + if (denied == null) { + denied = new LinkedHashMap(); + } + denied.put("code", code); + denied.put("codeSource", "universal_link"); + denied.put("codeMatch", MATCH_DIRECT); + denied.put("codeDeferred", "false"); + InviteStore.write(InviteStore.PENDING, denied); // Told, not silently dropped. checkForInvite() records the url as // consumed and skips the deferred path after this, so this is the // only chance the listener gets for this install -- and a // registered one heard nothing at all. - markTerminal(STATE_DECLINED, REASON_CONSENT_DENIED); - notifyUnavailable(REASON_CONSENT_DENIED); + if (markTerminal(STATE_DECLINED, REASON_CONSENT_DENIED)) { + notifyUnavailable(REASON_CONSENT_DENIED); + } return true; } if (getState() == STATE_RESOLVED && !reattribution) { @@ -887,9 +890,6 @@ public static void reset() { deferredStarted = false; lookupIssuedAt = 0; undelivered = null; - reopenedAlreadyDelivered = false; - reopenedFirstLaunch = 0; - reopenedExpiresAt = 0; unacknowledged.clear(); } @@ -980,8 +980,9 @@ static void onConsentChanged(boolean allowed) { // rewrite once the record is gone -- so the next launch read // STATE_NONE and told the listener again. The marker carries the // reason, which is what lets a later grant reopen it. - markTerminal(STATE_DECLINED, REASON_CONSENT_DENIED); - notifyUnavailable(REASON_CONSENT_DENIED); + if (markTerminal(STATE_DECLINED, REASON_CONSENT_DENIED)) { + notifyUnavailable(REASON_CONSENT_DENIED); + } } clearDimensions(); } @@ -1261,8 +1262,8 @@ private static boolean hasSavedCode() { return code != null && code.length() > 0; } - private static void markTerminal() { - markTerminal(null); + private static boolean markTerminal() { + return markTerminal(null); } // reason is recorded only when the answer could stop being true. A window @@ -1270,11 +1271,13 @@ private static void markTerminal() { // ships a non-zero window is asking for attribution again -- so that one // marker is reopened rather than being permanent, which is why it is the // only one that carries a reason. - private static void markTerminal(String reason) { - markTerminal(STATE_NONE_FOUND, reason); + private static boolean markTerminal(String reason) { + return markTerminal(STATE_NONE_FOUND, reason); } - private static void markTerminal(int terminalState, String reason) { + // Returns false when the marker could not be persisted, in which case + // NOTHING is committed and the caller must not report the outcome. + private static boolean markTerminal(int terminalState, String reason) { Map done = new LinkedHashMap(); done.put("state", String.valueOf(terminalState)); // The timing is carried, and only the timing. firstLaunch and expiresAt @@ -1304,9 +1307,31 @@ private static void markTerminal(int terminalState, String reason) { // got neither callback for the life of the install. done.put("reason", reason); } - InviteStore.write(InviteStore.PENDING, done); + // And the direct-link details, when there are any. + // + // A refusal is reopenable, so the code has to survive it: discarding it + // meant a user who denied consent when the link arrived and granted it + // afterwards had the exact claim replaced by a referrer read or a + // statistical match, which can miss or credit a different click. Four + // short fields, and none of them describes the device. + for (String key : new String[] {"code", "codeSource", "codeMatch", "codeDeferred", + "codeReferrer"}) { + InviteStore.put(done, key, InviteStore.get(before, key, null)); + } + if (!InviteStore.write(InviteStore.PENDING, done)) { + // Nothing is committed. Reporting a terminal outcome the device + // cannot remember meant the same lookup and the same callback + // repeated after every restart -- or, worse, the delivery flag + // landed on the OLD pending record and left the state at PENDING, + // so a supposedly settled lookup ran again and could never deliver + // its answer. + Log.p("invite: a terminal answer could not be persisted; it will be reached " + + "again rather than reported now", Log.WARNING); + return false; + } state = terminalState; stateLoaded = true; + return true; } // The terminal answer this install reached, if it was never delivered. @@ -1341,20 +1366,10 @@ private static Map pendingRecord() { } pending = new LinkedHashMap(); long now = System.currentTimeMillis(); - // Restored from the marker a reopen carried them on, when there is one, - // so granting consent late does not restart the attribution window. - pending.put("firstLaunch", String.valueOf(reopenedFirstLaunch > 0 - ? reopenedFirstLaunch : now)); - pending.put("expiresAt", String.valueOf(reopenedExpiresAt > 0 - ? reopenedExpiresAt : now + attributionWindow)); - reopenedFirstLaunch = 0; - reopenedExpiresAt = 0; + pending.put("firstLaunch", String.valueOf(now)); + pending.put("expiresAt", String.valueOf(now + attributionWindow)); pending.put("attempts", "0"); pending.put("state", String.valueOf(STATE_PENDING)); - if (reopenedAlreadyDelivered) { - pending.put("delivered", "true"); - reopenedAlreadyDelivered = false; - } Display d = Display.getInstance(); if (d != null) { InviteStore.put(pending, "platform", d.getPlatformName()); @@ -1387,18 +1402,21 @@ private static void beginDeferred() { boolean reopen = (REASON_UNSUPPORTED.equals(why) && attributionWindow != 0) || (REASON_CONSENT_DENIED.equals(why) && !explicitlyDenied()); if (reopen) { - // The listener may already have been told about this install, - // and the marker is where that fact lives. Deleting it lost it, - // so the resumed lookup's attribution was written as - // undelivered and inviteReceived() arrived as a second callback - // on the next launch. It rides the pending record instead. - reopenedAlreadyDelivered = - InviteStore.getBoolean(marker, "delivered", false); - reopenedFirstLaunch = InviteStore.getLong(marker, "firstLaunch", 0); - reopenedExpiresAt = InviteStore.getLong(marker, "expiresAt", 0); - InviteStore.delete(InviteStore.PENDING); - state = STATE_NONE; - s = STATE_NONE; + // The marker is CONVERTED, not deleted and rebuilt. + // + // Everything it carries has to survive the reopening: the + // original window, so a late grant does not start a fresh one; + // the delivered flag, so the listener is not told twice; and + // the direct-link code, so an exact answer is not replaced by a + // guess. Rebuilding from scratch lost each of those in turn, + // one review round at a time, which is what this shape exists + // to stop happening again. + marker.put("state", String.valueOf(STATE_PENDING)); + marker.remove("reason"); + InviteStore.write(InviteStore.PENDING, marker); + state = STATE_PENDING; + stateLoaded = true; + s = STATE_PENDING; } } if (s == STATE_RESOLVED || s == STATE_NONE_FOUND || s == STATE_DECLINED) { @@ -1415,8 +1433,9 @@ private static void beginDeferred() { // fresh install none does -- so this answer was purely in memory // and the listener heard it again on every launch, breaking the // documented once-per-install contract. - markTerminal(REASON_UNSUPPORTED); - notifyUnavailable(REASON_UNSUPPORTED); + if (markTerminal(REASON_UNSUPPORTED)) { + notifyUnavailable(REASON_UNSUPPORTED); + } return; } // Checked BEFORE the profile is created, not after. pendingRecord() @@ -1430,8 +1449,9 @@ private static void beginDeferred() { // Durable, and profile free: markTerminal replaces the record with // the state and the reason and nothing else. The reason is what // lets beginDeferred reopen this if consent is later granted. - markTerminal(STATE_DECLINED, REASON_CONSENT_DENIED); - notifyUnavailable(REASON_CONSENT_DENIED); + if (markTerminal(STATE_DECLINED, REASON_CONSENT_DENIED)) { + notifyUnavailable(REASON_CONSENT_DENIED); + } return; } Map pending = pendingRecord(); @@ -1446,16 +1466,18 @@ private static void beginDeferred() { if (abandonReplacement()) { return; } - markTerminal(REASON_EXPIRED); - notifyUnavailable(REASON_EXPIRED); + if (markTerminal(REASON_EXPIRED)) { + notifyUnavailable(REASON_EXPIRED); + } return; } if (InviteStore.getInt(pending, "attempts", 0) >= MAX_ATTEMPTS) { if (abandonReplacement()) { return; } - markTerminal(); - notifyUnavailable(REASON_NO_MATCH); + if (markTerminal()) { + notifyUnavailable(REASON_NO_MATCH); + } return; } setState(STATE_PENDING); @@ -1883,8 +1905,9 @@ static void handleResolution(String payload, String matchType, boolean deferred, // not enough: loadState() reads an absent record as STATE_NONE, // so the next launch built a fresh profile and asked again, and // an ordinary uninvited install re-queried the server for ever. - markTerminal(); - notifyUnavailable(REASON_NO_MATCH); + if (markTerminal()) { + notifyUnavailable(REASON_NO_MATCH); + } return; } String code = str(json.get("code")); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 88bfd39ac09..7eb379e4f82 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -1335,4 +1335,48 @@ void aFailedWriteOnTheLastAttemptCanStillBeRetried() { assertTrue(InviteStore.getInt(after, "attempts", 0) < Invites.MAX_ATTEMPTS, "the promised retry could never happen: the budget was still exhausted"); } + + @Test + @EdtTest + void aDeniedDirectLinkKeepsItsCodeForTheReopening() { + // A refusal is reopenable, so the code has to survive it. Discarding it + // meant a user who denied consent when the link arrived and granted it + // afterwards had the exact claim replaced by a referrer read or a + // statistical match, which can miss or credit a different click. + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/DENIED1"); + assertEquals(Invites.STATE_DECLINED, Invites.getState()); + + Analytics.setConsent(AnalyticsConsent.granted()); + + Map resumed = InviteStore.read(InviteStore.PENDING); + assertNotNull(resumed); + assertEquals("DENIED1", InviteStore.get(resumed, "code", null), + "the reopened lookup lost the exact code and fell back to a guess"); + } + + @FormTest + void aTerminalAnswerThatCannotBePersistedIsNotReported() { + // Reporting an outcome the device cannot remember meant the same lookup + // and the same callback repeated after every restart -- or, worse, the + // delivery flag landed on the OLD pending record and left the state at + // PENDING, so a settled lookup ran again and could never deliver. + Invites.setAttributionWindow(0); + final int[] told = new int[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0]++; + } + }); + InviteStore.failNextWriteForTest(InviteStore.PENDING); + Invites.checkForInvite(); + + assertEquals(0, told[0], + "an answer the device cannot remember was reported to the listener"); + assertTrue(Invites.getState() != Invites.STATE_NONE_FOUND, + "the state was committed without its marker"); + } } From abb2a1b8fe6641745dfa5f2c9b680bfc29172f8f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:31:42 +0300 Subject: [PATCH 29/99] Invites: three more places a write's result was assumed A first-time denial has no prior pending record -- that is the ordinary shape of a first launch by someone who had already refused -- so the marker copied an absent clock and carried expiresAt 0, which beginDeferred reads as "no window". An arbitrarily old install could then still run a fingerprint match after a later grant. The marker starts its own clock when there is nothing to copy. The delivery flag was written and not checked, on both sides. deliveredThisRun suppresses duplicates only until the process exits, so telling the listener about a delivery the device cannot remember means telling it again on the next launch -- against the exactly-once contract. Better late, on a launch where the flag can be written, than twice. The attempt refund's own write was unchecked, and it fails for exactly the reason the attribution write did. Nothing here can repair that, so it says so rather than leaving the promised retry to be assumed. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 54 +++++++++++++++---- .../invite/InviteResilienceTest.java | 48 +++++++++++++++++ 2 files changed, 93 insertions(+), 9 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 6182243d67f..1bdc1edd41a 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1286,9 +1286,18 @@ private static boolean markTerminal(int terminalState, String reason) { // moment consent was granted. A user who answers the prompt a week // later would then have run a fresh fingerprint lookup and reported // invite_install for somebody else's click. + // + // Started here when there is no prior record, which is the ordinary + // shape of a first launch by someone who had already refused: nothing + // has run yet, so nothing wrote one. Copying nulls left the reopened + // marker with expiresAt 0, and beginDeferred reads that as "no window", + // so an arbitrarily old install could still run a fingerprint match. Map before = InviteStore.read(InviteStore.PENDING); - InviteStore.put(done, "firstLaunch", InviteStore.get(before, "firstLaunch", null)); - InviteStore.put(done, "expiresAt", InviteStore.get(before, "expiresAt", null)); + long markedAt = System.currentTimeMillis(); + done.put("firstLaunch", InviteStore.get(before, "firstLaunch", + String.valueOf(markedAt))); + done.put("expiresAt", InviteStore.get(before, "expiresAt", + String.valueOf(markedAt + attributionWindow))); // And the delivery state, for the same reason the resolved record // inherits it: a reopened lookup that ends terminally has still been // answered once, and dropping the flag here delivered a second @@ -1350,13 +1359,19 @@ private static String undeliveredFromMarker() { return InviteStore.get(marker, "reason", REASON_NO_MATCH); } - private static void markUnavailableDelivered() { + // Returns false when the delivery could not be recorded. Same reasoning as + // the resolved side: deliveredThisRun only suppresses duplicates until the + // process exits, so telling the listener about a delivery the device cannot + // remember means telling it again on the next launch. + private static boolean markUnavailableDelivered() { Map marker = InviteStore.read(InviteStore.PENDING); if (marker == null) { - return; + // Nothing durable to mark. The answer is still terminal in memory + // and the run's own guard prevents a repeat within it. + return true; } marker.put("delivered", "true"); - InviteStore.write(InviteStore.PENDING, marker); + return InviteStore.write(InviteStore.PENDING, marker); } private static Map pendingRecord() { @@ -2007,7 +2022,15 @@ private static void resolve(InviteAttribution a, String confidence) { if (retry != null) { int spent = InviteStore.getInt(retry, "attempts", 0); retry.put("attempts", String.valueOf(spent > 0 ? spent - 1 : 0)); - InviteStore.write(InviteStore.PENDING, retry); + if (!InviteStore.write(InviteStore.PENDING, retry)) { + // The refund failed for the same reason the attribution did + // -- the store is unwritable -- so the durable count is + // still at the cap and the next flush would settle the + // install rather than retry. Nothing here can fix that, so + // it is said out loud instead of being assumed away. + Log.p("invite: the attempt could not be refunded, so a later retry may " + + "settle this install instead of asking again", Log.WARNING); + } } Log.p("invite: the attribution could not be persisted, so the lookup stays " + "pending and will be retried", Log.WARNING); @@ -2126,9 +2149,18 @@ private static void deliverPending() { if (a == null) { return; } - deliveredThisRun = true; r.put("delivered", "true"); - InviteStore.write(InviteStore.ATTRIBUTION, r); + if (!InviteStore.write(InviteStore.ATTRIBUTION, r)) { + // deliveredThisRun only suppresses duplicates until the process + // exits, so calling the listener on a delivery the device cannot + // remember means inviteReceived() fires again on the next launch -- + // against the exactly-once contract. Better to deliver late, on a + // launch where the flag can be written, than twice. + Log.p("invite: the delivery could not be recorded, so the attribution will be " + + "delivered on a later launch instead of twice", Log.WARNING); + return; + } + deliveredThisRun = true; try { listener.inviteReceived(a); } catch (Throwable t) { @@ -2153,8 +2185,12 @@ private static void notifyUnavailable(String reason) { undelivered = reason; return; } + if (!markUnavailableDelivered()) { + Log.p("invite: the delivery could not be recorded, so this answer will be " + + "reported on a later launch instead of twice", Log.WARNING); + return; + } deliveredThisRun = true; - markUnavailableDelivered(); try { target.attributionUnavailable(reason); } catch (Throwable t) { diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 7eb379e4f82..edeb4f17674 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -1379,4 +1379,52 @@ public void attributionUnavailable(String reason) { assertTrue(Invites.getState() != Invites.STATE_NONE_FOUND, "the state was committed without its marker"); } + + @Test + @EdtTest + void aFirstTimeDenialStartsItsOwnClock() { + // Someone who had already refused reaches this on a first launch, when + // nothing has written a pending record yet. Copying the absent clock + // left expiresAt at 0, which beginDeferred reads as "no window", so an + // arbitrarily old install could still run a fingerprint match after a + // later grant. + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + Invites.checkForInvite(); + assertEquals(Invites.STATE_DECLINED, Invites.getState()); + + Map marker = InviteStore.read(InviteStore.PENDING); + assertNotNull(marker); + assertTrue(InviteStore.getLong(marker, "expiresAt", 0) > System.currentTimeMillis(), + "the denial marker carries no window, so a reopening would have none"); + assertTrue(InviteStore.getLong(marker, "firstLaunch", 0) > 0); + } + + @FormTest + void aDeliveryThatCannotBeRecordedIsNotMadeTwice() { + // deliveredThisRun suppresses duplicates only until the process exits, + // so calling the listener on a delivery the device cannot remember + // means inviteReceived() fires again on the next launch. + Invites.handleResolution(InviteTestSupport.resolvedJson("ONCE1", "c1", "sms"), + Invites.MATCH_DIRECT, false); + + final int[] received = new int[1]; + InviteListener l = new InviteListener() { + public void inviteReceived(InviteAttribution a) { + received[0]++; + } + + public void attributionUnavailable(String reason) { + } + }; + InviteStore.failNextWriteForTest(InviteStore.ATTRIBUTION); + Invites.setInviteListener(l); + assertEquals(0, received[0], + "the listener was told about a delivery the device cannot remember"); + + // A later launch, with storage working, delivers it exactly once. + Invites.forgetLoadedState(); + Invites.setInviteListener(null); + Invites.setInviteListener(l); + assertEquals(1, received[0], "the attribution was never delivered at all"); + } } From 883f9aa9fcf419fb7b2adf9b1aabf84f876263ce Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:24:58 +0300 Subject: [PATCH 30/99] Invites: reopen with a usable window and a real device profile Two ways a reopened deferred lookup came back unable to answer, plus the Android delivery that never reached the invite code at all. A terminal marker deliberately carries no device profile -- a refusal deletes the fingerprint, which is the promise the consent path makes -- so converting one back to pending left the resumed match sending empty strings and zero screen dimensions. The server then had the network and the country to score on, which is below the threshold: a consent grant inside the original window could not recover the invite it was granted for. The profile is now CAPTURED AGAIN on reopening rather than carried through the refusal, which keeps both promises. The kill-switch reopening had a second problem the first could hide. A marker written while setAttributionWindow(0) was in force recorded expiresAt = firstLaunch + 0, a window already over at the instant it was created; reopening kept it and the expiry check settled the lookup again on the same pass. Shipping a non-zero window later -- the documented way to ask again -- could therefore never work. firstLaunch is a fact about the install and stays; the window is a policy and the current one now applies. The consent reopening is untouched: its marker was written under a real window and recomputing there would change a right answer. reenablingTheWindowReopensThatOneTerminalMarker was already asserting this and passed anyway, because whether the state assertion catches it depends on which sibling test ran first -- it fails on its own on master. It now asserts the expiry on the record. On Android, an App Link that arrives while the activity is resumed never reaches the application's start(): the generated lifecycle returns early when wasStopped is false, and the next onStop() clears the app arg the port just stored. The invite was lost with nothing to show for it -- no claim, no invite_opened. The stub now overrides onNewIntent and consumes it on the EDT. Generated rather than done in the port, because AndroidImplementation referencing com.codename1.analytics.invite would make PlatformFeatureCatalog match the prefix for every application and put a Play Install Referrer dependency and an API 21 floor on apps that never heard of invites -- the DatabaseConfig bug, already fixed once. AndroidInviteNewIntentTest pins that gating along with the override. --- .../codename1/analytics/invite/Invites.java | 66 +++++++++-- .../builders/AndroidGradleBuilder.java | 29 +++++ .../builders/AndroidInviteNewIntentTest.java | 106 ++++++++++++++++++ .../invite/InviteResilienceTest.java | 47 ++++++++ 4 files changed, 240 insertions(+), 8 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 1bdc1edd41a..8e9dc79a4a2 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1385,19 +1385,35 @@ private static Map pendingRecord() { pending.put("expiresAt", String.valueOf(now + attributionWindow)); pending.put("attempts", "0"); pending.put("state", String.valueOf(STATE_PENDING)); + captureProfile(pending); + InviteStore.write(InviteStore.PENDING, pending); + return pending; + } + + /// Writes the coarse device profile the deferred lookup is matched on. + /// + /// Separate from `pendingRecord()` because it is needed twice. A terminal + /// marker deliberately carries none of it -- a refused profile is deleted, + /// which is the promise the consent path makes -- so a marker that is + /// later reopened has to capture it again rather than restore it. Sending + /// the empty strings and zero dimensions the terminal marker really does + /// hold left the server with the network and the country and nothing else, + /// which scores below the threshold: a consent grant inside the original + /// window could not recover the invite it was granted for. + /// + /// - `record`: the pending record to fill in + private static void captureProfile(Map record) { Display d = Display.getInstance(); if (d != null) { - InviteStore.put(pending, "platform", d.getPlatformName()); - InviteStore.put(pending, "osVersion", d.getProperty("OSVer", "")); - InviteStore.put(pending, "deviceModel", + InviteStore.put(record, "platform", d.getPlatformName()); + InviteStore.put(record, "osVersion", d.getProperty("OSVer", "")); + InviteStore.put(record, "deviceModel", d.getProperty("DeviceHardwareModel", d.getProperty("DeviceName", ""))); - pending.put("screenWidth", String.valueOf(d.getDisplayWidth())); - pending.put("screenHeight", String.valueOf(d.getDisplayHeight())); + record.put("screenWidth", String.valueOf(d.getDisplayWidth())); + record.put("screenHeight", String.valueOf(d.getDisplayHeight())); } Locale loc = Locale.getDefault(); - InviteStore.put(pending, "locale", loc == null ? "" : loc.toString()); - InviteStore.write(InviteStore.PENDING, pending); - return pending; + InviteStore.put(record, "locale", loc == null ? "" : loc.toString()); } private static void beginDeferred() { @@ -1427,7 +1443,41 @@ private static void beginDeferred() { // one review round at a time, which is what this shape exists // to stop happening again. marker.put("state", String.valueOf(STATE_PENDING)); + if (REASON_UNSUPPORTED.equals(why)) { + // The window is recomputed for THIS reopening, and only + // this one. + // + // A marker written while the kill switch was on recorded + // expiresAt = firstLaunch + 0, so its window was already + // over at the instant it was created. Reopening it kept + // that zero-length window, the expiry check below settled + // the lookup again as "expired" on the same pass, and + // shipping a non-zero window later -- the documented way to + // ask again -- could therefore never work. + // + // firstLaunch is a fact about this install and stays; the + // window is a policy the application sets and the current + // one applies. The consent reopening is left alone: its + // marker was written under a real window, and recomputing + // there would change a value that is already right. + long began = InviteStore.getLong(marker, "firstLaunch", + System.currentTimeMillis()); + marker.put("expiresAt", String.valueOf(began + attributionWindow)); + } marker.remove("reason"); + // And the device profile is CAPTURED AGAIN, not restored. + // + // markTerminal() carries the timing, the delivery flag and the + // direct-link code and nothing that describes the device -- + // deliberately, because a refusal deletes the fingerprint. So + // the marker being converted here holds none of it, and the + // resumed requestMatch() sent empty strings and zero screen + // dimensions: the server had the network and the country to + // score on, which is not enough to match, so granting consent + // inside the original window recovered nothing. Recapturing + // costs five property reads and is the same profile the first + // launch would have taken. + captureProfile(marker); InviteStore.write(InviteStore.PENDING, marker); state = STATE_PENDING; stateLoaded = true; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 62361656ac8..6baa2a3fd64 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -6005,6 +6005,34 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { } } + // An App Link that arrives while the activity is already resumed never + // reaches the application's start(). The lifecycle generated below + // returns early when wasStopped is false -- it just re-shows the + // current form -- so the documented checkForInvite() call in start() + // does not run, and the app arg the port stored a moment ago is cleared + // again by the next onStop(). The invite is silently lost: no claim, no + // invite_opened, for a delivery that worked perfectly. + // + // Generated rather than done in the port, because AndroidImplementation + // referencing the invite package would make PlatformFeatureCatalog + // match it for EVERY application -- a Play Install Referrer dependency + // and an API 21 floor on apps that never heard of invites. This splice + // lands only in an app whose classes actually use them, which is the + // same condition the registration above rides on. + String inviteNewIntent = ""; + if (usesInvites) { + inviteNewIntent = " protected void onNewIntent(android.content.Intent intent) {\n" + + " super.onNewIntent(intent);\n" + + " if(!Display.isInitialized()) {\n" + + " return;\n" + + " }\n" + + " Display.getInstance().callSerially(new Runnable() {\n" + + " public void run() {\n" + + " com.codename1.analytics.invite.Invites.checkForInvite();\n" + + " }\n" + + " });\n" + + " }\n\n"; + } String inviteRegisterInstall = ""; if (usesInvites) { inviteRegisterInstall = " com.codename1.analytics.invite.Invites" @@ -6379,6 +6407,7 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { + " currentForm = null;\n" + " }\n" + " }\n\n" + + inviteNewIntent + " protected void onPause() {\n" + " super.onPause();\n" + " synchronized(LOCK) {\n" diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java new file mode 100644 index 00000000000..43d0db75ecf --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java @@ -0,0 +1,106 @@ +/* + * 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.builders; + +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * An App Link delivered to a resumed activity has to reach the invite code. + * + *

{@code onNewIntent} stores the url in {@code AppArg} and the port stops + * there. The generated lifecycle returns early when {@code wasStopped} is false + * -- it re-shows the current form and nothing else -- so the documented + * {@code checkForInvite()} call in the application's {@code start()} never + * runs, and the next {@code onStop()} clears the property again. The delivery + * worked and the invite was silently lost: no claim, no {@code invite_opened}. + * The stub therefore overrides {@code onNewIntent} and consumes it.

+ * + *

Generated rather than done in the port, and that is the load-bearing part: + * {@code AndroidImplementation} referencing {@code com.codename1.analytics.invite} + * would make {@code PlatformFeatureCatalog} match the prefix for EVERY + * application, putting a Play Install Referrer dependency and an API 21 floor + * on apps that never heard of invites. That is the {@code DatabaseConfig} bug, + * already fixed once. So the reference may only exist inside code emitted for + * an app whose own classes use invites.

+ * + *

Asserted against the builder's source text, as {@code StubLifecycleCastTest} + * does: the stub is assembled inline across a few hundred lines with no seam to + * call, and what has to stay true is a property of the assembly.

+ */ +public class AndroidInviteNewIntentTest { + + private static final String BUILDER = + "src/main/java/com/codename1/builders/AndroidGradleBuilder.java"; + + private String source() throws IOException { + File builder = new File(BUILDER); + assertTrue(builder.isFile(), "the builder must be readable: " + builder.getAbsolutePath()); + return new String(Files.readAllBytes(builder.toPath()), StandardCharsets.UTF_8); + } + + @Test + void theStubOverridesOnNewIntentAndConsumesTheInvite() throws IOException { + String source = source(); + int declared = source.indexOf("String inviteNewIntent = \"\";"); + assertTrue(declared > 0, "the onNewIntent splice is gone, so a resumed App Link is lost"); + assertTrue(source.contains("protected void onNewIntent(android.content.Intent intent)"), + "the generated stub no longer overrides onNewIntent"); + assertTrue(source.contains("com.codename1.analytics.invite.Invites.checkForInvite();"), + "the generated onNewIntent no longer consumes the invite"); + assertTrue(source.contains("+ inviteNewIntent"), + "the splice is built and never emitted into the stub"); + } + + @Test + void theOverrideRunsOnTheEventDispatchThread() throws IOException { + String source = source(); + int splice = source.indexOf("String inviteNewIntent = \"\";"); + int end = source.indexOf("String inviteRegisterInstall", splice); + assertTrue(splice > 0 && end > splice, "the splice block moved"); + String block = source.substring(splice, end); + // onNewIntent runs on Android's UI thread, not the Codename One EDT. + assertTrue(block.contains("Display.getInstance().callSerially("), + "the generated onNewIntent touches invite state off the EDT"); + assertTrue(block.contains("if(!Display.isInitialized())"), + "the generated onNewIntent can run before Display exists"); + } + + @Test + void theInviteReferenceOnlyExistsForAppsThatUseInvites() throws IOException { + String source = source(); + int splice = source.indexOf("String inviteNewIntent = \"\";"); + int gate = source.indexOf("if (usesInvites) {", splice); + int body = source.indexOf("com.codename1.analytics.invite.Invites.checkForInvite();", + splice); + assertTrue(gate > splice && gate < body, + "the onNewIntent splice is emitted for every app, which puts a Play Install " + + "Referrer dependency and an API 21 floor on all of them"); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index edeb4f17674..75aaddbb952 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -229,6 +229,18 @@ void reenablingTheWindowReopensThatOneTerminalMarker() { Invites.checkForInvite(); assertEquals(Invites.STATE_PENDING, Invites.getState(), "a re-enabled window did not reopen the lookup"); + // And the window it was reopened with is a real one. The marker was + // written while the kill switch was on, so it recorded + // expiresAt = firstLaunch + 0 -- a window already over at the instant + // it was created. Reopening kept it, the expiry check settled the + // lookup again on the same pass, and shipping a non-zero window later + // could never work. Asserted on the record rather than only through the + // state, because whether the state assertion above catches it depends + // on which other test in this class ran first. + Map reopened = InviteStore.read(InviteStore.PENDING); + assertNotNull(reopened); + assertTrue(InviteStore.getLong(reopened, "expiresAt", 0) > System.currentTimeMillis(), + "the reopened lookup carries the kill switch's zero-length window"); } @Test @@ -1036,6 +1048,41 @@ void reopeningAfterConsentKeepsTheOriginalWindow() { "granting consent restarted the attribution window"); } + @Test + @EdtTest + void reopeningAfterConsentCapturesTheDeviceProfileAgain() { + // The other half of the same reopen. markTerminal() carries the timing, + // the delivery flag and the direct-link code and nothing that describes + // the device -- deliberately, because a refusal deletes the + // fingerprint. So the marker converted back to pending held empty + // strings and zero screen dimensions, and the resumed match sent the + // server the network and the country to score on and nothing else, + // which is below the threshold. Granting consent inside the original + // window could not recover the invite it was granted for. + Invites.checkForInvite(); + Map first = InviteStore.read(InviteStore.PENDING); + assertNotNull(first); + String platform = InviteStore.get(first, "platform", ""); + assertTrue(platform.length() > 0, "the first launch captured no platform"); + + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + Map denied = InviteStore.read(InviteStore.PENDING); + assertNotNull(denied); + assertEquals("", InviteStore.get(denied, "platform", ""), + "the refused marker kept a device profile it promised to delete"); + + Analytics.setConsent(AnalyticsConsent.granted()); + + Map resumed = InviteStore.read(InviteStore.PENDING); + assertNotNull(resumed); + assertEquals(platform, InviteStore.get(resumed, "platform", ""), + "the reopened lookup carries no platform, so it cannot match"); + assertTrue(InviteStore.getLong(resumed, "screenWidth", 0) > 0, + "the reopened lookup carries no screen dimensions"); + assertTrue(InviteStore.get(resumed, "locale", "").length() > 0, + "the reopened lookup carries no locale"); + } + @Test @EdtTest void theZeroWindowDoesNotDiscardAnExactCodeWeAreHolding() { From 242bad2106e58139bbbe3e37ffa442b13db1c13b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:59:18 +0300 Subject: [PATCH 31/99] Invites: a failed pending write no longer loses what it was writing Storage can fail, and every caller here had already changed the in-memory state by the time it did. handleUrl() was the worst case: it committed STATE_PENDING and issued the claim before knowing the record reached the disk, so when the write failed and the claim failed too, the exact code from a direct link existed nowhere. The retry then read the stale record underneath it -- which has no code -- and answered with the install referrer or a fingerprint instead: a guess, or nothing, for a question the device had an exact answer to. Every read and write of the pending record now goes through readPending() and writePending(). A failed write keeps its record in memory and the next read prefers it -- it is always the newer of the two, because it exists only between a write that failed and the next one that succeeds -- and retries persisting it there, which is the next time anything wanted the record anyway. Successful writes and every delete clear it, so it can never shadow the disk. It does not survive the process, and cannot; that is what the durable record is for. A transient failure is over within one launch far more often than not. Separately, create() failed to mark the code unacknowledged on the branch where the outbox write failed AND consent forbade sending. isRegistered() reads absence from both the outbox and that set as acknowledgement, so the one invite the server is guaranteed never to have seen was the one reported as registered -- and an application that waits for it before sharing hands out a link with no campaign, channel or preview behind it. Both tests were checked by reverting their fix. The pending-write one arms the failure AFTER the record exists, so the write that fails is the one adding the code rather than the one creating the record: that is the case a stale disk record can shadow, and the one the earlier draft of this test missed entirely by passing without the fix. --- .../codename1/analytics/invite/Invites.java | 145 ++++++++++++++---- .../invite/InviteResilienceTest.java | 56 +++++++ 2 files changed, 175 insertions(+), 26 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 8e9dc79a4a2..9965437685e 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -221,6 +221,24 @@ public final class Invites { private static boolean deferredStarted; + /// The pending record that could not be written, held until it can be. + /// + /// `Storage` can fail -- a full disk, a revoked sandbox -- and every + /// caller here had already changed the in-memory state by the time it did. + /// A direct link was the worst case: `handleUrl` committed STATE_PENDING + /// and issued the claim, and if that request also failed, the exact code + /// existed nowhere. The retry then read a record with no code in it and + /// fell back to the install referrer or the fingerprint -- answering a + /// question the device already had an exact answer to, with a guess or not + /// at all. + /// + /// Held only while the durable copy is missing: a successful write clears + /// it, so this can never disagree with what is on the disk. It does not + /// survive the process, and cannot -- that is what the durable record is + /// for -- but a transient failure is over within one launch far more often + /// than not. + private static Map pendingFallback; + // When the last claim or match was issued. flush() restarts only once this // has aged out: retrying a request that is still outstanding spends an // attempt without a failure having been observed, and the attempt budget is @@ -303,6 +321,14 @@ public static Invite create(InviteRequest request) { unacknowledged.add(invite.getCode()); postRegistration(pendingRegistration); } else { + // Marked unacknowledged, exactly as the branch above does. + // + // isRegistered() reads absence from BOTH the outbox and this + // set as acknowledgement, and neither holds this code: the + // outbox write is what failed, and nothing was sent. So the + // one invite the server is guaranteed never to have seen was + // the one reported as registered. + unacknowledged.add(invite.getCode()); // Nothing leaves the device without consent, and that outranks // saving the registration. drainOutbox() carries the same guard; // this path had none, so a storage failure was the one way an @@ -546,7 +572,7 @@ public static boolean handleUrl(String url) { // written, so without this there is nothing for markTerminal to // carry, and a user who grants consent afterwards has the exact // claim replaced by a referrer read or a statistical match. - Map denied = InviteStore.read(InviteStore.PENDING); + Map denied = readPending(); if (denied == null) { denied = new LinkedHashMap(); } @@ -554,7 +580,7 @@ public static boolean handleUrl(String url) { denied.put("codeSource", "universal_link"); denied.put("codeMatch", MATCH_DIRECT); denied.put("codeDeferred", "false"); - InviteStore.write(InviteStore.PENDING, denied); + writePending(denied); // Told, not silently dropped. checkForInvite() records the url as // consumed and skips the deferred path after this, so this is the // only chance the listener gets for this install -- and a @@ -599,7 +625,7 @@ public static boolean handleUrl(String url) { // are holding the code for, so a referrer read is no longer a better // answer waiting to happen. pending.remove("referrerRetry"); - InviteStore.write(InviteStore.PENDING, pending); + writePending(pending); setState(STATE_PENDING); // A deferred fingerprint or referrer lookup may already be on the wire, // and this direct claim supersedes it. Without the bump both answers @@ -649,6 +675,11 @@ static void forgetLoadedState() { state = STATE_NONE; deferredStarted = false; deliveredThisRun = false; + // The in-memory pending copy goes with the rest of the loaded state. + // Keeping it made "forget what you loaded" leave behind the one record + // that had never reached the disk, so a test -- or an application + // deliberately re-reading -- saw a record no launch could ever see. + pendingFallback = null; } /// Where attribution has got to: one of the `STATE_` constants. @@ -671,7 +702,7 @@ private static void loadState() { return; } stateLoaded = true; - Map pending = InviteStore.read(InviteStore.PENDING); + Map pending = readPending(); // Reduced to a value first. The obvious spelling -- null-check the // record inside the condition, then assign the static below it -- is // the shape PMD reads as an unsynchronized lazy singleton, and the @@ -876,6 +907,7 @@ public static void flush() { public static void reset() { lookupEpoch++; InviteStore.delete(InviteStore.PENDING); + forgetPendingFallback(); InviteStore.delete(InviteStore.ATTRIBUTION); InviteStore.delete(InviteStore.OUTBOX); Preferences.delete(PREF_CONSUMED_ARG); @@ -1224,10 +1256,10 @@ private static void putIfSet(Map p, String key, String value) { private static void setState(int s) { state = s; stateLoaded = true; - Map pending = InviteStore.read(InviteStore.PENDING); + Map pending = readPending(); if (pending != null) { pending.put("state", String.valueOf(s)); - InviteStore.write(InviteStore.PENDING, pending); + writePending(pending); } } @@ -1249,6 +1281,7 @@ private static boolean abandonReplacement() { return false; } InviteStore.delete(InviteStore.PENDING); + forgetPendingFallback(); state = STATE_RESOLVED; stateLoaded = true; deferredStarted = false; @@ -1257,7 +1290,7 @@ private static boolean abandonReplacement() { } private static boolean hasSavedCode() { - Map pending = InviteStore.read(InviteStore.PENDING); + Map pending = readPending(); String code = InviteStore.get(pending, "code", null); return code != null && code.length() > 0; } @@ -1292,7 +1325,7 @@ private static boolean markTerminal(int terminalState, String reason) { // has run yet, so nothing wrote one. Copying nulls left the reopened // marker with expiresAt 0, and beginDeferred reads that as "no window", // so an arbitrarily old install could still run a fingerprint match. - Map before = InviteStore.read(InviteStore.PENDING); + Map before = readPending(); long markedAt = System.currentTimeMillis(); done.put("firstLaunch", InviteStore.get(before, "firstLaunch", String.valueOf(markedAt))); @@ -1327,7 +1360,7 @@ private static boolean markTerminal(int terminalState, String reason) { "codeReferrer"}) { InviteStore.put(done, key, InviteStore.get(before, key, null)); } - if (!InviteStore.write(InviteStore.PENDING, done)) { + if (!writePending(done)) { // Nothing is committed. Reporting a terminal outcome the device // cannot remember meant the same lookup and the same callback // repeated after every restart -- or, worse, the delivery flag @@ -1352,7 +1385,7 @@ private static String undeliveredFromMarker() { if (s != STATE_NONE_FOUND && s != STATE_DECLINED) { return null; } - Map marker = InviteStore.read(InviteStore.PENDING); + Map marker = readPending(); if (marker == null || InviteStore.getBoolean(marker, "delivered", false)) { return null; } @@ -1364,18 +1397,76 @@ private static String undeliveredFromMarker() { // process exits, so telling the listener about a delivery the device cannot // remember means telling it again on the next launch. private static boolean markUnavailableDelivered() { - Map marker = InviteStore.read(InviteStore.PENDING); + Map marker = readPending(); if (marker == null) { // Nothing durable to mark. The answer is still terminal in memory // and the run's own guard prevents a repeat within it. return true; } marker.put("delivered", "true"); - return InviteStore.write(InviteStore.PENDING, marker); + return writePending(marker); + } + + /// Writes the pending record, keeping an in-memory copy while that fails. + /// + /// - `record`: the record to persist + /// + /// #### Returns + /// + /// true when it reached storage + private static boolean writePending(Map record) { + boolean written = InviteStore.write(InviteStore.PENDING, record); + // Cleared on success rather than left behind, so the fallback can never + // shadow a newer durable record. + pendingFallback = written ? null : record; + return written; + } + + /// Forgets the in-memory copy, for the paths that delete the record. + private static void forgetPendingFallback() { + pendingFallback = null; + } + + /// Reads the pending record, preferring the copy a failed write left behind. + /// + /// The held copy is always the newer of the two, because it exists only + /// between a write that failed and the next one that succeeds -- so the + /// record still on the disk is whatever was there BEFORE the change that + /// could not be saved. Reading the disk first was the shape of the bug this + /// exists to close: a direct link's exact code was written into a record + /// that never landed, the stale one underneath it had no code, and the + /// retry answered with the install referrer or a fingerprint instead. + /// + /// Persisting is retried here rather than on a timer, which is the next + /// time anything wanted the record anyway. + /// + /// #### Returns + /// + /// the record, or null when there is none + /// The pending record as the feature itself sees it, for tests. + /// + /// Package private: the tests have to be able to tell the durable record + /// apart from the copy held after a failed write, and going through + /// `InviteStore` directly cannot. + /// + /// #### Returns + /// + /// the record, or null + static Map pendingRecordForTest() { + return readPending(); + } + + private static Map readPending() { + Map held = pendingFallback; + if (held != null) { + writePending(held); + return held; + } + return InviteStore.read(InviteStore.PENDING); } private static Map pendingRecord() { - Map pending = InviteStore.read(InviteStore.PENDING); + Map pending = readPending(); if (pending != null) { return pending; } @@ -1386,7 +1477,7 @@ private static Map pendingRecord() { pending.put("attempts", "0"); pending.put("state", String.valueOf(STATE_PENDING)); captureProfile(pending); - InviteStore.write(InviteStore.PENDING, pending); + writePending(pending); return pending; } @@ -1428,7 +1519,7 @@ private static void beginDeferred() { // was a real answer about this install and stays. Reopening reads the // condition itself, never a second stored copy of it. if (s == STATE_NONE_FOUND || s == STATE_DECLINED) { - Map marker = InviteStore.read(InviteStore.PENDING); + Map marker = readPending(); String why = InviteStore.get(marker, "reason", null); boolean reopen = (REASON_UNSUPPORTED.equals(why) && attributionWindow != 0) || (REASON_CONSENT_DENIED.equals(why) && !explicitlyDenied()); @@ -1478,7 +1569,7 @@ private static void beginDeferred() { // costs five property reads and is the same profile the first // launch would have taken. captureProfile(marker); - InviteStore.write(InviteStore.PENDING, marker); + writePending(marker); state = STATE_PENDING; stateLoaded = true; s = STATE_PENDING; @@ -1635,7 +1726,7 @@ public void run() { InviteStore.put(pending, "codeReferrer", rawReferrer == null ? "" : rawReferrer); pending.remove("referrerRetry"); - InviteStore.write(InviteStore.PENDING, pending); + writePending(pending); claim(code, "install_referrer", rawReferrer == null ? "" : rawReferrer, MATCH_REFERRER, true); @@ -1692,7 +1783,7 @@ private static void fallBackToMatch() { // later, so a no-match from the statistical fallback stays pending instead // of becoming the final word. private static void fallBackToMatch(boolean retryable) { - Map pending = InviteStore.read(InviteStore.PENDING); + Map pending = readPending(); if (pending != null) { if (retryable) { pending.put("referrerRetry", "true"); @@ -1704,13 +1795,13 @@ private static void fallBackToMatch(boolean retryable) { // pending and every launch asked again until the attempt cap. pending.remove("referrerRetry"); } - InviteStore.write(InviteStore.PENDING, pending); + writePending(pending); } fallBackToMatchImpl(); } private static void fallBackToMatchImpl() { - Map pending = InviteStore.read(InviteStore.PENDING); + Map pending = readPending(); if (pending == null) { return; } @@ -1751,7 +1842,7 @@ private static void claim(String code, String source, String rawReferrer, if (!allowed()) { return; } - Map pending = InviteStore.read(InviteStore.PENDING); + Map pending = readPending(); if (pending != null) { bumpAttempts(pending); } @@ -1766,7 +1857,7 @@ private static void claim(String code, String source, String rawReferrer, private static void bumpAttempts(Map pending) { pending.put("attempts", String.valueOf(InviteStore.getInt(pending, "attempts", 0) + 1)); - InviteStore.write(InviteStore.PENDING, pending); + writePending(pending); } private static Map identity() { @@ -1928,7 +2019,7 @@ static void handleResolution(String payload, String matchType, boolean deferred, // throw that away for a statistical guess. Bounded by the // attempt cap and the attribution window, both checked in // beginDeferred(). - Map outstanding = InviteStore.read(InviteStore.PENDING); + Map outstanding = readPending(); if (outstanding != null && "true".equals(InviteStore.get(outstanding, "referrerRetry", null))) { // Deliberately silent. attributionUnavailable() is the @@ -1960,6 +2051,7 @@ static void handleResolution(String payload, String matchType, boolean deferred, // replacement attempt is dropped and the install goes back // to what it was. InviteStore.delete(InviteStore.PENDING); + forgetPendingFallback(); state = STATE_RESOLVED; stateLoaded = true; deferredStarted = false; @@ -2047,7 +2139,7 @@ private static void resolve(InviteAttribution a, String confidence) { // record that carried the fact across the reopen. Map previous = InviteStore.read(InviteStore.ATTRIBUTION); if (previous == null) { - previous = InviteStore.read(InviteStore.PENDING); + previous = readPending(); } record.put("delivered", String.valueOf(InviteStore.getBoolean(previous, "delivered", false))); @@ -2068,11 +2160,11 @@ private static void resolve(InviteAttribution a, String confidence) { // install terminal instead of performing the retry this promises -- // so the very last response, the one most likely to be the only one // left, could never be stored. - Map retry = InviteStore.read(InviteStore.PENDING); + Map retry = readPending(); if (retry != null) { int spent = InviteStore.getInt(retry, "attempts", 0); retry.put("attempts", String.valueOf(spent > 0 ? spent - 1 : 0)); - if (!InviteStore.write(InviteStore.PENDING, retry)) { + if (!writePending(retry)) { // The refund failed for the same reason the attribution did // -- the store is unwritable -- so the durable count is // still at the cap and the next flush would settle the @@ -2087,6 +2179,7 @@ private static void resolve(InviteAttribution a, String confidence) { return; } InviteStore.delete(InviteStore.PENDING); + forgetPendingFallback(); resolved = a; attributionLoaded = true; state = STATE_RESOLVED; diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 75aaddbb952..3fcb6552ded 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -515,6 +515,62 @@ void aFailedOutboxWriteStillTransmitsNothingWithoutConsent() { "a registration was transmitted before consent was given"); } + @FormTest + void anInviteTheServerNeverSawIsNotReportedAsRegistered() { + // isRegistered() reads absence from BOTH the outbox and the in-memory + // unacknowledged set as acknowledgement. On this path neither holds the + // code -- the outbox write is what failed, and consent forbade sending + // -- so the one invite the server is guaranteed never to have seen was + // the one reported as registered, and an application that waits for + // isRegistered() before sharing would hand out a link with no campaign, + // channel or preview behind it. + Analytics.setConsent(null); + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + InviteStore.failNextOutboxWriteForTest(); + + Invite invite = Invites.create(InviteRequest.create().campaign("launch").build()); + assertNotNull(invite, "minting is offline and must still work"); + assertEquals(0, implementation.getQueuedRequests().size(), + "a registration was transmitted before consent was given"); + assertFalse(Invites.isRegistered(invite), + "an invite that was neither queued nor sent reported itself registered"); + } + + @FormTest + void aFailedPendingWriteDoesNotLoseTheDirectCode() { + // handleUrl() commits STATE_PENDING and issues the claim before it + // knows the record reached the disk. When the write failed and the + // claim failed too, the exact code existed nowhere: the retry read a + // record with no code in it and fell back to the install referrer or + // the fingerprint -- answering with a guess, or not at all, a question + // the device had an exact answer to. The copy is held in memory until a + // write succeeds, which the next read of the record retries. + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + // The record has to EXIST first, so the failing write is the one that + // adds the code rather than the one that creates the record. That is + // also the harder case: a stale record with no code in it sits on the + // disk underneath the copy that never landed, and reading the disk + // first found it and answered with a guess. + Invites.checkForInvite(); + assertNull(InviteStore.get(InviteStore.read(InviteStore.PENDING), "code", null), + "the fixture already has a code, so the assertion below proves nothing"); + InviteStore.failNextWriteForTest(InviteStore.PENDING); + + assertTrue(Invites.handleUrl("https://cloud.codenameone.com/i/acme/DIRECT7"), + "the link was not recognised at all"); + assertEquals(Invites.STATE_PENDING, Invites.getState()); + + // And the next read of the record still has the code, and persists it. + Map record = Invites.pendingRecordForTest(); + assertNotNull(record, "the failed write was never retried"); + assertEquals("DIRECT7", InviteStore.get(record, "code", null), + "the exact code was lost, so the retry will guess instead"); + assertEquals(Invites.MATCH_DIRECT, InviteStore.get(record, "codeMatch", null), + "the direct claim lost its provenance"); + } + @Test @EdtTest void flushDoesNotSpendAnAttemptOnALookupThatIsStillOutstanding() { From 1dcfc6b9988a8b3113e5060b8fa137cc8a758179 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:31:10 +0300 Subject: [PATCH 32/99] Invites: two ways a registration said yes when the answer was no A regression from the pending-record fallback, found by review before it shipped. markUnavailableDelivered() puts delivered=true on the marker and the caller withholds the callback when the write fails -- but the map carrying that flag is the one the failure holds for retry, so the next read persisted the very flag the failure was meant to prevent. The answer then read as already delivered and the listener never heard it, on that launch or any other. The flag is backed out of the map on failure, which is the whole restore because the method returns early when it was already set. Separately, the outbox is capped at 512 and drops the OLDEST entry to stay under it. isRegistered() reads absence from both the outbox and the unacknowledged set as acknowledgement, and an evicted entry is in neither -- so the one registration the server is guaranteed never to have received reported itself as registered, and only a log line said otherwise. InviteStore now hands each evicted entry to Invites before dropping it. In memory only, like every other entry in that set; the ERROR log remains the durable half. The cap moved out of writeOutbox's try block to do it: copy.remove(0) on a List compiles to a CHECKCAST, ParparVM does not throw for a failed cast, and check-cast-semantics.sh refuses a checked cast under a catch(Throwable) because the handler cannot run on iOS. Nothing in the cap can fail anyway -- a copy, a size comparison and a removal. Both tests were checked by reverting their fix. --- .../analytics/invite/InviteStore.java | 33 +++++--- .../codename1/analytics/invite/Invites.java | 78 ++++++++++++++++--- .../invite/InviteResilienceTest.java | 74 ++++++++++++++++++ 3 files changed, 163 insertions(+), 22 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java index 158a8ada13e..ec6388a8b6d 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java @@ -179,21 +179,34 @@ static boolean writeOutbox(List entries) { failNextWrite = false; return false; } + // The cap is applied OUTSIDE the try, deliberately. + // + // copy.remove(0) on a List compiles to a CHECKCAST, and + // ParparVM does not throw for a failed cast -- so a checked cast inside + // a catch(Throwable) is a handler that cannot run on iOS, which + // check-cast-semantics.sh refuses outright. Nothing here can fail + // anyway: it is a copy, a size comparison and a removal. + List copy = new ArrayList(entries); + int dropped = 0; + while (copy.size() > MAX_OUTBOX) { + // Reported to Invites before it goes, so isRegistered() can keep + // saying no about it. That method reads absence from BOTH the + // outbox and the unacknowledged set as acknowledgement, and an + // evicted entry is in neither -- so the one registration the server + // is guaranteed never to have received was reported as registered, + // and only the log below said otherwise. + Invites.registrationEvicted(copy.remove(0)); + dropped++; + } + if (dropped > 0) { + Log.p("invite: dropped " + dropped + " unacknowledged registration(s); " + + "those invite links can no longer be attributed", Log.ERROR); + } try { Storage s = Storage.getInstance(); if (s == null) { return false; } - List copy = new ArrayList(entries); - int dropped = 0; - while (copy.size() > MAX_OUTBOX) { - copy.remove(0); - dropped++; - } - if (dropped > 0) { - Log.p("invite: dropped " + dropped + " unacknowledged registration(s); " - + "those invite links can no longer be attributed", Log.ERROR); - } if (copy.isEmpty()) { if (s.exists(OUTBOX)) { s.deleteStorageFile(OUTBOX); diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 9965437685e..c8bc826877b 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1361,12 +1361,18 @@ private static boolean markTerminal(int terminalState, String reason) { InviteStore.put(done, key, InviteStore.get(before, key, null)); } if (!writePending(done)) { - // Nothing is committed. Reporting a terminal outcome the device - // cannot remember meant the same lookup and the same callback - // repeated after every restart -- or, worse, the delivery flag - // landed on the OLD pending record and left the state at PENDING, - // so a supposedly settled lookup ran again and could never deliver - // its answer. + // Not reported now. Reporting a terminal outcome the device cannot + // remember meant the same lookup and the same callback repeated + // after every restart -- or, worse, the delivery flag landed on the + // OLD pending record and left the state at PENDING, so a supposedly + // settled lookup ran again and could never deliver its answer. + // + // The record is held by writePending() and persisted by the next + // read, so the answer is not lost, only deferred: this run says + // nothing and the marker is read back as an undelivered terminal + // answer afterwards, which is what the contract promises. The state + // is deliberately not set in memory either, so nothing here acts on + // a record that may still be only in memory. Log.p("invite: a terminal answer could not be persisted; it will be reached " + "again rather than reported now", Log.WARNING); return false; @@ -1403,8 +1409,24 @@ private static boolean markUnavailableDelivered() { // and the run's own guard prevents a repeat within it. return true; } + if (InviteStore.getBoolean(marker, "delivered", false)) { + return true; + } marker.put("delivered", "true"); - return writePending(marker); + if (writePending(marker)) { + return true; + } + // Backed out of the map, not only reported. + // + // The caller withholds the callback when this returns false, so the + // record must not go on claiming the answer was delivered -- and the + // fallback holds THIS map. Left as it is, the next readPending() would + // persist the very flag the failed write was supposed to prevent, + // undeliveredFromMarker() would then read the answer as already given, + // and the listener would never hear it on any launch. Removing is the + // whole restore because the early return above means it was absent. + marker.remove("delivered"); + return false; } /// Writes the pending record, keeping an in-memory copy while that fails. @@ -2165,11 +2187,13 @@ private static void resolve(InviteAttribution a, String confidence) { int spent = InviteStore.getInt(retry, "attempts", 0); retry.put("attempts", String.valueOf(spent > 0 ? spent - 1 : 0)); if (!writePending(retry)) { - // The refund failed for the same reason the attribution did - // -- the store is unwritable -- so the durable count is - // still at the cap and the next flush would settle the - // install rather than retry. Nothing here can fix that, so - // it is said out loud instead of being assumed away. + // The refund failed for the same reason the attribution + // did -- the store is unwritable -- so the count ON DISK is + // still at the cap. writePending() holds the refunded copy + // and the next read persists it, so a retry within this + // launch sees the right number; a restart before that does + // not, and settles the install rather than asking again. + // Said out loud rather than assumed away. Log.p("invite: the attempt could not be refunded, so a later retry may " + "settle this install instead of asking again", Log.WARNING); } @@ -2354,6 +2378,36 @@ private static void notifyUnavailable(String reason) { // the durable store is the thing that just failed. private static final List unacknowledged = new ArrayList(); + /// Records that a queued registration was evicted to keep the outbox + /// under its cap. + /// + /// The entry is gone for good -- its campaign, channel, payload and + /// preview cannot be reconstructed from a click -- so the least this can + /// do is stop [#isRegistered] answering yes about it. In memory only, like + /// every other entry in that set: after a restart the outbox is the only + /// record, and the evicted entry is not in it. The ERROR logged by the + /// caller is the durable half. + /// + /// - `entry`: the registration JSON that was dropped + static void registrationEvicted(String entry) { + if (entry == null) { + return; + } + try { + Map parsed = + new JSONParser().parseJSON(new java.io.StringReader(entry)); + Object code = parsed == null ? null : parsed.get("code"); + if (code != null) { + unacknowledged.add(code.toString()); + } + } catch (Throwable t) { + // A malformed entry is already lost; failing here would take the + // whole write with it, and the write is what keeps the REST of the + // queue. + Log.e(t); + } + } + private static boolean queueRegistration(Invite invite, InviteRequest request) { Map body = identity(); body.put("code", invite.getCode()); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 3fcb6552ded..6ab80b9254b 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -537,6 +537,33 @@ void anInviteTheServerNeverSawIsNotReportedAsRegistered() { "an invite that was neither queued nor sent reported itself registered"); } + @FormTest + void anEvictedRegistrationIsNotReportedAsRegistered() { + // The outbox is capped, and the cap drops the OLDEST entry. isRegistered() + // reads absence from both the outbox and the unacknowledged set as + // acknowledgement, and an evicted entry is in neither -- so the one + // registration the server is guaranteed never to have received was the + // one reported as registered, and only a log line said otherwise. + // + // Driven through the store rather than by minting 513 invites: the cap + // is InviteStore's and this is what it does when it is reached. + Analytics.setConsent(null); + implementation.setAutoProcessConnections(false); + Invite first = Invites.create(InviteRequest.create().campaign("evicted").build()); + assertNotNull(first); + assertFalse(Invites.isRegistered(first), + "the fixture is already acknowledged, so the assertion below proves nothing"); + + List stuffed = new ArrayList(InviteStore.readOutbox()); + while (stuffed.size() <= InviteStore.MAX_OUTBOX) { + stuffed.add("{\"code\":\"FILLER" + stuffed.size() + "\"}"); + } + assertTrue(InviteStore.writeOutbox(stuffed), "the stuffed outbox could not be written"); + + assertFalse(Invites.isRegistered(first), + "an evicted registration reported itself as acknowledged"); + } + @FormTest void aFailedPendingWriteDoesNotLoseTheDirectCode() { // handleUrl() commits STATE_PENDING and issues the claim before it @@ -1483,6 +1510,53 @@ public void attributionUnavailable(String reason) { "the state was committed without its marker"); } + @Test + @EdtTest + void afailedDeliveryWriteIsStillOwedToTheListener() { + // The other half of the record-held-in-memory change. When the + // delivered=true write fails, the callback is withheld -- but the map + // carrying that flag is the one the failure holds for retry, so the + // next read persisted the very flag the failure was supposed to + // prevent. The answer then read as already delivered and the listener + // never heard it, on this launch or any other. + Invites.setAttributionWindow(0); + Invites.checkForInvite(); + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState(), + "the fixture did not reach a terminal answer"); + + final int[] told = new int[1]; + InviteStore.failNextWriteForTest(InviteStore.PENDING); + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0]++; + } + }); + assertEquals(0, told[0], + "a delivery the device could not record was reported anyway"); + + // The record must not claim it was delivered, or nothing will ever + // report it. + Map marker = Invites.pendingRecordForTest(); + assertNotNull(marker); + assertFalse(InviteStore.getBoolean(marker, "delivered", false), + "a delivery that never happened was recorded as done"); + + // And a listener registered afterwards is told, which is the contract: + // exactly one callback per install, and the answer is remembered. + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0]++; + } + }); + assertEquals(1, told[0], "the answer was owed to the listener and never arrived"); + } + @Test @EdtTest void aFirstTimeDenialStartsItsOwnClock() { From 9fe42d087394c8433a93502a59d0ff1a68b3c2b3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:57:19 +0300 Subject: [PATCH 33/99] Invites: reconcile the cached state when a held record finally lands markTerminal() deliberately does not set the in-memory state when its write fails, so the record held for retry can be terminal while memory still says pending. Persisting it later without saying so left the two disagreeing: a flush read the cached pending state, treated the lookup as live, rewrote the terminal marker back to STATE_PENDING and issued another lookup -- with the device profile markTerminal had stripped, so it could not have matched anyway. readPending() invalidates the cached state when a held record reaches the disk. Invalidating rather than assigning, because what the record means depends on the re-attribution setting and on whether an attribution exists, and loadState() is the one place that knows. The cost is one extra read of a record just written, and only after a storage failure. Checked by reverting the invalidation: the state stays PENDING while the record on the disk says otherwise. --- .../codename1/analytics/invite/Invites.java | 20 +++++++++++- .../invite/InviteResilienceTest.java | 31 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index c8bc826877b..e610d2bc16e 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1481,7 +1481,25 @@ static Map pendingRecordForTest() { private static Map readPending() { Map held = pendingFallback; if (held != null) { - writePending(held); + if (writePending(held)) { + // The cached state is invalidated, not left as it was. + // + // markTerminal() deliberately does NOT set the state when its + // write fails, so the record held here can be terminal while + // memory still says pending. Persisting it without saying so + // left the two disagreeing: a later flush read the cached + // pending state, treated the lookup as live, rewrote the + // terminal marker back to STATE_PENDING and issued another + // lookup -- with the device profile markTerminal had stripped, + // so it could not have matched anyway. + // + // Invalidating rather than assigning, because what the record + // means depends on the re-attribution setting and on whether an + // attribution exists, and loadState() is the one place that + // knows. The cost is one extra read of a record just written, + // and only after a storage failure. + stateLoaded = false; + } return held; } return InviteStore.read(InviteStore.PENDING); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 6ab80b9254b..14daf4d23d6 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -1557,6 +1557,37 @@ public void attributionUnavailable(String reason) { assertEquals(1, told[0], "the answer was owed to the listener and never arrived"); } + @Test + @EdtTest + void aterminalMarkerPersistedLateIsNotReopenedAsPending() { + // markTerminal() deliberately does not set the state when its write + // fails, so the record held for retry can be terminal while memory + // still says pending. Persisting it without reconciling left the two + // disagreeing: a later flush read the cached pending state, treated the + // lookup as live, rewrote the terminal marker back to STATE_PENDING and + // issued another lookup -- with the device profile markTerminal had + // stripped, so it could not have matched anyway. + Invites.checkForInvite(); + assertEquals(Invites.STATE_PENDING, Invites.getState()); + + // The terminal write fails, so the answer is held rather than recorded. + Invites.setAttributionWindow(0); + InviteStore.failNextWriteForTest(InviteStore.PENDING); + Invites.forgetLoadedState(); + Invites.checkForInvite(); + + // Storage recovers: the next read persists the held terminal record. + Map persisted = Invites.pendingRecordForTest(); + assertNotNull(persisted); + assertEquals(Invites.STATE_NONE_FOUND, + InviteStore.getInt(InviteStore.read(InviteStore.PENDING), "state", -1), + "the held terminal record was never persisted"); + + // And the state agrees with the record that is now on the disk. + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState(), + "the cached state still says pending, so a flush will reopen a settled lookup"); + } + @Test @EdtTest void aFirstTimeDenialStartsItsOwnClock() { From 3c16d236443c9e06c49a2c93dfc930179564e566 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:25:03 +0300 Subject: [PATCH 34/99] Invites: getState() never answers from a cache the record contradicts markTerminal() deliberately does not set the state when its write fails, so the record held for retry can be terminal while memory still says pending. loadState() now reconciles the held copy before it trusts the cached answer, which puts it ahead of every state decision because they all come through that method. The review round that prompted this predicted more than measurement supports, and the code says so where the guard is. It claimed flush() would act on the stale answer, reopen the marker and issue a fresh lookup without the profile markTerminal strips. Traced end to end with the reconciliation removed: it does not. Every path that reopens or rewrites the record reads it first, and readPending() drains the held copy and invalidates the cache before anything is written -- flush() enters its restart branch on the stale PENDING and still finishes with the state and the marker both terminal. What is real is narrower and worth fixing on its own: getState() is public API, and answering PENDING out of a cache the device's own record already contradicts is wrong whatever the caller does next. The first test written for this passed WITHOUT the fix, which is what sent me to instrument the path rather than believe it; the test now asserts the one thing that can observe the disagreement, and fails without the guard. Both places drain, and both have to: whichever reaches the held record first is the one that has to invalidate, or the other finds nothing left and trusts an answer the record has already contradicted. --- .../codename1/analytics/invite/Invites.java | 49 ++++++++++++----- .../invite/InviteResilienceTest.java | 55 ++++++++++++++++++- 2 files changed, 87 insertions(+), 17 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index e610d2bc16e..a1d4d01ce2d 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -698,6 +698,29 @@ public static int getState() { // and re-deriving it from storage on every call would read the disk for // every uninvited install. No locking -- the facade runs on the EDT. private static void loadState() { + // The held record is reconciled BEFORE the cached answer is trusted. + // + // markTerminal() deliberately does not set the state when its write + // fails, so the record held for retry can be terminal while memory + // still says pending -- and getState() is public API. Answering PENDING + // out of a cache the device's own record already contradicts is wrong + // on its own terms, whatever the caller then does with it. + // + // It is NOT, measured, what a review round claimed: that flush() would + // act on the stale answer, reopen the marker and issue a fresh lookup + // without the profile markTerminal strips. It does not, because every + // path that reopens or rewrites the record reads it first, and that + // read drains the held copy and invalidates the cache before anything + // is written. Traced end to end with the drain here removed: flush() + // enters its restart branch on the stale PENDING and still finishes + // with the state and the marker both terminal. + // + // Kept anyway, because "the answer is only ever wrong to callers that + // go on to correct it" is an invariant nobody can see from here. + Map held = pendingFallback; + if (held != null && writePending(held)) { + stateLoaded = false; + } if (stateLoaded) { return; } @@ -1474,6 +1497,10 @@ private static void forgetPendingFallback() { /// #### Returns /// /// the record, or null + static boolean pendingFallbackPresentForTest() { + return pendingFallback != null; + } + static Map pendingRecordForTest() { return readPending(); } @@ -1482,22 +1509,14 @@ private static Map readPending() { Map held = pendingFallback; if (held != null) { if (writePending(held)) { - // The cached state is invalidated, not left as it was. - // - // markTerminal() deliberately does NOT set the state when its - // write fails, so the record held here can be terminal while - // memory still says pending. Persisting it without saying so - // left the two disagreeing: a later flush read the cached - // pending state, treated the lookup as live, rewrote the - // terminal marker back to STATE_PENDING and issued another - // lookup -- with the device profile markTerminal had stripped, - // so it could not have matched anyway. + // Invalidated here TOO, not only in loadState(). // - // Invalidating rather than assigning, because what the record - // means depends on the re-attribution setting and on whether an - // attribution exists, and loadState() is the one place that - // knows. The cost is one extra read of a record just written, - // and only after a storage failure. + // Whichever of the two drains the held record first is the one + // that has to say so. loadState() reconciles before any state + // decision is made, which is what beginDeferred() needs; but a + // caller that reads the record directly can get here first, and + // then loadState() finds nothing left to drain and trusts a + // cached answer the record has already contradicted. stateLoaded = false; } return held; diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 14daf4d23d6..1ecf0dd03b2 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -1506,8 +1506,22 @@ public void attributionUnavailable(String reason) { assertEquals(0, told[0], "an answer the device cannot remember was reported to the listener"); - assertTrue(Invites.getState() != Invites.STATE_NONE_FOUND, - "the state was committed without its marker"); + // The disk carries no terminal marker, which is the condition this + // guards: the state must not be terminal while the only copy of that + // answer is in memory. Read through InviteStore rather than through + // Invites, because the accessor now reconciles the held copy first -- + // which is the point of the assertion below. + assertNotEquals(Invites.STATE_NONE_FOUND, + InviteStore.getInt(InviteStore.read(InviteStore.PENDING), "state", + Invites.STATE_PENDING), + "the terminal marker reached the disk, so this proves nothing"); + + // And the answer is deferred rather than dropped: the held record is + // persisted by the next read and the state then agrees with it. Before + // the record was held at all this stayed pending for ever, so the same + // lookup ran again on every launch and the listener heard nothing. + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState(), + "the terminal answer was neither recorded nor reachable afterwards"); } @Test @@ -1588,6 +1602,43 @@ void aterminalMarkerPersistedLateIsNotReopenedAsPending() { "the cached state still says pending, so a flush will reopen a settled lookup"); } + @Test + @EdtTest + void getStateNeverAnswersFromAcacheTheRecordContradicts() { + // markTerminal() deliberately does not set the state when its write + // fails, so the record held for retry can be terminal while memory + // still says pending. Every path that ACTS on the state reads the + // record and reconciles on the way, so the disagreement never reached a + // write -- but getState() is public API, and answering PENDING out of a + // cache the device's own record already contradicts is wrong on its own + // terms. Reconciled at the top of loadState(), which every state + // decision comes through. + Invites.checkForInvite(); + assertEquals(Invites.STATE_PENDING, Invites.getState()); + + Invites.setAttributionWindow(0); + InviteStore.failNextWriteForTest(InviteStore.PENDING); + Invites.forgetLoadedState(); + Invites.checkForInvite(); + + // Nothing has read the record yet, so the terminal answer is still only + // in memory and the cached state still says pending -- the precondition + // this is about. + assertTrue(Invites.pendingFallbackPresentForTest(), + "the record was already persisted, so this proves nothing"); + + // getState() is public API and must not answer out of a cache the + // device's own record contradicts. Asserted before anything else + // touches the record, because every path that acts on the state reads + // the record and reconciles on the way -- so this is the one caller + // that can observe the disagreement. + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState(), + "getState() answered from a cache the held record contradicts"); + assertEquals(Invites.STATE_NONE_FOUND, + InviteStore.getInt(InviteStore.read(InviteStore.PENDING), "state", -1), + "asking for the state did not persist the record it answered from"); + } + @Test @EdtTest void aFirstTimeDenialStartsItsOwnClock() { From 2afe6d2c96eaea7ee8976adb120498ebd22f17ca Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:02:45 +0300 Subject: [PATCH 35/99] Invites: four review findings, one of them a design decision reversed The standard launch mode no longer fails the build. The guard refused it outright, on the reasoning that a link then starts a second activity and the invite is lost. A review round pointed at the generated stub's `private Form currentForm` -- an INSTANCE field -- and it is right: the second activity's copy is null, so wasStopped is true, the generated run() reaches createStartInvocation(), and the application's start() reads the link out of getAppArg() exactly as on a cold launch. The invite arrives. Refusing rejected a configuration apps already build and ship with, so it warns instead and names the colder path. Queued registrations went out carrying the consent they were minted under. Under the default opt-in mode an invite is usually minted BEFORE the prompt is answered, so the serialized body says consentAnalytics:false; draining is gated on consent, but the flag travels with the body and the analytics transport reads it as the proof that the gate was satisfied. Rewritten at drain time -- rewritten, not rebuilt, because the campaign, payload and preview are what the invite was minted with and must not be re-derived from today's state. dispatchNewIntentUrl mutated the caller's Intent. It runs from CodenameOneActivity.onNewIntent, and the ordinary way to extend that is super.onNewIntent(intent) followed by reading intent.getData() -- which had just been set to null underneath the override, so custom deep-link routing that worked before lost the url. The consumption happens on a copy, stored with setIntent; the object the override holds is left as the OS handed it over. setReattribution(false) did not stop a replacement already on the wire. The response still passed handleResolution()'s epoch guard and overwrote the first-touch attribution the setting had just said to keep. The epoch bump fails it on arrival. The first attempt at this deleted the durable replacement record too -- turningOnReattributionLetsTheStateBeReadAgain caught that, and it is right: the off/on round trip is supported and the record is a link the user really did open. What is cancelled is the request, not the invite. Each has a test checked by reverting its fix. --- .../codename1/analytics/invite/Invites.java | 71 ++++++++++++++++++- .../impl/android/AndroidImplementation.java | 25 +++++-- .../builders/AndroidGradleBuilder.java | 38 ++++++---- .../builders/AndroidInviteNewIntentTest.java | 47 ++++++++++++ .../invite/InviteResilienceTest.java | 65 +++++++++++++++++ 5 files changed, 224 insertions(+), 22 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index a1d4d01ce2d..8a8f09b16b3 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -868,6 +868,7 @@ public static long getAttributionWindow() { /// /// - `value`: true for last touch public static void setReattribution(boolean value) { + boolean wasOn = reattribution; reattribution = value; // The cached state was derived under the old value. loadState() reads // the pending record only when re-attribution is on, so a process that @@ -875,6 +876,31 @@ public static void setReattribution(boolean value) { // replacement again -- and setInviteListener(), which most applications // call first, is enough to cache it. stateLoaded = false; + // Turning it OFF discards a replacement RESPONSE already in flight. + // + // Changing the setting alone only changed how the state is read: an + // outstanding replacement still passed handleResolution()'s epoch guard + // and overwrote the first-touch attribution the setting had just said + // to keep. The epoch bump fails it on arrival. + // + // The durable replacement record deliberately stays. Turning the + // setting off and on again is a supported round trip -- there is a test + // named for it -- and deleting the record would lose a link the user + // really did open. What is cancelled is the request, not the invite. + // + // Guarded on there BEING an attribution, because that is what makes an + // outstanding lookup a replacement. On a device with no attribution yet + // the lookup in flight is the first one, and turning last touch off + // says nothing about it -- discarding it there would lose an ordinary + // install's attribution outright. + if (wasOn && !value && getAttribution() != null) { + lookupEpoch++; + // Nothing is outstanding once the epoch has moved, so a later + // resume can issue its own request rather than waiting out a retry + // delay for one that can no longer be acted on. + lookupIssuedAt = 0; + deferredStarted = false; + } } /// Whether last touch attribution is enabled. @@ -2482,7 +2508,50 @@ private static void drainOutbox() { // Re-posting an entry that did land is harmless: the server keys on // the code and treats a repeat from the same inviter as idempotent. for (String json : outbox) { - postRegistration(json); + postRegistration(withCurrentConsent(json)); + } + } + + /// Rewrites a queued registration's consent flag to what consent says now. + /// + /// The body is serialized at mint time, and under the default opt-in mode + /// an invite is very often minted BEFORE the prompt is answered -- so the + /// stored JSON carries `consentAnalytics:false`. Draining is already gated + /// on consent having been granted, but the field travels with the body and + /// the analytics transport reads it as the proof that the gate was + /// satisfied. Sent unchanged, a registration queued before the grant + /// arrived looking unconsented and could be refused, and the link it + /// describes would keep its code and lose its campaign, payload and + /// preview for good. + /// + /// Rewritten rather than rebuilt: everything else in the entry -- the code + /// and the metadata -- is what the invite was minted with and must not be + /// re-derived from today's state. + /// + /// - `json`: the queued registration + /// + /// #### Returns + /// + /// the registration with a current consent flag, or the original when it + /// cannot be parsed + private static String withCurrentConsent(String json) { + if (json == null) { + return null; + } + try { + Map body = + new JSONParser().parseJSON(new java.io.StringReader(json)); + if (body == null) { + return json; + } + body.put("consentAnalytics", Boolean.valueOf(allowed())); + return JSONParser.mapToJson(body); + } catch (Throwable t) { + // An entry that cannot be parsed is still worth sending as it is: + // the alternative is dropping a registration whose metadata exists + // nowhere else. + Log.e(t); + return json; } } diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 3552619336f..788d9c8aeff 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -1697,13 +1697,24 @@ static void dispatchNewIntentUrl(Intent intent) { // rather than whatever the previous intent left cached. instance.setAppArg(null); clearIntentProperties(); - // And the intent's data is consumed, exactly as the lazy - // getAppArg() path consumes it. CodenameOneActivity.onStop() clears - // the app arg, so leaving the data on the intent meant the next - // read after a resume rebuilt the same url from it, and an - // application that handles AppArg in start() saw the deep link a - // second time -- opening the same invite twice for one tap. - intent.setData(null); + // The data is consumed on a COPY, never on the caller's intent. + // + // getAppArg() rebuilds the url from the activity's stored intent, and + // CodenameOneActivity.onStop() clears the app arg -- so leaving the data + // in place meant the next read after a resume rebuilt the same url and + // an application that handles AppArg in start() saw the deep link a + // second time, opening the same invite twice for one tap. + // + // Clearing it on the intent passed in was worse. This runs from + // CodenameOneActivity.onNewIntent(), and the ordinary way to extend that + // is super.onNewIntent(intent) followed by the subclass reading + // intent.getData() -- which had just been set to null underneath it, so + // custom deep-link routing that worked before lost the url entirely. The + // copy is what the activity stores; the object the override holds is + // left exactly as the OS handed it over. + android.content.Intent consumed = new android.content.Intent(intent); + consumed.setData(null); + getActivity().setIntent(consumed); Display.getInstance().setProperty("AppArg", data.toString()); } catch (Throwable t) { com.codename1.io.Log.e(t); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 6baa2a3fd64..6877aea9e95 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -2979,22 +2979,32 @@ public void usesClassMethod(String cls, String method) { debug("Invite attribution: adding the App Links filter for " + inviteHost); request.putArgument("android.xintent_filter", withAppLinks); } - // launchMode decides whether a link reaching an app that is - // already running is delivered to it at all. singleTop (the - // default) and singleTask both route through onNewIntent; - // "standard" starts a SECOND activity and a second lifecycle, and - // the invite is simply lost. Refused rather than warned: a warning - // in a build log is exactly the thing nobody reads, and the - // symptom on the device is a feature that silently never fires. + // launchMode decides WHICH delivery path a link takes, not whether + // it arrives. + // + // singleTop (the default) and singleTask route a link into the + // running activity through onNewIntent. "standard" starts a SECOND + // activity instead -- and that activity's `currentForm` is an + // INSTANCE field, so it is null, `wasStopped` is true, and the + // generated run() goes on to createStartInvocation(): the + // application's start() runs and reads the link out of getAppArg() + // exactly as it does on a cold launch. + // + // This refused the build outright until a review round pointed at + // that field. It was wrong: the invite is delivered, and refusing + // rejected a configuration the app already built and shipped with. + // Warned instead, because the delivery is real but the path is the + // colder one and the second activity is a surprise worth naming. String launchMode = request.getArg("android.activity.launchMode", "singleTop"); if ("standard".equals(launchMode)) { - throw new BuildException("This app uses invite attribution " - + "(com.codename1.analytics.invite), which needs an invite link to reach " - + "the running activity, but android.activity.launchMode is \"standard\". " - + "A link then starts a second activity instead of being delivered to the " - + "running one, and the invite is lost. Use singleTop (the default) or " - + "singleTask, or set android.invite.appLinks=false and handle the link " - + "yourself."); + warn("This app uses invite attribution " + + "(com.codename1.analytics.invite) with " + + "android.activity.launchMode=\"standard\". An invite link then starts a " + + "second activity rather than reaching the running one, so the invite " + + "arrives through the application's start() instead of onNewIntent(). " + + "That works, and it is what a cold launch does anyway -- but " + + "checkForInvite() has to be called from start(), and singleTop (the " + + "default) or singleTask avoids the second activity entirely."); } } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java index 43d0db75ecf..b4e1633288c 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java @@ -59,6 +59,10 @@ public class AndroidInviteNewIntentTest { private static final String BUILDER = "src/main/java/com/codename1/builders/AndroidGradleBuilder.java"; + /** The port source, relative to the plugin module the tests run in. */ + private static final String ANDROID_PORT = + "../../Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java"; + private String source() throws IOException { File builder = new File(BUILDER); assertTrue(builder.isFile(), "the builder must be readable: " + builder.getAbsolutePath()); @@ -92,6 +96,49 @@ void theOverrideRunsOnTheEventDispatchThread() throws IOException { "the generated onNewIntent can run before Display exists"); } + @Test + void theConsumedUrlIsClearedOnAcopyNotOnTheCallersIntent() throws IOException { + // dispatchNewIntentUrl runs from CodenameOneActivity.onNewIntent, and + // the ordinary way to extend that is super.onNewIntent(intent) followed + // by the subclass reading intent.getData(). Clearing the data on THAT + // object set it to null underneath the override, so custom deep-link + // routing that worked before lost the url entirely. + File port = new File(ANDROID_PORT); + assertTrue(port.isFile(), "the port must be readable: " + port.getAbsolutePath()); + String source = new String(Files.readAllBytes(port.toPath()), StandardCharsets.UTF_8); + int at = source.indexOf("static void dispatchNewIntentUrl("); + assertTrue(at > 0, "dispatchNewIntentUrl is gone"); + String block = source.substring(at, source.indexOf("\n }", at)); + assertTrue(!block.contains("intent.setData(null)"), + "the caller's intent is mutated, so a subclass reading it after " + + "super.onNewIntent() finds no data"); + assertTrue(block.contains("new android.content.Intent(intent)") + && block.contains("consumed.setData(null)"), + "the url is no longer consumed on a copy"); + } + + @Test + void standardLaunchModeIsWarnedAboutRatherThanRefused() throws IOException { + // It refused the build outright until a review round pointed at the + // generated stub's `private Form currentForm` -- an INSTANCE field. A + // standard-mode App Link starts a SECOND activity, whose copy of that + // field is null, so wasStopped is true and the generated run() reaches + // createStartInvocation(): the application's start() runs and reads the + // link out of getAppArg() exactly as on a cold launch. The invite is + // delivered, and refusing rejected a configuration the app already + // built and shipped with. + String source = source(); + int guard = source.indexOf("\"standard\".equals(launchMode)"); + assertTrue(guard > 0, "the launch-mode guard is gone"); + int end = source.indexOf("\n }", guard); + assertTrue(end > guard, "the guard block moved"); + String block = source.substring(guard, end); + assertTrue(block.contains("warn("), + "a working launch mode is refused instead of warned about"); + assertTrue(!block.contains("throw new BuildException"), + "standard launch mode still fails the build"); + } + @Test void theInviteReferenceOnlyExistsForAppsThatUseInvites() throws IOException { String source = source(); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 1ecf0dd03b2..64d1a456390 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -564,6 +564,44 @@ void anEvictedRegistrationIsNotReportedAsRegistered() { "an evicted registration reported itself as acknowledged"); } + @FormTest + void aqueuedRegistrationIsSentWithTodaysConsentNotYesterdays() { + // The body is serialized at mint time, and under the default opt-in + // mode an invite is very often minted BEFORE the prompt is answered -- + // so the stored JSON carries consentAnalytics:false. Draining is gated + // on consent having been granted, but the flag travels WITH the body + // and the analytics transport reads it as the proof that the gate was + // satisfied. Sent unchanged, a registration queued before the grant + // arrived looking unconsented and could be refused, and the link it + // describes would keep its code and lose its campaign, payload and + // preview for good. + Analytics.setConsent(null); + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + + Invite invite = Invites.create(InviteRequest.create().campaign("launch").build()); + assertNotNull(invite); + assertEquals(0, implementation.getQueuedRequests().size(), + "the registration was transmitted before consent was given"); + + Analytics.setConsent(AnalyticsConsent.granted()); + Invites.flush(); + + String body = null; + for (int i = 0; i < implementation.getQueuedRequests().size(); i++) { + String candidate = implementation.getQueuedRequests().get(i).getRequestBody(); + if (candidate != null && candidate.indexOf(invite.getCode()) >= 0) { + body = candidate; + } + } + assertNotNull(body, "the queued registration was never drained"); + assertTrue(body.indexOf("\"consentAnalytics\":true") >= 0 + || body.indexOf("\"consentAnalytics\": true") >= 0, + "the registration went out with the consent it was minted under: " + body); + assertTrue(body.indexOf("launch") >= 0, + "rewriting the consent flag lost the metadata the outbox exists to keep"); + } + @FormTest void aFailedPendingWriteDoesNotLoseTheDirectCode() { // handleUrl() commits STATE_PENDING and issues the claim before it @@ -1218,6 +1256,33 @@ void turningOnReattributionLetsTheStateBeReadAgain() { "the cached state hid the durable replacement"); } + @Test + @EdtTest + void turningReattributionOffDiscardsAreplacementAlreadyInFlight() { + // Changing the setting only changed how the state is READ. An + // outstanding replacement response still passed handleResolution()'s + // epoch guard and overwrote the first-touch attribution the setting had + // just said to keep -- so an application that turned last touch off + // could still have a user's cohort change underneath its reports, once, + // by a request that was already on the wire. + Invites.handleResolution(InviteTestSupport.resolvedJson("FIRST8", "c1", "sms"), + Invites.MATCH_DIRECT, false); + Invites.setReattribution(true); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/SECOND8"); + int inFlight = Invites.currentLookupEpochForTest(); + + Invites.setReattribution(false); + + // The response that was already on the wire lands now. + Invites.handleResolution(InviteTestSupport.resolvedJson("SECOND8", "c1", "sms"), + Invites.MATCH_DIRECT, false, inFlight); + + InviteAttribution a = Invites.getAttribution(); + assertNotNull(a); + assertEquals("FIRST8", a.getCode(), + "an in-flight replacement overwrote first touch after last touch was turned off"); + } + @Test @EdtTest void aResumedLookupThatEndsTerminallyIsNotAnnouncedTwice() { From 3b1d4c0727a4f8d2a02bb15da5d248ba595b0d9a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:24:32 +0300 Subject: [PATCH 36/99] Invites: an erasure that lasts, a kill switch that reaches the wire reset() left the state at STATE_NONE, which is indistinguishable from a fresh install -- so the next ordinary checkForInvite() built a new device profile and started deferred matching again. Inside the original click window, which on iOS is the normal path, the server can match the same device to the same click and restore the very inviter dimensions the user asked to be rid of, under their new client id. The erasure lasted until the next launch. eraseInternal() leaves a tombstone: a state and a reason and nothing else -- no code, no fingerprint, no identifier, none of what the erasure removed. It is marked delivered, because the answer it stands for was already given and has just been erased, and its reason is not one beginDeferred() reopens. A direct link still reopens attribution, since handleUrl() overwrites the state and clears the reason. That asymmetry is the point: somebody who erases their identity and then taps a new invite is asking for that invite; somebody who erases it and reopens the app is not. setAttributionWindow(0) changed only what future calls read. A statistical request queued a moment earlier carries the epoch it was issued with, so its answer still landed, persisted and reported an attribution the application had just switched off. The window is read against the ANSWER now -- and only the deferred one, because the switch turns off the statistical lookup and not an exact code the device is holding. hasSavedCode() exempts one where the lookup begins and this keeps the same exemption from the other end, which is also why it is not an epoch bump: the epoch is global and would discard the direct claim with it. And the consent rewrite from the previous commit broke acknowledgement. It passed the rewritten JSON as both the body and the outbox key, so outbox.remove() matched nothing: every registration would be resent on every flush for ever and isRegistered() would never become true. The body is rewritten and the original stays the key. The test that covered the rewrite only asserted the body it sent, which is how it got through. Each fix has a test checked by reverting it. --- .../codename1/analytics/invite/Invites.java | 98 ++++++++++++++++++- .../invite/InviteConsentAndErasureTest.java | 41 +++++++- .../invite/InviteResilienceTest.java | 90 +++++++++++++++++ 3 files changed, 225 insertions(+), 4 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 8a8f09b16b3..0729ba03745 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -148,6 +148,13 @@ public final class Invites { /// This platform cannot recover a deferred invite. public static final String REASON_UNSUPPORTED = "unsupported"; + // Not public, and never delivered to a listener. It is the marker an + // erasure leaves behind so the automatic lookup does not start again, and + // an application has no decision to make about it -- the reasons above are + // answers about an invite, this is a record that there is no longer anyone + // to answer about. + static final String REASON_ERASED = "erased"; + /// The analytics category every invite event is reported under. public static final String CATEGORY = "referral"; @@ -625,6 +632,11 @@ public static boolean handleUrl(String url) { // are holding the code for, so a referrer read is no longer a better // answer waiting to happen. pending.remove("referrerRetry"); + // And any terminal reason the record was carrying, which is what makes + // a direct link the one thing that reopens an erased install: the + // tombstone eraseInternal() leaves is a state and a reason, and this + // overwrites both rather than reopening around them. + pending.remove("reason"); writePending(pending); setState(STATE_PENDING); // A deferred fingerprint or referrer lookup may already be on the wire, @@ -992,6 +1004,36 @@ static void forgetCachedAttributionForTest() { // id changes underneath us, which is what an erasure request looks like. static void eraseInternal() { reset(); + // A tombstone, so the erasure is not undone by the next ordinary + // launch. + // + // reset() deletes the records and leaves the state at STATE_NONE, which + // is indistinguishable from a fresh install -- so the next routine + // checkForInvite() built a new profile and started deferred matching + // again. Inside the original click window, which on iOS is the normal + // path, the server can match the same device to the same click and + // restore the very inviter dimensions the user asked to be rid of, + // under their new client id. The erasure would have lasted until the + // next launch. + // + // The marker carries a state and a reason and NOTHING else: no code, no + // fingerprint, no identifier, nothing the erasure was meant to remove. + // It is marked delivered because there is no answer owed to anyone -- + // the install had one and it has just been erased -- and its reason is + // not one beginDeferred() reopens, so the automatic lookup stays off. + // + // A direct link still reopens attribution: handleUrl() overwrites the + // state and clears the reason, which is the right asymmetry. Somebody + // who erases their identity and then taps a new invite is asking for + // that invite; somebody who erases it and reopens the app is not. + Map erased = new LinkedHashMap(); + erased.put("state", String.valueOf(STATE_NONE_FOUND)); + erased.put("reason", REASON_ERASED); + erased.put("delivered", "true"); + if (writePending(erased)) { + state = STATE_NONE_FOUND; + stateLoaded = true; + } } // Package private: called from the provider when consent changes. @@ -1962,9 +2004,30 @@ private static void post(String url, Map body, String matchType, private static void send(String url, String json, String matchType, boolean deferred, boolean registration) { + send(url, json, json, matchType, deferred, registration); + } + + /// Sends `json`, and remembers `outboxKey` as the entry to retire when the + /// server accepts it. + /// + /// The two are the same string everywhere except one place: a queued + /// registration is rewritten on the way out so its consent flag is current, + /// and the entry sitting in the outbox is still the original. Passing the + /// rewritten body as the key made `outbox.remove(...)` match nothing, so + /// the registration was resent on every flush for ever and isRegistered() + /// never became true. + /// + /// - `url`: where to send it + /// - `json`: the body to transmit + /// - `outboxKey`: the stored entry this acknowledges, or null + /// - `matchType`: how the attribution was reached + /// - `deferred`: whether this is the statistical path + /// - `registration`: whether this is a mint registration + private static void send(String url, String json, String outboxKey, String matchType, + boolean deferred, boolean registration) { try { InviteConnection req = new InviteConnection(matchType, deferred, registration, - registration ? json : null, lookupEpoch); + registration ? outboxKey : null, lookupEpoch); req.setUrl(url); req.setPost(true); req.setContentType("application/json"); @@ -2086,6 +2149,24 @@ static void handleResolution(String payload, String matchType, boolean deferred, if (epoch != lookupEpoch || !allowed()) { return; } + // And the kill switch is read HERE, not only where the lookup starts. + // + // setAttributionWindow(0) turns off deferred attribution, but a + // statistical request queued a moment earlier is already on the wire + // and carries the epoch it was issued with -- so its answer used to + // land, persist and report an attribution the application had just + // switched off. The window is checked against the answer rather than + // against the request. + // + // Only the DEFERRED answer. The switch turns off the statistical + // lookup, not an exact code the device is holding: hasSavedCode() + // exempts one where the lookup begins, and cancelling a direct claim + // here would break the same exemption from the other end. That is also + // why this is not an epoch bump -- the epoch is global and would + // discard the direct claim with it. + if (deferred && attributionWindow == 0) { + return; + } try { if (payload == null || payload.length() == 0) { return; @@ -2508,7 +2589,10 @@ private static void drainOutbox() { // Re-posting an entry that did land is harmless: the server keys on // the code and treats a repeat from the same inviter as idempotent. for (String json : outbox) { - postRegistration(withCurrentConsent(json)); + // The body is rewritten, the KEY is not. The outbox still holds the + // original string, and that is what has to be removed when the + // server accepts it. + postRegistration(withCurrentConsent(json), json); } } @@ -2556,7 +2640,15 @@ private static String withCurrentConsent(String json) { } private static void postRegistration(String json) { - send(getLinkBase() + PATH_MINT, json, MATCH_DIRECT, false, true); + postRegistration(json, json); + } + + /// Posts `body`, retiring `outboxKey` from the outbox when it lands. + /// + /// - `body`: the registration to transmit + /// - `outboxKey`: the stored entry it stands for + private static void postRegistration(String body, String outboxKey) { + send(getLinkBase() + PATH_MINT, body, outboxKey, MATCH_DIRECT, false, true); } // Called from the registration response, once its status has been checked. diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java index d42af34ae9c..c68e1fe7f27 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java @@ -114,7 +114,46 @@ void resetClientIdErasesTheReferralDimensionsAndKeepsTheApplicationsOwn() { // destroying data it never asked to lose. assertEquals("pro", dims.get("plan")); assertNull(Invites.getAttribution()); - assertEquals(Invites.STATE_NONE, Invites.getState()); + // Terminal, not STATE_NONE. STATE_NONE is indistinguishable from a + // fresh install, and that is precisely what let the erasure be undone: + // the next ordinary checkForInvite() built a new profile and started + // deferred matching again, and inside the original click window the + // server can match the same device to the same click and restore the + // same inviter under the new client id. The tombstone carries a state + // and a reason and nothing else. + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState()); + } + + @FormTest + void anerasedInstallDoesNotStartLookingAgainByItself() { + // The erasure has to survive the next launch, not just the moment it + // happens. Nothing personal is kept to achieve it -- the marker is a + // state and a reason -- but the automatic lookup must not restart, or + // the server can hand the same inviter back under the new identity. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.handleResolution( + InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + Invites.MATCH_REFERRER, true); + assertNotNull(Invites.getAttribution()); + + Analytics.resetClientId(); + implementation.clearQueuedRequests(); + + // The next ordinary launch. + Invites.forgetLoadedState(); + Invites.checkForInvite(); + + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState(), + "an erased install started deferred matching again by itself"); + assertEquals(0, implementation.getQueuedRequests().size(), + "an erased install sent a fresh device profile to the server"); + + // And a NEW invite still reopens it: erasing an identity is not a + // decision about an invite the person taps afterwards. + assertTrue(Invites.handleUrl("https://cloud.codenameone.com/i/acme/AFTER1")); + assertEquals(Invites.STATE_PENDING, Invites.getState(), + "a direct invite could not reopen attribution after an erasure"); } @FormTest diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 64d1a456390..0b6ff116f08 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -602,6 +602,55 @@ void aqueuedRegistrationIsSentWithTodaysConsentNotYesterdays() { "rewriting the consent flag lost the metadata the outbox exists to keep"); } + @FormTest + void arewrittenRegistrationStillRetiresItsOriginalOutboxEntry() { + // The body is rewritten on the way out so its consent flag is current; + // the entry sitting in the outbox is still the original. Passing the + // rewritten string as the acknowledgement key made outbox.remove() + // match nothing, so the registration was resent on every flush for ever + // and isRegistered() never became true -- a fix for one silent failure + // that introduced a louder one. + Analytics.setConsent(null); + implementation.clearQueuedRequests(); + implementation.setAutoProcessConnections(false); + + Invite invite = Invites.create(InviteRequest.create().campaign("launch").build()); + assertNotNull(invite); + List queued = InviteStore.readOutbox(); + assertEquals(1, queued.size(), "the registration was not queued"); + String stored = queued.get(0); + assertTrue(stored.indexOf("false") >= 0, + "the fixture was queued with consent already granted"); + + Analytics.setConsent(AnalyticsConsent.granted()); + Invites.flush(); + + // The server accepts it. The connection has to hand back the ORIGINAL + // entry, or nothing is retired. + Invites.InviteConnection req = null; + for (int i = 0; i < implementation.getQueuedRequests().size(); i++) { + ConnectionRequest r = implementation.getQueuedRequests().get(i); + if (r instanceof Invites.InviteConnection + && r.getRequestBody() != null + && r.getRequestBody().indexOf(invite.getCode()) >= 0) { + req = (Invites.InviteConnection) r; + } + } + assertNotNull(req, "the queued registration was never sent"); + try { + req.readResponse(new ByteArrayInputStream( + "{\"registered\":true}".getBytes("UTF-8"))); + } catch (IOException e) { + throw new IllegalStateException(e); + } + req.postResponse(); + + assertEquals(0, InviteStore.readOutbox().size(), + "the acknowledged registration stayed in the outbox and will be resent for ever"); + assertTrue(Invites.isRegistered(invite), + "an acknowledged registration never reports itself registered"); + } + @FormTest void aFailedPendingWriteDoesNotLoseTheDirectCode() { // handleUrl() commits STATE_PENDING and issues the claim before it @@ -1232,6 +1281,47 @@ public void attributionUnavailable(String reason) { assertEquals(Invites.STATE_PENDING, Invites.getState()); } + @Test + @EdtTest + void thekillSwitchAlsoRefusesAmatchAlreadyOnTheWire() { + // setAttributionWindow(0) changed only the value future calls read. A + // statistical request queued a moment earlier carries the epoch it was + // issued with, so its answer still landed, persisted and reported an + // attribution the application had just switched off. + Invites.checkForInvite(); + assertEquals(Invites.STATE_PENDING, Invites.getState()); + int inFlight = Invites.currentLookupEpochForTest(); + + Invites.setAttributionWindow(0); + + Invites.handleResolution(InviteTestSupport.resolvedJson("LATE1", "c1", "sms"), + Invites.MATCH_FINGERPRINT, true, inFlight); + + assertNull(Invites.getAttribution(), + "a statistical answer landed after the kill switch was thrown"); + } + + @Test + @EdtTest + void thekillSwitchStillLetsAnExactAnswerLand() { + // The switch turns off the STATISTICAL lookup, not an exact code the + // device is holding -- hasSavedCode() exempts one where the lookup + // begins, and refusing a direct claim on the way back in would break + // the same exemption from the other end. This is why the guard reads + // the deferred flag rather than bumping the epoch, which is global. + Invites.handleUrl("https://cloud.codenameone.com/i/acme/EXACT7"); + int inFlight = Invites.currentLookupEpochForTest(); + + Invites.setAttributionWindow(0); + + Invites.handleResolution(InviteTestSupport.resolvedJson("EXACT7", "c1", "sms"), + Invites.MATCH_DIRECT, false, inFlight); + + InviteAttribution a = Invites.getAttribution(); + assertNotNull(a, "the kill switch discarded an exact answer we had asked for"); + assertEquals("EXACT7", a.getCode()); + } + @Test @EdtTest void turningOnReattributionLetsTheStateBeReadAgain() { From 0d0e067b95f648b89076b0f253aa45bdb5109c39 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:53:49 +0300 Subject: [PATCH 37/99] Invites: the kill switch is about the guess, not about being deferred The response guard keyed on `deferred`, and an install-referrer claim is exact AND deferred: the code came back through the store, which is the whole reason the Android path is the deterministic one. So setAttributionWindow(0) dropped the best answer the device will ever have -- the same saved-code exemption beginDeferred() honours when it starts a lookup, broken from the returning end. Keyed on MATCH_FINGERPRINT now, which is what the switch actually turns off. A test covers the referrer claim landing past the switch alongside the existing ones for the fingerprint answer being refused and the direct claim still landing. --- .../codename1/analytics/invite/Invites.java | 14 ++++++++---- .../invite/InviteResilienceTest.java | 22 +++++++++++++++++++ 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 0729ba03745..1a9a689050f 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -2158,13 +2158,19 @@ static void handleResolution(String payload, String matchType, boolean deferred, // switched off. The window is checked against the answer rather than // against the request. // - // Only the DEFERRED answer. The switch turns off the statistical + // Only the STATISTICAL answer. The switch turns off the fingerprint // lookup, not an exact code the device is holding: hasSavedCode() - // exempts one where the lookup begins, and cancelling a direct claim + // exempts one where the lookup begins, and cancelling an exact claim // here would break the same exemption from the other end. That is also // why this is not an epoch bump -- the epoch is global and would - // discard the direct claim with it. - if (deferred && attributionWindow == 0) { + // discard the exact claim with it. + // + // Keyed on the match type rather than on `deferred`, which was the + // first spelling and was wrong: an install-referrer claim is exact AND + // deferred -- the code came back through the store, which is the whole + // reason the Android path is the deterministic one -- so the kill + // switch dropped the best answer the device will ever have. + if (MATCH_FINGERPRINT.equals(matchType) && attributionWindow == 0) { return; } try { diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 0b6ff116f08..570339f3c10 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -1322,6 +1322,28 @@ void thekillSwitchStillLetsAnExactAnswerLand() { assertEquals("EXACT7", a.getCode()); } + @Test + @EdtTest + void thekillSwitchStillLetsAnInstallReferrerClaimLand() { + // An install-referrer claim is exact AND deferred: the code came back + // through the store, which is the whole reason the Android path is the + // deterministic one. Keying the guard on the deferred flag therefore + // dropped the best answer the device will ever have -- the same + // saved-code exemption the lookup start honours, broken from the + // returning end. + Invites.checkForInvite(); + int inFlight = Invites.currentLookupEpochForTest(); + + Invites.setAttributionWindow(0); + + Invites.handleResolution(InviteTestSupport.resolvedJson("REF9", "c1", "sms"), + Invites.MATCH_REFERRER, true, inFlight); + + InviteAttribution a = Invites.getAttribution(); + assertNotNull(a, "the kill switch discarded an exact install-referrer claim"); + assertEquals("REF9", a.getCode()); + } + @Test @EdtTest void turningOnReattributionLetsTheStateBeReadAgain() { From 7c7585380631ade023ef283fc6554a3a87548311 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:15:48 +0300 Subject: [PATCH 38/99] Invites: an erasure that survives a failed write, and three smaller holes The erasure tombstone was written and its result ignored. If that write failed and the process exited before any read retried the held copy, no marker survived -- and InviteAttributionProvider had already recorded the new client id as its baseline, so the next launch saw no change of identity, did not erase again, and found a state indistinguishable from a fresh install: free to start deferred attribution and be handed the same inviter back under the new id. eraseInternal() reports whether it persisted and the baseline moves only when it did, which costs one repeated erasure and is the only thing here that survives the process. A completed referrer read left its in-flight timestamp behind. Under OPT_OUT with no choice on record the referrer read IS permitted, so a Play install that comes back empty falls through to the statistical match -- which needs an explicit grant and declines. onConsentChanged() then saw a lookup still in flight, did not start the match the grant had just permitted, and nothing retried it: the attribution stayed pending until some unrelated flush, check or relaunch happened along. The builder could not see invites inside a submitted library. The scan reads the application's own classes, so a cn1lib that encapsulates Invites left usesInvites false and lost the entire Android integration at once -- no App Links filter, no onNewIntent splice, the install-referrer package deleted from the generated sources, and the Play Install Referrer dependency never selected, so the library compiled against an API nothing had switched on. It rides the same LibraryClassPrefixScan the call and VPN prefixes use, and feeds the feature catalog as well as the flag. And the developer guide said setAttributionWindow(0) switches deferred attribution off entirely, which is not what it does: an exact code the device already holds is still claimed. The same sentence in code was wrong in the same direction and was fixed in the previous commit. --- .../invite/InviteAttributionProvider.java | 16 ++++++- .../codename1/analytics/invite/Invites.java | 35 ++++++++++++++-- docs/developer-guide/Analytics.asciidoc | 2 +- .../builders/AndroidGradleBuilder.java | 42 +++++++++++++++++++ .../builders/AndroidInviteNewIntentTest.java | 20 +++++++++ 5 files changed, 108 insertions(+), 7 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java b/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java index d553ebfeeec..47f291b1eb2 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java @@ -74,8 +74,20 @@ public void init(AnalyticsContext context) { return; } if (!last.equals(seen)) { - Invites.eraseInternal(); - Preferences.set(PREF_LAST_CLIENT_ID, seen); + // The baseline moves only once the erasure is durable. + // + // Recording the new id regardless meant a failed marker write ended + // the erasure for good: the held copy is retried by the next read of + // the record, but a process that exits before one loses it, and the + // next launch sees no change of identity, does not erase again, and + // finds a state indistinguishable from a fresh install -- free to + // start deferred attribution and be handed the same inviter back + // under the new id. Leaving the baseline where it is costs one + // repeated erasure and is the only thing here that survives the + // process. + if (Invites.eraseInternal()) { + Preferences.set(PREF_LAST_CLIENT_ID, seen); + } } } diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 1a9a689050f..b23f1eef57d 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1002,7 +1002,7 @@ static void forgetCachedAttributionForTest() { // Package private: the analytics provider hook calls this when the client // id changes underneath us, which is what an erasure request looks like. - static void eraseInternal() { + static boolean eraseInternal() { reset(); // A tombstone, so the erasure is not undone by the next ordinary // launch. @@ -1030,10 +1030,25 @@ static void eraseInternal() { erased.put("state", String.valueOf(STATE_NONE_FOUND)); erased.put("reason", REASON_ERASED); erased.put("delivered", "true"); - if (writePending(erased)) { - state = STATE_NONE_FOUND; - stateLoaded = true; + if (!writePending(erased)) { + // The caller is told, because the caller is what remembers that the + // erasure happened. + // + // The held copy is retried by the next read of the record -- but if + // the process exits before one, it is gone, and the provider had + // already recorded the new client id as its baseline. The next + // launch then sees no change, does not erase again, and finds + // STATE_NONE: a fresh install as far as everything here is + // concerned, free to start deferred attribution and be handed the + // same inviter back. Leaving the baseline alone is what makes the + // erasure happen again instead. + Log.p("invite: the erasure marker could not be persisted; it will be applied " + + "again rather than reported as done", Log.WARNING); + return false; } + state = STATE_NONE_FOUND; + stateLoaded = true; + return true; } // Package private: called from the provider when consent changes. @@ -1950,6 +1965,18 @@ private static void onEdt(Runnable r) { private static void requestMatch(Map pending) { if (!explicitlyAllowed()) { + // Nothing is outstanding after this, and saying so is what lets a + // later grant act immediately. + // + // Under OPT_OUT with no choice on record the referrer read IS + // permitted, so a Play install that comes back empty falls through + // to here -- where the statistical match needs an explicit grant + // and declines. The referrer's own lookupIssuedAt was still set, so + // onConsentChanged() saw a lookup in flight, did not start the + // match the grant had just permitted, and nothing retried it: the + // attribution stayed pending until some unrelated flush, check or + // relaunch happened along. + lookupIssuedAt = 0; return; } bumpAttempts(pending); diff --git a/docs/developer-guide/Analytics.asciidoc b/docs/developer-guide/Analytics.asciidoc index 8954ffa4318..c879d25ddc9 100644 --- a/docs/developer-guide/Analytics.asciidoc +++ b/docs/developer-guide/Analytics.asciidoc @@ -230,7 +230,7 @@ When attribution resolves it's also written as persistent analytics dimensions ( The App Store carries no referrer parameter of its own, so on iOS a deferred install -- one where the friend didn't already have the app -- can only be matched statistically, on a coarse device profile within a short window. That match is occasionally wrong, and `getConfidence()` reports how much to trust it. Report it as an estimate, and don't pay a referral bounty on it without saying so. -`Invites.setAttributionWindow(0)` switches deferred attribution off entirely if you would rather not use it. +`Invites.setAttributionWindow(0)` switches the statistical match off if you would rather not use it. It doesn't turn deferred attribution off altogether: an exact code the device is already holding -- one that came back through the Play install referrer, or arrived on a link -- is still claimed and still reported, because there's nothing to guess about it. What the window governs is the match that needs a window to mean anything. ==== Consent diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 6877aea9e95..f1cae75a37b 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -779,6 +779,40 @@ static List readMediaPermissionNames(boolean blocked, "com/codename1/vpn/tunnel/", }; + /// The invite entry points, for the same library scan. + /// + /// Both of them, because an application can reference either alone: the + /// button without the facade, or the facade without the button. The second + /// is an exact class rather than a package, and matching it as a prefix is + /// the same answer -- a class name starts with itself. + private static final String[] INVITE_LIB_PREFIXES = { + "com/codename1/analytics/invite/", + "com/codename1/components/InviteButton", + }; + + /// Folds invite usage found inside submitted libraries into the scanner's + /// flags. + /// + /// A library that encapsulates invites was invisible to the scan over the + /// application's own classes, so usesInvites stayed false and every part of + /// the Android integration went missing at once: no App Links filter, no + /// onNewIntent splice, the install-referrer package deleted from the + /// generated sources, and the Play Install Referrer dependency never + /// selected. The library compiled against an API nothing had switched on. + /// + /// @param libsDir the submitted-libraries folder + /// @return the prefixes found, for the feature catalog + private java.util.Set foldInInviteLibraryUsage(java.io.File libsDir) { + java.util.Set found = + LibraryClassPrefixScan.prefixesFound(libsDir, INVITE_LIB_PREFIXES); + if (found.isEmpty()) { + return found; + } + debug("Invite usage found inside a submitted library: " + found); + usesInvites = true; + return found; + } + /// Folds call and VPN usage found inside submitted libraries into the /// scanner's flags. /// @@ -2665,6 +2699,14 @@ public void usesClassMethod(String cls, String method) { for (String callVpnPrefix : callVpnFromLibraries) { aiAcc.consume(callVpnPrefix); } + // Invites, for the same two reasons. The flag decides the App Links + // filter, the onNewIntent splice and whether the install-referrer + // package survives; the CATALOG is what adds the Play Install Referrer + // dependency and lifts minSdk to 21. Setting only the flag left the + // referrer sources in the project with nothing to compile them against. + for (String invitePrefix : foldInInviteLibraryUsage(libsDir)) { + aiAcc.consume(invitePrefix); + } NearbyManifestFragments.NearbyUsage libraryNearby = NearbyManifestFragments.scanForNearbyUsage(libsDir); if (!libraryNearby.isEmpty()) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java index b4e1633288c..7752eafe791 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java @@ -139,6 +139,26 @@ void standardLaunchModeIsWarnedAboutRatherThanRefused() throws IOException { "standard launch mode still fails the build"); } + @Test + void inviteUsageInsideAsubmittedLibraryIsFoundToo() throws IOException { + // The scan over the application's own classes cannot see a cn1lib that + // encapsulates invites, so usesInvites stayed false and the whole + // Android integration went missing at once: no App Links filter, no + // onNewIntent splice, the install-referrer package deleted from the + // generated sources, and the Play Install Referrer dependency never + // selected. The library compiled against an API nothing switched on. + String source = source(); + assertTrue(source.contains("INVITE_LIB_PREFIXES"), + "invite prefixes are not scanned inside submitted libraries"); + assertTrue(source.contains("foldInInviteLibraryUsage"), + "the library scan does not fold into usesInvites"); + // Fed to the CATALOG as well as to the flag: the flag decides the + // manifest and the sources, the catalog adds the dependency and the + // API 21 floor. + assertTrue(source.contains("for (String invitePrefix : foldInInviteLibraryUsage(libsDir))"), + "the library prefixes never reach the feature catalog"); + } + @Test void theInviteReferenceOnlyExistsForAppsThatUseInvites() throws IOException { String source = source(); From e629d9ef14bb91eee3028063348bb4d03ac9cbd0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:46:10 +0300 Subject: [PATCH 39/99] Invites: an erasure that is checked, and an answer that can arrive too late Storage.deleteStorageFile reports nothing useful on either port that matters -- Android's Context.deleteFile() and JavaSE's File.delete() both return a boolean and neither throws -- so a delete that failed looked exactly like one that worked. reset() cleared the caches regardless, the tombstone was written, and the provider recorded the new client id as fully erased while the attribution record was still on the disk. It came back on the next launch, so getAttribution() and conversion() reported the old referral identity under the new id and a later consent change restored its dimensions. InviteStore.delete() now re-checks existence rather than trusting the call, and OVERWRITES a record that survives with an empty one -- a delete that cannot happen at least leaves nothing behind to restore. reset() keeps its public signature and resetVerified() carries the answer to the one caller that needs it: an erasure is not reported complete unless the attribution record is verifiably gone, and the provider's baseline only moves when it is. Separately, a fingerprint request issued just before expiresAt can sit in the queue or on the wire past it, and only the CURRENT window was checked -- so a late statistical answer resolved and reported invite_install outside the window the application configured. The request carries no expiry to the server, so the record on this device is the only place that deadline exists; it is read against the answer now. A marker with no expiry at all is left alone, being a record from before the window was written rather than one that has run out. Both have tests, each checked by reverting its fix, and both needed a new delete-failure seam in InviteStore for the same reason the write seam exists: a full or read-only store cannot be produced from a test. --- .../analytics/invite/InviteStore.java | 50 +++++++++++++- .../codename1/analytics/invite/Invites.java | 66 +++++++++++++++++-- .../invite/InviteConsentAndErasureTest.java | 22 +++++++ .../invite/InviteResilienceTest.java | 26 ++++++++ 4 files changed, 156 insertions(+), 8 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java index ec6388a8b6d..4310650d52c 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java @@ -110,6 +110,14 @@ static void failNextWriteForTest(String name) { failNextNamed = name; } + // The same seam for a delete. Storage.deleteStorageFile cannot be made to + // fail from a test either, and the erasure path turns on exactly that. + private static String failNextDeleteNamed; + + static void failNextDeleteForTest(String name) { + failNextDeleteNamed = name; + } + static boolean write(String record, Map values) { if (record != null && record.equals(failNextNamed)) { failNextNamed = null; @@ -127,14 +135,50 @@ static boolean write(String record, Map values) { } } - static void delete(String record) { + /// Deletes a record and says whether it is really gone. + /// + /// `deleteStorageFile` reports nothing useful on either port that matters + /// -- Android's `Context.deleteFile()` and JavaSE's `File.delete()` both + /// return a boolean and neither throws -- so a failed delete looked + /// identical to a successful one. That is load bearing for erasure: the + /// caller went on to report the identity erased while the record was still + /// on the disk, ready to come back on the next launch. + /// + /// Existence is re-checked afterwards rather than trusted, and a record + /// that survives is OVERWRITTEN with an empty one. An empty record carries + /// no code, no inviter and no campaign, so a delete that cannot happen at + /// least leaves nothing behind to restore. + /// + /// - `record`: the record name + /// + /// #### Returns + /// + /// true when nothing readable is left + static boolean delete(String record) { + if (record != null && record.equals(failNextDeleteNamed)) { + failNextDeleteNamed = null; + return false; + } try { Storage s = Storage.getInstance(); - if (s != null && s.exists(record)) { - s.deleteStorageFile(record); + if (s == null) { + return false; } + if (!s.exists(record)) { + return true; + } + s.deleteStorageFile(record); + if (!s.exists(record)) { + return true; + } + if (!s.writeObject(record, new LinkedHashMap())) { + return false; + } + Map left = read(record); + return left == null || left.isEmpty(); } catch (Throwable t) { Log.e(t); + return false; } } diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index b23f1eef57d..7056ec69de1 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -966,10 +966,28 @@ public static void flush() { /// that left the referral dimensions behind would re-link the fresh /// identity to the same inviter. public static void reset() { + resetVerified(); + } + + /// The same work, reporting whether the durable records really went. + /// + /// Package private and separate so `reset()` keeps the signature an + /// application already calls. The answer matters to exactly one caller: + /// an erasure must not be reported complete while the attribution record + /// is still readable, or it comes back on the next launch under the new + /// identity. + /// + /// #### Returns + /// + /// true when nothing readable is left behind + static boolean resetVerified() { lookupEpoch++; - InviteStore.delete(InviteStore.PENDING); + boolean cleared = InviteStore.delete(InviteStore.PENDING); forgetPendingFallback(); - InviteStore.delete(InviteStore.ATTRIBUTION); + // ATTRIBUTION is the one that matters: it names the inviter. The other + // two are a lookup in progress and a queue of registrations, neither of + // which identifies anybody after this. + cleared &= InviteStore.delete(InviteStore.ATTRIBUTION); InviteStore.delete(InviteStore.OUTBOX); Preferences.delete(PREF_CONSUMED_ARG); clearDimensions(); @@ -984,6 +1002,7 @@ public static void reset() { lookupIssuedAt = 0; undelivered = null; unacknowledged.clear(); + return cleared; } // Package private test seam: the epoch an outstanding lookup was issued @@ -1003,7 +1022,23 @@ static void forgetCachedAttributionForTest() { // Package private: the analytics provider hook calls this when the client // id changes underneath us, which is what an erasure request looks like. static boolean eraseInternal() { - reset(); + // The DELETES have to have happened, not just been attempted. + // + // Storage.deleteStorageFile reports nothing useful: Android's + // Context.deleteFile() and JavaSE's File.delete() both return a boolean + // and neither throws, so a delete that failed looked exactly like one + // that worked. reset() cleared the caches regardless, the tombstone was + // written, and the provider recorded the new client id as fully + // erased -- while the old attribution record was still on the disk. It + // came back on the next launch, so getAttribution() and conversion() + // reported the old referral identity under the new id, and a later + // consent change restored its dimensions. + boolean cleared = resetVerified(); + if (!cleared) { + Log.p("invite: the attribution record could not be deleted, so the erasure is " + + "not complete and will be attempted again", Log.WARNING); + return false; + } // A tombstone, so the erasure is not undone by the next ordinary // launch. // @@ -2197,8 +2232,29 @@ static void handleResolution(String payload, String matchType, boolean deferred, // deferred -- the code came back through the store, which is the whole // reason the Android path is the deterministic one -- so the kill // switch dropped the best answer the device will ever have. - if (MATCH_FINGERPRINT.equals(matchType) && attributionWindow == 0) { - return; + if (MATCH_FINGERPRINT.equals(matchType)) { + if (attributionWindow == 0) { + return; + } + // And the window has to still be open when the ANSWER arrives. + // + // A request issued just before expiresAt can sit in the queue or on + // the wire past it, and only the current window was checked -- so a + // late statistical answer resolved and reported invite_install + // outside the window the application configured. The request does + // not carry the expiry to the server either, so the server cannot + // refuse it on our behalf; the record on this device is the only + // place the deadline exists. + // + // Read from the pending record rather than recomputed, because it + // is the deadline this lookup was started under -- and a marker + // with no expiry at all is left alone, since that is a record from + // before the window was written rather than one that has run out. + Map deadline = readPending(); + long expiresAt = InviteStore.getLong(deadline, "expiresAt", 0); + if (expiresAt > 0 && System.currentTimeMillis() > expiresAt) { + return; + } } try { if (payload == null || payload.length() == 0) { diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java index c68e1fe7f27..cf3340e2621 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java @@ -156,6 +156,28 @@ void anerasedInstallDoesNotStartLookingAgainByItself() { "a direct invite could not reopen attribution after an erasure"); } + @FormTest + void anerasureIsNotReportedDoneWhileTheAttributionSurvives() { + // Storage.deleteStorageFile reports nothing useful: Android's + // Context.deleteFile() and JavaSE's File.delete() both return a boolean + // and neither throws, so a delete that failed looked exactly like one + // that worked. The caches were cleared regardless, the tombstone was + // written, and the provider recorded the new client id as fully + // erased -- while the attribution record was still on the disk, ready + // to come back on the next launch and report the old referral identity + // under the new id. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.handleResolution( + InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + Invites.MATCH_REFERRER, true); + assertNotNull(Invites.getAttribution()); + + InviteStore.failNextDeleteForTest(InviteStore.ATTRIBUTION); + assertFalse(Invites.eraseInternal(), + "an erasure reported success while the attribution record survived"); + } + @FormTest void registeringTheProviderIsNotMistakenForAnErasure() { InviteTestSupport.freshInstall(); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 570339f3c10..ee07bfda0fb 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -1344,6 +1344,32 @@ void thekillSwitchStillLetsAnInstallReferrerClaimLand() { assertEquals("REF9", a.getCode()); } + @Test + @EdtTest + void afingerprintAnswerThatArrivesAfterTheWindowIsRefused() { + // A request issued just before expiresAt can sit in the queue or on the + // wire past it, and only the CURRENT window was checked -- so a late + // statistical answer resolved and reported invite_install outside the + // window the application configured. The request carries no expiry to + // the server either, so the record on this device is the only place + // that deadline exists. + Invites.checkForInvite(); + int inFlight = Invites.currentLookupEpochForTest(); + + // The window closes while the answer is on the wire. + Map pending = InviteStore.read(InviteStore.PENDING); + assertNotNull(pending); + pending.put("expiresAt", String.valueOf(System.currentTimeMillis() - 1000L)); + assertTrue(InviteStore.write(InviteStore.PENDING, pending)); + Invites.forgetLoadedState(); + + Invites.handleResolution(InviteTestSupport.resolvedJson("LATE2", "c1", "sms"), + Invites.MATCH_FINGERPRINT, true, inFlight); + + assertNull(Invites.getAttribution(), + "a statistical answer landed after the attribution window closed"); + } + @Test @EdtTest void turningOnReattributionLetsTheStateBeReadAgain() { From 3330a860ea7fcb22fe4c3caa5af2a281baff14d3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:13:52 +0300 Subject: [PATCH 40/99] Invites: the erasure has to take the outbox, and a refusal has to settle resetVerified() gated on the attribution record and ignored the outbox. The outbox holds the queued registration JSON, and that carries the OLD client id along with the campaign, payload and preview -- so if the store rejected deleting AND overwriting it, the erasure still reported success, the provider advanced its baseline, and the next drainOutbox() sent a pre-erasure registration under the new identity as soon as storage recovered. Both identifying records are gated now; the pending record is a lookup in progress and identifies nobody, so it is not. And refusing a late fingerprint answer left the lookup stranded. The ordinary flow makes one asynchronous request with no timer behind it, so returning without terminalising left the install STATE_PENDING for ever and the listener owed an answer it would never get -- unless the application happened to call flush() or checkForInvite() itself. It is settled with REASON_EXPIRED now, and a replacement is abandoned instead, for the reason abandonReplacement() already gives: the earlier attribution still stands, and telling a listener "no invite" about an install it has already been told about is a contradiction rather than an answer. Both were introduced by the fixes in the two commits before them, and both have tests checked by reverting them. --- .../codename1/analytics/invite/Invites.java | 37 ++++++++++++++++--- .../invite/InviteConsentAndErasureTest.java | 18 +++++++++ .../invite/InviteResilienceTest.java | 9 +++++ 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 7056ec69de1..adba5fa4fb7 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -982,13 +982,23 @@ public static void reset() { /// true when nothing readable is left behind static boolean resetVerified() { lookupEpoch++; - boolean cleared = InviteStore.delete(InviteStore.PENDING); + InviteStore.delete(InviteStore.PENDING); forgetPendingFallback(); - // ATTRIBUTION is the one that matters: it names the inviter. The other - // two are a lookup in progress and a queue of registrations, neither of - // which identifies anybody after this. + boolean cleared = true; + // ATTRIBUTION names the inviter, and the OUTBOX is the queued + // registration JSON -- which carries the OLD client id along with the + // campaign, payload and preview. Both have to actually go. + // + // Ignoring the outbox result was a hole the size of the whole erasure: + // if the store rejected deleting and overwriting it, the erasure still + // reported success and the provider advanced its baseline, and the next + // drainOutbox() transmitted a pre-erasure registration under the new + // identity once storage recovered. + // + // The pending record is a lookup in progress and identifies nobody + // after this, so it is deleted without gating on it. cleared &= InviteStore.delete(InviteStore.ATTRIBUTION); - InviteStore.delete(InviteStore.OUTBOX); + cleared &= InviteStore.delete(InviteStore.OUTBOX); Preferences.delete(PREF_CONSUMED_ARG); clearDimensions(); resolved = null; @@ -2253,6 +2263,23 @@ static void handleResolution(String payload, String matchType, boolean deferred, Map deadline = readPending(); long expiresAt = InviteStore.getLong(deadline, "expiresAt", 0); if (expiresAt > 0 && System.currentTimeMillis() > expiresAt) { + // SETTLED, not just refused. + // + // The ordinary flow makes this one asynchronous request and has + // no timer behind it, so returning here left the install + // STATE_PENDING for ever: the window had closed, the answer had + // been thrown away, and nothing would ask again unless the + // application happened to call flush() or checkForInvite() + // itself. The listener was owed an answer and never got one. + // + // A replacement is abandoned rather than settled, for the + // reason abandonReplacement() gives: the earlier attribution + // still stands, and telling a listener "no invite" about an + // install it has already been told about is a contradiction + // rather than an answer. + if (!abandonReplacement() && markTerminal(REASON_EXPIRED)) { + notifyUnavailable(REASON_EXPIRED); + } return; } } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java index cf3340e2621..327d2bfa249 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java @@ -178,6 +178,24 @@ void anerasureIsNotReportedDoneWhileTheAttributionSurvives() { "an erasure reported success while the attribution record survived"); } + @FormTest + void anerasureIsNotReportedDoneWhileTheOutboxSurvives() { + // The outbox holds the queued registration JSON, and that carries the + // OLD client id along with the campaign, payload and preview. Ignoring + // its delete result was a hole the size of the whole erasure: the + // erasure reported success, the provider advanced its baseline, and the + // next drainOutbox() transmitted a pre-erasure registration under the + // new identity as soon as storage recovered. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.create(InviteRequest.create().campaign("launch").build()); + assertFalse(InviteStore.readOutbox().isEmpty(), "the fixture queued nothing"); + + InviteStore.failNextDeleteForTest(InviteStore.OUTBOX); + assertFalse(Invites.eraseInternal(), + "an erasure reported success while the queued registration survived"); + } + @FormTest void registeringTheProviderIsNotMistakenForAnErasure() { InviteTestSupport.freshInstall(); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index ee07bfda0fb..d13bc3e0f97 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -1368,6 +1368,15 @@ void afingerprintAnswerThatArrivesAfterTheWindowIsRefused() { assertNull(Invites.getAttribution(), "a statistical answer landed after the attribution window closed"); + // And the lookup is SETTLED, not left hanging. The ordinary flow makes + // one asynchronous request and has no timer behind it, so refusing the + // answer without terminalising left the install pending for ever and + // the listener owed an answer it would never get. + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState(), + "refusing a late answer left the lookup pending for ever"); + assertEquals(Invites.REASON_EXPIRED, + InviteStore.get(InviteStore.read(InviteStore.PENDING), "reason", null), + "the settled lookup does not say why"); } @Test From 25dffdd496b2fc539594bda0a3c9972c460b917c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:39:34 +0300 Subject: [PATCH 41/99] Invites: App Clips replace the statistical match on iOS The App Store carries no referrer of its own, so an iOS install deferred through it could only be guessed at: a coarse device profile written to local storage on first launch, posted to the server, and matched against a hashed fingerprint of somebody's address inside an hour-long window. It was occasionally wrong, it could not say which times, and it required collecting something from people who installed nothing and consented to nothing. An App Clip is launched BY the invite link and receives that link exactly, so it can hand the code to the app the person then installs. The answer is a fact, and everything that existed to make the guess is gone with it. Client: - AppClipHandoffSource / AppClipHandoffCallback, the iOS counterpart of InstallReferrerSource and registered the same way by the build. - requestMatch() becomes requestAppClipHandoff(); the code it returns is claimed with source "app_clip", exactly as a referrer code is. - MATCH_FINGERPRINT becomes MATCH_APP_CLIP, and every match type is now exact -- so the response guard that refused a statistical answer past the kill switch or the window has nothing left to key on and goes. The window still governs where a lookup STARTS. - The device profile is not captured at all any more: no platform, OS version, hardware model, locale or screen size, in storage or on the wire. explicitlyAllowed() went with it, the strict grant having been about transmitting that profile. - No clip source, or a clip with nothing, settles the install as NO_MATCH -- a real and permanent answer -- rather than UNSUPPORTED, which is the reopenable marker the kill switch writes and would have every launch ask again for something that can never be there. Builder: the invite host declares appclips: as well as applinks:. applinks: opens an app that is already installed; appclips: is what lets iOS offer the clip to somebody who does not have it, which is the whole iOS path. Declaring only the first leaves that person on a Safari page. Tests: the three cases that existed for the statistical match are gone, the referrer fallback test now asserts the clip is asked and that nothing is posted, and two new cases cover "the clip had nothing" and "there is no clip on this platform". InviteTestSupport registers a clip source that never answers, which is how "a lookup is outstanding" is still expressible now that no network request is involved. --- .../invite/AppClipHandoffCallback.java | 47 ++++ .../invite/AppClipHandoffSource.java | 65 +++++ .../codename1/analytics/invite/Invites.java | 255 ++++++++---------- docs/developer-guide/Analytics.asciidoc | 12 +- .../com/codename1/builders/IPhoneBuilder.java | 20 +- .../builders/InviteAssociatedDomainTest.java | 28 ++ .../analytics/invite/InviteDeliveryTest.java | 91 ++++++- .../invite/InviteResilienceTest.java | 114 +------- .../analytics/invite/InviteTestSupport.java | 50 ++++ 9 files changed, 420 insertions(+), 262 deletions(-) create mode 100644 CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffCallback.java create mode 100644 CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffSource.java diff --git a/CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffCallback.java b/CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffCallback.java new file mode 100644 index 00000000000..31cd572d9b0 --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffCallback.java @@ -0,0 +1,47 @@ +/* + * 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.analytics.invite; + +/// Receives the answer to [AppClipHandoffSource#requestHandoff]. +/// +/// Exactly one method is called, once. +public interface AppClipHandoffCallback { + /// Called with the invite code an App Clip recorded. + /// + /// #### Parameters + /// + /// - `code`: the invite code the clip received, never empty + /// + /// - `clickedSeconds`: when the link was tapped, in seconds since the + /// epoch, or 0 when the clip did not record it + void onHandoff(String code, long clickedSeconds); + + /// Called when no clip handoff exists. This is the normal answer for + /// somebody who installed the application without ever tapping an invite + /// link, and is not an error. + /// + /// #### Parameters + /// + /// - `reason`: one of the `REASON_` constants on [Invites] + void onUnavailable(String reason); +} diff --git a/CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffSource.java b/CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffSource.java new file mode 100644 index 00000000000..8db4e0f1e5a --- /dev/null +++ b/CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffSource.java @@ -0,0 +1,65 @@ +/* + * 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.analytics.invite; + +/// Reads the invite code an iOS App Clip left behind for the full +/// application. +/// +/// This is the iOS half of deterministic attribution, and the counterpart of +/// [InstallReferrerSource] on Android. An App Clip is launched by the invite +/// link itself and receives that link exactly, so it can write the code into +/// the container it shares with the full application before offering the App +/// Store. When the person installs, the application reads it here: the code +/// made the whole trip through the store, so nothing is matched or guessed. +/// +/// It replaced a statistical match against a hashed device profile, which +/// existed only because the App Store carries no referrer of its own. Nothing +/// about the visitor is collected any more. +/// +/// The Codename One build supplies the implementation on platforms that have +/// one and registers it through [Invites#registerAppClipHandoffSource] before +/// the application starts. Where none is registered -- the simulator, the +/// desktop build, Android, and any iOS application built without an App Clip +/// -- [Invites] behaves exactly as it does when a clip left nothing. +/// +/// An application does not implement this interface. +public interface AppClipHandoffSource { + /// Whether this source can answer at all on the current device. + /// + /// #### Returns + /// + /// true when a shared container is reachable + boolean isSupported(); + + /// Asks for the code an App Clip left behind. The answer arrives on the + /// callback, possibly asynchronously and possibly on another thread; + /// [Invites] marshals it back onto the EDT. + /// + /// The handoff is read once and cleared by the implementation, so a code + /// cannot be claimed twice by two launches. + /// + /// #### Parameters + /// + /// - `callback`: receives the answer, never null + void requestHandoff(AppClipHandoffCallback callback); +} diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index adba5fa4fb7..0f6fe711f21 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -42,7 +42,6 @@ import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; -import java.util.Locale; import java.util.Map; /// Invite a friend, and follow the invitation through to what it caused. @@ -103,12 +102,18 @@ /// /// ### How exact the answer is /// -/// [InviteAttribution#getMatchType] says how the attribution was made. -/// [#MATCH_DIRECT] and [#MATCH_REFERRER] are exact. [#MATCH_FINGERPRINT] is a -/// statistical match made on the server, used where the platform's store -/// carries no referrer, and it is occasionally wrong -- check -/// [InviteAttribution#getConfidence] and do not pay a referral bounty on it -/// without saying so. +/// [InviteAttribution#getMatchType] says how the attribution was made, and +/// every one of them is exact. [#MATCH_DIRECT] is a link opening an +/// application that was already installed; [#MATCH_REFERRER] is a code that +/// made the whole trip through the Play store; [#MATCH_APP_CLIP] is a code an +/// iOS App Clip received from the link itself and handed to the application it +/// installed. +/// +/// There used to be a statistical match here as well, because the App Store +/// carries no referrer of its own and an iOS install could only be guessed at. +/// It was occasionally wrong, it could not say which times, and it required +/// collecting a hashed profile of people who installed nothing. App Clips made +/// it unnecessary and it is gone. public final class Invites { /// Nothing has been attributed and nothing is outstanding. public static final int STATE_NONE = 0; @@ -132,9 +137,9 @@ public final class Invites { /// came back verbatim. Exact. public static final String MATCH_REFERRER = "referrer"; - /// The server matched this install to a click statistically, because the - /// platform's store carries no referrer. Not exact. - public static final String MATCH_FINGERPRINT = "fingerprint"; + /// An iOS App Clip received the invite link, kept the code, and handed it + /// to the application the person then installed. Exact. + public static final String MATCH_APP_CLIP = "app_clip"; /// No invite matched. The ordinary outcome for an uninvited install. public static final String REASON_NO_MATCH = "no_match"; @@ -215,6 +220,11 @@ public final class Invites { private static boolean reattribution; private static InviteListener listener; private static InstallReferrerSource referrerSource; + + // The iOS counterpart: the code an App Clip left in the container it shares + // with this application. Registered by the build the same way, and absent + // on every platform that has no clip. + private static AppClipHandoffSource appClipSource; private static InviteAttribution resolved; private static boolean attributionLoaded; private static int state = STATE_NONE; @@ -290,6 +300,17 @@ public static void registerInstallReferrerSource(InstallReferrerSource source) { referrerSource = source; } + /// Registers the platform hook that reads the invite code an iOS App Clip + /// left for this application. The Codename One build calls this before the + /// application starts on platforms that have one; an application does not. + /// + /// #### Parameters + /// + /// - `source`: the platform source, or null to remove it + public static void registerAppClipHandoffSource(AppClipHandoffSource source) { + appClipSource = source; + } + // ---- sending --------------------------------------------------------- /// Mints an invite and returns it immediately. @@ -1226,11 +1247,6 @@ private static boolean explicitlyDenied() { return c != null && !c.isAnalytics(); } - private static boolean explicitlyAllowed() { - AnalyticsConsent c = Analytics.getConsent(); - return c != null && c.isAnalytics(); - } - private static String newCode() { byte[] raw = new byte[16]; try { @@ -1663,37 +1679,10 @@ private static Map pendingRecord() { pending.put("expiresAt", String.valueOf(now + attributionWindow)); pending.put("attempts", "0"); pending.put("state", String.valueOf(STATE_PENDING)); - captureProfile(pending); writePending(pending); return pending; } - /// Writes the coarse device profile the deferred lookup is matched on. - /// - /// Separate from `pendingRecord()` because it is needed twice. A terminal - /// marker deliberately carries none of it -- a refused profile is deleted, - /// which is the promise the consent path makes -- so a marker that is - /// later reopened has to capture it again rather than restore it. Sending - /// the empty strings and zero dimensions the terminal marker really does - /// hold left the server with the network and the country and nothing else, - /// which scores below the threshold: a consent grant inside the original - /// window could not recover the invite it was granted for. - /// - /// - `record`: the pending record to fill in - private static void captureProfile(Map record) { - Display d = Display.getInstance(); - if (d != null) { - InviteStore.put(record, "platform", d.getPlatformName()); - InviteStore.put(record, "osVersion", d.getProperty("OSVer", "")); - InviteStore.put(record, "deviceModel", - d.getProperty("DeviceHardwareModel", d.getProperty("DeviceName", ""))); - record.put("screenWidth", String.valueOf(d.getDisplayWidth())); - record.put("screenHeight", String.valueOf(d.getDisplayHeight())); - } - Locale loc = Locale.getDefault(); - InviteStore.put(record, "locale", loc == null ? "" : loc.toString()); - } - private static void beginDeferred() { if (deferredStarted) { return; @@ -1743,19 +1732,6 @@ private static void beginDeferred() { marker.put("expiresAt", String.valueOf(began + attributionWindow)); } marker.remove("reason"); - // And the device profile is CAPTURED AGAIN, not restored. - // - // markTerminal() carries the timing, the delivery flag and the - // direct-link code and nothing that describes the device -- - // deliberately, because a refusal deletes the fingerprint. So - // the marker being converted here holds none of it, and the - // resumed requestMatch() sent empty strings and zero screen - // dimensions: the server had the network and the country to - // score on, which is not enough to match, so granting consent - // inside the original window recovered nothing. Recapturing - // costs five property reads and is the same profile the first - // launch would have taken. - captureProfile(marker); writePending(marker); state = STATE_PENDING; stateLoaded = true; @@ -1850,7 +1826,7 @@ private static void beginDeferred() { requestReferrer(source); return; } - requestMatch(pending); + requestAppClipHandoff(pending); } private static boolean safeSupported(InstallReferrerSource source) { @@ -1992,7 +1968,7 @@ private static void fallBackToMatchImpl() { if (pending == null) { return; } - requestMatch(pending); + requestAppClipHandoff(pending); } private static void onEdt(Runnable r) { @@ -2008,32 +1984,76 @@ private static void onEdt(Runnable r) { } } - private static void requestMatch(Map pending) { - if (!explicitlyAllowed()) { - // Nothing is outstanding after this, and saying so is what lets a - // later grant act immediately. + /// Asks the platform whether an App Clip left a code behind. + /// + /// This replaced a statistical match against a hashed device profile. That + /// existed only because the App Store carries no referrer of its own, so an + /// install deferred through it could only be guessed at -- from a coarse + /// profile, a network prefix and an hour-long window, sometimes wrong and + /// never able to say so. A clip is launched BY the invite link and receives + /// it exactly, so the answer is a fact and the guess is gone, along with + /// everything that was collected to make it. + /// + /// No consent gate beyond the ordinary one. The strict grant the match + /// needed was for transmitting a device fingerprint; there is no + /// fingerprint now, and the code this reads is one the person produced + /// themselves by tapping an invite. + /// + /// - `pending`: the pending record, for the attempt budget + private static void requestAppClipHandoff(final Map pending) { + final AppClipHandoffSource source = appClipSource; + if (source == null || !source.isSupported()) { + // No clip on this platform or this build, which is the ordinary + // case: Android answered through the install referrer before + // reaching here, and the desktop and the simulator have neither. // - // Under OPT_OUT with no choice on record the referrer read IS - // permitted, so a Play install that comes back empty falls through - // to here -- where the statistical match needs an explicit grant - // and declines. The referrer's own lookupIssuedAt was still set, so - // onConsentChanged() saw a lookup in flight, did not start the - // match the grant had just permitted, and nothing retried it: the - // attribution stayed pending until some unrelated flush, check or - // relaunch happened along. + // NO_MATCH rather than UNSUPPORTED. This install was not invited -- + // that is a real answer about it, and a permanent one. UNSUPPORTED + // is the reopenable marker the kill switch writes, so reporting it + // here would have every launch reopen a lookup that can never have + // anything to find. lookupIssuedAt = 0; + settleNoHandoff(REASON_NO_MATCH); return; } bumpAttempts(pending); - Map body = identity(); - body.put("platform", InviteStore.get(pending, "platform", "")); - body.put("osVersion", InviteStore.get(pending, "osVersion", "")); - body.put("deviceModel", InviteStore.get(pending, "deviceModel", "")); - body.put("locale", InviteStore.get(pending, "locale", "")); - body.put("screenWidth", Integer.valueOf(InviteStore.getInt(pending, "screenWidth", 0))); - body.put("screenHeight", Integer.valueOf(InviteStore.getInt(pending, "screenHeight", 0))); lookupIssuedAt = System.currentTimeMillis(); - post(getLinkBase() + PATH_MATCH, body, MATCH_FINGERPRINT, true); + source.requestHandoff(new AppClipHandoffCallback() { + public void onHandoff(final String code, final long clickedSeconds) { + onEdt(new Runnable() { + public void run() { + lookupIssuedAt = 0; + if (code == null || code.length() == 0) { + settleNoHandoff(REASON_NO_MATCH); + return; + } + // Claimed exactly as a referrer code is: the trip + // through the store is what makes both of them exact, + // and the server treats them the same way. + claim(code, "app_clip", "", MATCH_APP_CLIP, true); + } + }); + } + + public void onUnavailable(final String reason) { + onEdt(new Runnable() { + public void run() { + lookupIssuedAt = 0; + settleNoHandoff(reason == null ? REASON_NO_MATCH : reason); + } + }); + } + }); + } + + /// Settles an install no clip left anything for, which is most of them. + private static void settleNoHandoff(String reason) { + if (abandonReplacement()) { + return; + } + if (markTerminal(reason)) { + notifyUnavailable(reason); + } } private static void claim(String code, String source, String rawReferrer, @@ -2221,68 +2241,21 @@ static void handleResolution(String payload, String matchType, boolean deferred, if (epoch != lookupEpoch || !allowed()) { return; } - // And the kill switch is read HERE, not only where the lookup starts. + // There is no kill-switch guard on the answer any more, because every + // answer is exact. // - // setAttributionWindow(0) turns off deferred attribution, but a - // statistical request queued a moment earlier is already on the wire - // and carries the epoch it was issued with -- so its answer used to - // land, persist and report an attribution the application had just - // switched off. The window is checked against the answer rather than - // against the request. + // It refused a statistical match that setAttributionWindow(0) had + // switched off, and a statistical match that arrived after the window + // closed. Both were about a guess: a coarse profile matched on the + // server, which could be wrong and could be stale. A referrer code and + // an App Clip code are facts that made the trip through the store, and + // a fact arriving late is still the right answer -- which is why the + // guard had to be keyed on the match type rather than on `deferred` in + // the first place, and why it has nothing left to key on now. // - // Only the STATISTICAL answer. The switch turns off the fingerprint - // lookup, not an exact code the device is holding: hasSavedCode() - // exempts one where the lookup begins, and cancelling an exact claim - // here would break the same exemption from the other end. That is also - // why this is not an epoch bump -- the epoch is global and would - // discard the exact claim with it. - // - // Keyed on the match type rather than on `deferred`, which was the - // first spelling and was wrong: an install-referrer claim is exact AND - // deferred -- the code came back through the store, which is the whole - // reason the Android path is the deterministic one -- so the kill - // switch dropped the best answer the device will ever have. - if (MATCH_FINGERPRINT.equals(matchType)) { - if (attributionWindow == 0) { - return; - } - // And the window has to still be open when the ANSWER arrives. - // - // A request issued just before expiresAt can sit in the queue or on - // the wire past it, and only the current window was checked -- so a - // late statistical answer resolved and reported invite_install - // outside the window the application configured. The request does - // not carry the expiry to the server either, so the server cannot - // refuse it on our behalf; the record on this device is the only - // place the deadline exists. - // - // Read from the pending record rather than recomputed, because it - // is the deadline this lookup was started under -- and a marker - // with no expiry at all is left alone, since that is a record from - // before the window was written rather than one that has run out. - Map deadline = readPending(); - long expiresAt = InviteStore.getLong(deadline, "expiresAt", 0); - if (expiresAt > 0 && System.currentTimeMillis() > expiresAt) { - // SETTLED, not just refused. - // - // The ordinary flow makes this one asynchronous request and has - // no timer behind it, so returning here left the install - // STATE_PENDING for ever: the window had closed, the answer had - // been thrown away, and nothing would ask again unless the - // application happened to call flush() or checkForInvite() - // itself. The listener was owed an answer and never got one. - // - // A replacement is abandoned rather than settled, for the - // reason abandonReplacement() gives: the earlier attribution - // still stands, and telling a listener "no invite" about an - // install it has already been told about is a contradiction - // rather than an answer. - if (!abandonReplacement() && markTerminal(REASON_EXPIRED)) { - notifyUnavailable(REASON_EXPIRED); - } - return; - } - } + // The window still governs where the lookup STARTS: beginDeferred() + // refuses to begin one past the deadline, and hasSavedCode() exempts a + // code already in hand. try { if (payload == null || payload.length() == 0) { return; @@ -2359,10 +2332,12 @@ static void handleResolution(String payload, String matchType, boolean deferred, if (rawScore instanceof Number) { double s = ((Number) rawScore).doubleValue(); score = s > 1d ? s / 100d : s; - } else if (MATCH_FINGERPRINT.equals(matchType)) { - score = 0d; } - if (MATCH_DIRECT.equals(matchType) || MATCH_REFERRER.equals(matchType)) { + // Every match type is exact now, so the score is one whatever the + // server said. It survives because InviteAttribution advertises it + // and an application may read it; it no longer varies. + if (MATCH_DIRECT.equals(matchType) || MATCH_REFERRER.equals(matchType) + || MATCH_APP_CLIP.equals(matchType)) { score = 1d; } Map params = new LinkedHashMap(); diff --git a/docs/developer-guide/Analytics.asciidoc b/docs/developer-guide/Analytics.asciidoc index c879d25ddc9..e9515de9a01 100644 --- a/docs/developer-guide/Analytics.asciidoc +++ b/docs/developer-guide/Analytics.asciidoc @@ -224,19 +224,19 @@ When attribution resolves it's also written as persistent analytics dimensions ( | `MATCH_REFERRER` | The invite code travelled through the app store and came back verbatim. Exact. This is the Android path. -| `MATCH_FINGERPRINT` -| The server matched this install to an earlier click statistically. Not exact. +| `MATCH_APP_CLIP` +| An iOS App Clip received the invite link and handed the code to the app the person then installed. Exact. This is the iOS path. |=== -The App Store carries no referrer parameter of its own, so on iOS a deferred install -- one where the friend didn't already have the app -- can only be matched statistically, on a coarse device profile within a short window. That match is occasionally wrong, and `getConfidence()` reports how much to trust it. Report it as an estimate, and don't pay a referral bounty on it without saying so. +Every match type is exact. The App Store carries no referrer parameter of its own, so on iOS the code travels a different road than it does on Android: tapping an invite link offers an App Clip, the clip is launched by the link itself and so receives the code exactly, and it leaves that code where the full app can read it after installation. Nothing is matched, estimated or guessed, and a referral bounty can be paid on any of these. -`Invites.setAttributionWindow(0)` switches the statistical match off if you would rather not use it. It doesn't turn deferred attribution off altogether: an exact code the device is already holding -- one that came back through the Play install referrer, or arrived on a link -- is still claimed and still reported, because there's nothing to guess about it. What the window governs is the match that needs a window to mean anything. +`Invites.setAttributionWindow(0)` stops a deferred lookup being started at all -- neither the Play install referrer nor the App Clip handoff is read. An exact code the device is already holding, one that arrived on a link, is still claimed: there is nothing to defer about it. ==== Consent -Everything reported here is gated on the analytics consent category, and nothing is transmitted until consent is granted. The statistical match additionally requires an explicit grant: opt-out mode alone isn't enough, because it reports permission with no user choice on record. +Everything reported here is gated on the analytics consent category, and nothing is transmitted until consent is granted. -One thing does happen before consent. On first launch a coarse device profile -- OS version, hardware model, language, screen size -- is written to local storage so a deferred match is still possible if consent arrives in time. It's never transmitted while consent is withheld, and it's deleted outright if consent is refused. There's no alternative that also works: the window in which a deferred match can be made closes long before a typical consent prompt is answered. +Nothing at all is collected about someone who only taps a link. An earlier design wrote a coarse device profile to local storage on first launch -- OS version, hardware model, language, screen size -- so an iOS install could be matched to a click, and the server kept a hashed fingerprint of the visitor's address for seven days to match it against. App Clips made all that unnecessary: the clip is handed the code by the link, so there is nothing to match and nothing to keep. `Analytics.resetClientId()` erases the invite attribution along with the identity, so an erasure request can't leave a fresh pseudonymous id linked to the same inviter. diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 643916b5aa6..4b792c452a7 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4421,14 +4421,26 @@ public void usesClassMethod(String cls, String method) { if (usesInvites && "true".equals(request.getArg("ios.invite.universalLinks", "true"))) { String inviteHost = request.getArg("invite.domain", "cloud.codenameone.com"); - String want = "applinks:" + inviteHost; String existingDomains = request.getArg("ios.associatedDomains", ""); - if (!declaresAssociatedDomain(existingDomains, want)) { - String merged = existingDomains.trim().length() == 0 + // TWO prefixes on the same host, and they do different jobs. + // + // applinks: is what opens an INSTALLED app from the link. + // appclips: is what lets iOS offer the App Clip to somebody who + // does not have the app -- which is the whole iOS attribution + // path now, since the clip receives the invite url exactly and + // hands the code to the app the person then installs. Declaring + // only applinks: leaves that person with a Safari page and no + // way to attribute the install that follows. + String[] wanted = {"applinks:" + inviteHost, "appclips:" + inviteHost}; + for (String want : wanted) { + if (declaresAssociatedDomain(existingDomains, want)) { + continue; + } + existingDomains = existingDomains.trim().length() == 0 ? want : existingDomains + "," + want; debug("Invite attribution: adding the associated domain " + want); - request.putArgument("ios.associatedDomains", merged); } + request.putArgument("ios.associatedDomains", existingDomains); } if (request.getArg("ios.associatedDomains", null) != null) { diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteAssociatedDomainTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteAssociatedDomainTest.java index a42d1e2e567..2f5b4583b21 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteAssociatedDomainTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteAssociatedDomainTest.java @@ -82,4 +82,32 @@ void emptyAndNullAreHandled() { assertFalse(IPhoneBuilder.declaresAssociatedDomain(null, WANT)); assertFalse(IPhoneBuilder.declaresAssociatedDomain(WANT, null)); } + + @Test + void bothPrefixesAreDeclaredForTheInviteHost() { + // applinks: opens an INSTALLED app from the link. appclips: is what + // lets iOS offer the App Clip to somebody who does not have the app -- + // and since the clip receives the invite url exactly and hands the code + // to the app the person then installs, that IS the iOS attribution + // path. Declaring only applinks: leaves that person on a Safari page + // with nothing to attribute the install that follows. + String source = builderSource(); + int at = source.indexOf("String[] wanted = {\"applinks:\" + inviteHost"); + assertTrue(at > 0, "the invite host no longer declares both prefixes"); + assertTrue(source.indexOf("\"appclips:\" + inviteHost", at) > at, + "appclips: is not declared, so iOS cannot offer the App Clip"); + } + + /** The builder source, read the way the other codegen tests read it. */ + private static String builderSource() { + try { + java.io.File f = new java.io.File( + "src/main/java/com/codename1/builders/IPhoneBuilder.java"); + assertTrue(f.isFile(), "the builder must be readable: " + f.getAbsolutePath()); + return new String(java.nio.file.Files.readAllBytes(f.toPath()), + java.nio.charset.StandardCharsets.UTF_8); + } catch (java.io.IOException e) { + throw new IllegalStateException(e); + } + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java index 46977368ea6..bd2e1d868aa 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java @@ -145,7 +145,16 @@ public void requestReferrer(InstallReferrerCallback callback) { } @FormTest - void noStoreReferrerFallsBackToTheStatisticalMatch() { + void noStoreReferrerFallsBackToTheAppClipHandoff() { + // The Android store answered "no referral", so the deferred lookup asks + // the other exact source: the code an iOS App Clip left in the + // container it shares with this application. + // + // It used to fall back to a statistical match -- a coarse device + // profile posted to the server, matched against a hashed click within + // an hour. Nothing is posted now and nothing about the device is read, + // which is why this asserts on the SOURCE rather than on a request + // body: there is no request. InviteTestSupport.freshInstall(); implementation.clearQueuedRequests(); implementation.setAutoProcessConnections(false); @@ -161,19 +170,81 @@ public void requestReferrer(InstallReferrerCallback callback) { Invites.checkForInvite(); - boolean sawMatch = false; + assertTrue(InviteTestSupport.pendingHandoff.wasAsked(), + "the referrer came back empty and nothing asked the App Clip"); for (int i = 0; i < implementation.getQueuedRequests().size(); i++) { - if (implementation.getQueuedRequests().get(i).getUrl().endsWith("/invites/match")) { - sawMatch = true; + assertTrue(!implementation.getQueuedRequests().get(i).getUrl().endsWith("/match"), + "a statistical match was still posted to the server"); + } + + // And the code the clip hands over is claimed exactly, like a referrer. + InviteTestSupport.pendingHandoff.answer("CLIP123"); + boolean sawClaim = false; + for (int i = 0; i < implementation.getQueuedRequests().size(); i++) { + if (implementation.getQueuedRequests().get(i).getUrl().endsWith("/invites/claim")) { String body = implementation.getQueuedRequests().get(i).getRequestBody(); - // The server reads the address off the socket; the client must - // never try to enumerate it. - assertTrue(!body.contains("\"ip\""), body); - assertTrue(body.contains("osVersion"), body); - assertTrue(body.contains("deviceModel"), body); + if (body != null && body.contains("CLIP123")) { + sawClaim = true; + assertTrue(body.contains("app_clip"), body); + // Nothing about the device goes with it. + assertTrue(!body.contains("osVersion"), body); + assertTrue(!body.contains("deviceModel"), body); + } } } - assertTrue(sawMatch, "expected the statistical match as the fallback"); + assertTrue(sawClaim, "the App Clip's code was never claimed"); + } + + @FormTest + void anInstallWithNoClipHandoffIsNotInvited() { + // The overwhelmingly common case: somebody installed the application + // without ever tapping an invite. The clip answers that it has nothing, + // and that is a real and permanent answer about this install -- not + // "unsupported", which is the reopenable marker the kill switch writes + // and would have every launch ask again for something that can never be + // there. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + final String[] told = new String[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0] = reason; + } + }); + + Invites.checkForInvite(); + InviteTestSupport.pendingHandoff.answerNothing(Invites.REASON_NO_MATCH); + + assertEquals(Invites.REASON_NO_MATCH, told[0]); + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState()); + } + + @FormTest + void aplatformWithNoAppClipSettlesRatherThanWaiting() { + // No clip source at all -- the desktop, the simulator, an iOS build + // without a clip, or Android once the referrer has already answered. + // Settling immediately is what keeps the listener's contract: exactly + // one answer per install, and this install's answer is "no invite". + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.registerAppClipHandoffSource(null); + final int[] told = new int[1]; + Invites.setInviteListener(new InviteListener() { + public void inviteReceived(InviteAttribution a) { + } + + public void attributionUnavailable(String reason) { + told[0]++; + } + }); + + Invites.checkForInvite(); + + assertEquals(1, told[0], "a platform with no App Clip left the listener waiting"); + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState()); } @FormTest diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index d13bc3e0f97..f5ecc49ebb2 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -118,7 +118,7 @@ void aNoMatchAnswerIsNotAskedAgainOnTheNextLaunch() { // queried again, and an ordinary uninvited install kept contacting the // server for ever. Invites.checkForInvite(); - Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_FINGERPRINT, true); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_APP_CLIP, true); assertEquals(Invites.STATE_NONE_FOUND, Invites.getState()); Invites.forgetLoadedState(); @@ -132,7 +132,7 @@ void theTerminalMarkerKeepsNoDeviceProfile() { // It is durable and it is empty: the profile existed to be matched, // and there is nothing left to match it against. Invites.checkForInvite(); - Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_FINGERPRINT, true); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_APP_CLIP, true); Map marker = InviteStore.read(InviteStore.PENDING); assertNotNull(marker, "the answer has to be durable"); assertTrue(marker.containsKey("state")); @@ -256,7 +256,7 @@ void aNoMatchDoesNotSettleTheInstallWhileAReferrerRetryIsOutstanding() { pending.put("referrerRetry", "true"); InviteStore.write(InviteStore.PENDING, pending); - Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_FINGERPRINT, true); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_APP_CLIP, true); Invites.forgetLoadedState(); assertEquals(Invites.STATE_PENDING, Invites.getState(), @@ -296,7 +296,7 @@ void aDirectLinkSupersedesADeferredLookupAlreadyOnTheWire() { // The deferred answer arrives late, under the epoch it was issued in. Invites.handleResolution(InviteTestSupport.resolvedJson("GUESS", "c2", "unknown"), - Invites.MATCH_FINGERPRINT, true, deferredEpoch); + Invites.MATCH_APP_CLIP, true, deferredEpoch); InviteAttribution a = Invites.getAttribution(); assertNotNull(a); @@ -328,7 +328,7 @@ public void attributionUnavailable(String reason) { told[0]++; } }); - Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_FINGERPRINT, true); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_APP_CLIP, true); assertEquals(0, told[0], "a pending outcome used the terminal callback"); // And the exact answer that arrives afterwards is still deliverable. @@ -359,7 +359,7 @@ public void requestReferrer(InstallReferrerCallback callback) { }); Invites.reset(); Invites.checkForInvite(); - Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_FINGERPRINT, true); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_APP_CLIP, true); Invites.forgetLoadedState(); assertEquals(Invites.STATE_NONE_FOUND, Invites.getState(), @@ -405,7 +405,7 @@ void flushSupersedesWhateverTheLastAttemptLeftOutstanding() { Invites.MATCH_REFERRER, true); Invites.handleResolution(InviteTestSupport.resolvedJson("GUESS", "c2", "unknown"), - Invites.MATCH_FINGERPRINT, true, stale); + Invites.MATCH_APP_CLIP, true, stale); InviteAttribution a = Invites.getAttribution(); assertNotNull(a); @@ -758,7 +758,7 @@ void aRefusalHeldForALateListenerIsDiscardedWhenTheLookupResumes() { Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); Analytics.setConsent(AnalyticsConsent.granted()); Invites.handleResolution(InviteTestSupport.resolvedJson("RESOLVED1", "c1", "sms"), - Invites.MATCH_FINGERPRINT, true); + Invites.MATCH_APP_CLIP, true); final String[] unavailable = new String[1]; final InviteAttribution[] received = new InviteAttribution[1]; @@ -1021,7 +1021,7 @@ public void attributionUnavailable(String reason) { Analytics.setConsent(AnalyticsConsent.granted()); Invites.handleResolution(InviteTestSupport.resolvedJson("LATER3", "c1", "sms"), - Invites.MATCH_FINGERPRINT, true); + Invites.MATCH_APP_CLIP, true); Invites.forgetLoadedState(); Invites.setInviteListener(null); @@ -1159,7 +1159,7 @@ public void requestReferrer(InstallReferrerCallback callback) { } }); Invites.checkForInvite(); - Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_FINGERPRINT, true); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_APP_CLIP, true); Invites.forgetLoadedState(); assertEquals(Invites.STATE_NONE_FOUND, Invites.getState(), @@ -1218,41 +1218,6 @@ void reopeningAfterConsentKeepsTheOriginalWindow() { "granting consent restarted the attribution window"); } - @Test - @EdtTest - void reopeningAfterConsentCapturesTheDeviceProfileAgain() { - // The other half of the same reopen. markTerminal() carries the timing, - // the delivery flag and the direct-link code and nothing that describes - // the device -- deliberately, because a refusal deletes the - // fingerprint. So the marker converted back to pending held empty - // strings and zero screen dimensions, and the resumed match sent the - // server the network and the country to score on and nothing else, - // which is below the threshold. Granting consent inside the original - // window could not recover the invite it was granted for. - Invites.checkForInvite(); - Map first = InviteStore.read(InviteStore.PENDING); - assertNotNull(first); - String platform = InviteStore.get(first, "platform", ""); - assertTrue(platform.length() > 0, "the first launch captured no platform"); - - Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); - Map denied = InviteStore.read(InviteStore.PENDING); - assertNotNull(denied); - assertEquals("", InviteStore.get(denied, "platform", ""), - "the refused marker kept a device profile it promised to delete"); - - Analytics.setConsent(AnalyticsConsent.granted()); - - Map resumed = InviteStore.read(InviteStore.PENDING); - assertNotNull(resumed); - assertEquals(platform, InviteStore.get(resumed, "platform", ""), - "the reopened lookup carries no platform, so it cannot match"); - assertTrue(InviteStore.getLong(resumed, "screenWidth", 0) > 0, - "the reopened lookup carries no screen dimensions"); - assertTrue(InviteStore.get(resumed, "locale", "").length() > 0, - "the reopened lookup carries no locale"); - } - @Test @EdtTest void theZeroWindowDoesNotDiscardAnExactCodeWeAreHolding() { @@ -1281,26 +1246,6 @@ public void attributionUnavailable(String reason) { assertEquals(Invites.STATE_PENDING, Invites.getState()); } - @Test - @EdtTest - void thekillSwitchAlsoRefusesAmatchAlreadyOnTheWire() { - // setAttributionWindow(0) changed only the value future calls read. A - // statistical request queued a moment earlier carries the epoch it was - // issued with, so its answer still landed, persisted and reported an - // attribution the application had just switched off. - Invites.checkForInvite(); - assertEquals(Invites.STATE_PENDING, Invites.getState()); - int inFlight = Invites.currentLookupEpochForTest(); - - Invites.setAttributionWindow(0); - - Invites.handleResolution(InviteTestSupport.resolvedJson("LATE1", "c1", "sms"), - Invites.MATCH_FINGERPRINT, true, inFlight); - - assertNull(Invites.getAttribution(), - "a statistical answer landed after the kill switch was thrown"); - } - @Test @EdtTest void thekillSwitchStillLetsAnExactAnswerLand() { @@ -1344,41 +1289,6 @@ void thekillSwitchStillLetsAnInstallReferrerClaimLand() { assertEquals("REF9", a.getCode()); } - @Test - @EdtTest - void afingerprintAnswerThatArrivesAfterTheWindowIsRefused() { - // A request issued just before expiresAt can sit in the queue or on the - // wire past it, and only the CURRENT window was checked -- so a late - // statistical answer resolved and reported invite_install outside the - // window the application configured. The request carries no expiry to - // the server either, so the record on this device is the only place - // that deadline exists. - Invites.checkForInvite(); - int inFlight = Invites.currentLookupEpochForTest(); - - // The window closes while the answer is on the wire. - Map pending = InviteStore.read(InviteStore.PENDING); - assertNotNull(pending); - pending.put("expiresAt", String.valueOf(System.currentTimeMillis() - 1000L)); - assertTrue(InviteStore.write(InviteStore.PENDING, pending)); - Invites.forgetLoadedState(); - - Invites.handleResolution(InviteTestSupport.resolvedJson("LATE2", "c1", "sms"), - Invites.MATCH_FINGERPRINT, true, inFlight); - - assertNull(Invites.getAttribution(), - "a statistical answer landed after the attribution window closed"); - // And the lookup is SETTLED, not left hanging. The ordinary flow makes - // one asynchronous request and has no timer behind it, so refusing the - // answer without terminalising left the install pending for ever and - // the listener owed an answer it would never get. - assertEquals(Invites.STATE_NONE_FOUND, Invites.getState(), - "refusing a late answer left the lookup pending for ever"); - assertEquals(Invites.REASON_EXPIRED, - InviteStore.get(InviteStore.read(InviteStore.PENDING), "reason", null), - "the settled lookup does not say why"); - } - @Test @EdtTest void turningOnReattributionLetsTheStateBeReadAgain() { @@ -1451,7 +1361,7 @@ public void attributionUnavailable(String reason) { assertEquals(1, told[0], "the refusal was not delivered, so this proves nothing"); Analytics.setConsent(AnalyticsConsent.granted()); - Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_FINGERPRINT, true); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_APP_CLIP, true); Invites.forgetLoadedState(); Invites.setInviteListener(null); @@ -1467,7 +1377,7 @@ void aDirectLinkDiscardsAHeldAnswerThatIsNoLongerTrue() { // resolved the stale unavailable result -- with deliveredThisRun then // suppressing the correct one. Invites.checkForInvite(); - Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_FINGERPRINT, true); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_APP_CLIP, true); assertEquals(Invites.STATE_NONE_FOUND, Invites.getState()); Invites.handleUrl("https://cloud.codenameone.com/i/acme/LATER6"); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java index ca6510d232b..aeb105dcb44 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java @@ -37,6 +37,48 @@ final class InviteTestSupport { private InviteTestSupport() { } + /** The source freshInstall() leaves registered; holds its callback. */ + static PendingHandoffSource pendingHandoff; + + /** + * An App Clip source that is supported and never answers on its own, so a + * test can decide when -- and whether -- the handoff arrives. + */ + static final class PendingHandoffSource implements AppClipHandoffSource { + private AppClipHandoffCallback callback; + + public boolean isSupported() { + return true; + } + + public void requestHandoff(AppClipHandoffCallback cb) { + callback = cb; + } + + /** True once Invites has asked. */ + boolean wasAsked() { + return callback != null; + } + + /** Delivers a code, as a clip that saw the link would. */ + void answer(String code) { + AppClipHandoffCallback cb = callback; + callback = null; + if (cb != null) { + cb.onHandoff(code, 0L); + } + } + + /** Answers that no clip left anything, which is the common case. */ + void answerNothing(String reason) { + AppClipHandoffCallback cb = callback; + callback = null; + if (cb != null) { + cb.onUnavailable(reason); + } + } + } + static RecordingProvider freshInstall() { clearAppArg(); Analytics.clearProviders(); @@ -48,6 +90,14 @@ static RecordingProvider freshInstall() { Invites.setReattribution(false); Invites.setAttributionWindow(Invites.DEFAULT_ATTRIBUTION_WINDOW); Invites.registerInstallReferrerSource(null); + // A clip source that is present and never answers, which is the state + // the old statistical match left behind: a lookup outstanding with its + // response still to come. Without one, every deferred lookup settles + // the instant it starts -- correct on a device with no App Clip, and + // useless for testing anything that happens while one is in flight. + // A case that wants an answer installs its own. + pendingHandoff = new PendingHandoffSource(); + Invites.registerAppClipHandoffSource(pendingHandoff); Invites.lookupRetryDelay = 30000L; Invites.reset(); Preferences.delete(Invites.PREF_SLUG); From 018119b5c5f09a51b0133cff662491ac71d89b03 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:05:13 +0300 Subject: [PATCH 42/99] Invites: five holes the App Clip path and the erasure left open The pending record is gated by the erasure after all. It looks like bookkeeping -- a state, a deadline, an attempt count -- but it also carries the code a direct link left on the device, and a code names an inviter. A surviving one re-links the new identity to the old invite on the next launch, which is the thing being erased. A failed erasure now blocks the drain. Reporting the failure was not enough on its own: the queued entries carry the OLD client id, so the next flush would transmit exactly what the erasure was asked to prevent as soon as storage recovered -- an erasure that ends by sending the erased identity to the server. flush() retries the erasure first and drains nothing until it succeeds. The App Clip callback checks the epoch it was issued under. The read is asynchronous and everything that supersedes a lookup bumps that epoch, so a code read before a direct link arrived could overwrite the newer exact claim, and the unavailable branch could settle a lookup the answer no longer belonged to. And the clip's code is written down before it is sent. The claim is one fail-silent request, a fresh install is exactly when the device is most likely to be offline, and the clip has already cleared its own copy by the time it answers -- so a code that lived only in the callback was gone for good the moment that request failed. handleUrl() persists a direct code first for the same reason. Also: the compilable developer-guide snippet still named MATCH_FINGERPRINT, which failed the demos build. The first stale-callback test passed without its fix, because an erasure deletes the records the callback would have written to. It is retargeted at the direct-link race, which is the case where the records survive and the epoch is the only thing standing between them. --- .../codename1/analytics/invite/Invites.java | 61 +++++++++++++++++-- .../generated/AnalyticsJava011Snippet.java | 6 +- .../invite/InviteConsentAndErasureTest.java | 24 ++++++++ .../analytics/invite/InviteDeliveryTest.java | 50 +++++++++++++++ 4 files changed, 134 insertions(+), 7 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 0f6fe711f21..57f06cda573 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -225,6 +225,11 @@ public final class Invites { // with this application. Registered by the build the same way, and absent // on every platform that has no clip. private static AppClipHandoffSource appClipSource; + + // Set when an erasure could not remove the durable records, and cleared + // when a later attempt does. Nothing that transmits may run while it is + // set: the records still on the disk describe the identity being erased. + private static boolean erasurePending; private static InviteAttribution resolved; private static boolean attributionLoaded; private static int state = STATE_NONE; @@ -1003,9 +1008,8 @@ public static void reset() { /// true when nothing readable is left behind static boolean resetVerified() { lookupEpoch++; - InviteStore.delete(InviteStore.PENDING); + boolean cleared = InviteStore.delete(InviteStore.PENDING); forgetPendingFallback(); - boolean cleared = true; // ATTRIBUTION names the inviter, and the OUTBOX is the queued // registration JSON -- which carries the OLD client id along with the // campaign, payload and preview. Both have to actually go. @@ -1016,8 +1020,11 @@ static boolean resetVerified() { // drainOutbox() transmitted a pre-erasure registration under the new // identity once storage recovered. // - // The pending record is a lookup in progress and identifies nobody - // after this, so it is deleted without gating on it. + // The PENDING record is gated too. It looks like bookkeeping -- a + // state, a deadline, an attempt count -- but it also carries the code + // a direct link left on the device, and a code names an inviter. A + // surviving one re-links the new identity to the old invite on the + // next launch, which is the thing being erased. cleared &= InviteStore.delete(InviteStore.ATTRIBUTION); cleared &= InviteStore.delete(InviteStore.OUTBOX); Preferences.delete(PREF_CONSUMED_ARG); @@ -1068,6 +1075,7 @@ static boolean eraseInternal() { if (!cleared) { Log.p("invite: the attribution record could not be deleted, so the erasure is " + "not complete and will be attempted again", Log.WARNING); + erasurePending = true; return false; } // A tombstone, so the erasure is not undone by the next ordinary @@ -1110,10 +1118,12 @@ static boolean eraseInternal() { // erasure happen again instead. Log.p("invite: the erasure marker could not be persisted; it will be applied " + "again rather than reported as done", Log.WARNING); + erasurePending = true; return false; } state = STATE_NONE_FOUND; stateLoaded = true; + erasurePending = false; return true; } @@ -2018,15 +2028,45 @@ private static void requestAppClipHandoff(final Map pending) { } bumpAttempts(pending); lookupIssuedAt = System.currentTimeMillis(); + // The epoch this read was issued under, checked when it answers. + // + // The read is asynchronous, and everything that supersedes a lookup + // bumps the epoch: an erasure, a consent withdrawal, a direct link + // arriving while this was outstanding. Without the check a clip code + // read before an erasure could restore the attribution it removed, or + // overwrite the newer exact claim that superseded it -- and the + // unavailable branch could settle a lookup that is no longer the one + // this answer belongs to. + final int issued = lookupEpoch; source.requestHandoff(new AppClipHandoffCallback() { public void onHandoff(final String code, final long clickedSeconds) { onEdt(new Runnable() { public void run() { + if (issued != lookupEpoch) { + return; + } lookupIssuedAt = 0; if (code == null || code.length() == 0) { settleNoHandoff(REASON_NO_MATCH); return; } + // WRITTEN DOWN before it is sent. + // + // The claim is one fail-silent request. If it does not + // land -- offline first launch, which is exactly when a + // fresh install happens -- the code existed only in + // this callback, the clip had already cleared its own + // copy, and the invite was gone for good. Persisting it + // first is what makes the retry possible, and it is + // what handleUrl() does with a direct code for the same + // reason. + Map record = pendingRecord(); + InviteStore.put(record, "code", code); + record.put("codeSource", "app_clip"); + record.put("codeMatch", MATCH_APP_CLIP); + record.put("codeDeferred", "true"); + record.put("codeReferrer", ""); + writePending(record); // Claimed exactly as a referrer code is: the trip // through the store is what makes both of them exact, // and the server treats them the same way. @@ -2038,6 +2078,9 @@ public void run() { public void onUnavailable(final String reason) { onEdt(new Runnable() { public void run() { + if (issued != lookupEpoch) { + return; + } lookupIssuedAt = 0; settleNoHandoff(reason == null ? REASON_NO_MATCH : reason); } @@ -2665,6 +2708,16 @@ private static void drainOutbox() { if (!allowed()) { return; } + if (erasurePending) { + // An erasure could not delete the queue, and these entries carry + // the OLD client id along with the campaign, payload and preview. + // Sending them once storage recovers is exactly the transmission + // the erasure was asked to prevent, so the erasure is retried and + // nothing is drained until it succeeds. + if (!eraseInternal()) { + return; + } + } List outbox = InviteStore.readOutbox(); if (outbox.isEmpty()) { return; diff --git a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/AnalyticsJava011Snippet.java b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/AnalyticsJava011Snippet.java index 16858b6c064..8f24fc14dca 100644 --- a/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/AnalyticsJava011Snippet.java +++ b/docs/demos/common/src/main/java/com/codenameone/developerguide/snippets/generated/AnalyticsJava011Snippet.java @@ -63,9 +63,9 @@ void snippet() { Invites.setInviteListener(new InviteListener() { public void inviteReceived(InviteAttribution attribution) { // attribution.getCampaign(), getCode(), getPayload() - if (Invites.MATCH_FINGERPRINT.equals(attribution.getMatchType())) { - // A statistical match. Credit it, but do not pay a bounty - // on it without saying so. + if (Invites.MATCH_APP_CLIP.equals(attribution.getMatchType())) { + // An iOS App Clip received the link and handed the code + // over. Exact, like every other match type. } } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java index 327d2bfa249..c356efd8c6d 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java @@ -196,6 +196,30 @@ void anerasureIsNotReportedDoneWhileTheOutboxSurvives() { "an erasure reported success while the queued registration survived"); } + @FormTest + void asurvivingOutboxIsNotDrainedUntilTheErasureFinishes() { + // Reporting the failure was not enough on its own. The entries carry + // the OLD client id, so the next flush would transmit exactly what the + // erasure was asked to prevent as soon as storage recovered -- an + // erasure that ends by sending the erased identity to the server. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.create(InviteRequest.create().campaign("launch").build()); + assertFalse(InviteStore.readOutbox().isEmpty(), "the fixture queued nothing"); + + InviteStore.failNextDeleteForTest(InviteStore.OUTBOX); + assertFalse(Invites.eraseInternal(), "the fixture's erasure did not fail"); + + implementation.clearQueuedRequests(); + Invites.flush(); + + assertEquals(0, implementation.getQueuedRequests().size(), + "a pre-erasure registration was transmitted after the erasure failed"); + // And the retry inside flush() finished the job, so the queue is gone. + assertTrue(InviteStore.readOutbox().isEmpty(), + "the erasure was never retried"); + } + @FormTest void registeringTheProviderIsNotMistakenForAnErasure() { InviteTestSupport.freshInstall(); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java index bd2e1d868aa..9798c800862 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java @@ -22,6 +22,9 @@ */ package com.codename1.analytics.invite; +import com.codename1.analytics.Analytics; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.assertNull; import com.codename1.junit.FormTest; import com.codename1.junit.UITestBase; import java.util.ArrayList; @@ -247,6 +250,53 @@ public void attributionUnavailable(String reason) { assertEquals(Invites.STATE_NONE_FOUND, Invites.getState()); } + @FormTest + void aclipCodeIsWrittenDownBeforeItIsSent() { + // The claim is one fail-silent request, and a fresh install is exactly + // when the device is most likely to be offline. The clip has already + // cleared its own copy by the time it answers, so a code that lived + // only in the callback was gone for good the moment that request + // failed. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + + Invites.checkForInvite(); + InviteTestSupport.pendingHandoff.answer("CLIPSAVE"); + + Map record = InviteStore.read(InviteStore.PENDING); + assertNotNull(record); + assertEquals("CLIPSAVE", InviteStore.get(record, "code", null), + "the clip's code was never written down, so a failed claim loses it"); + assertEquals(Invites.MATCH_APP_CLIP, InviteStore.get(record, "codeMatch", null), + "the saved code lost its provenance"); + } + + @FormTest + void aclipAnswerThatOutlivedItsLookupIsIgnored() { + // The read is asynchronous and everything that supersedes a lookup + // bumps the epoch. A direct link arriving while the clip read is + // outstanding is the case that shows it: the link is an exact answer + // about THIS install, and a clip code read before it must not overwrite + // the record it just wrote. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + + Invites.checkForInvite(); + assertTrue(InviteTestSupport.pendingHandoff.wasAsked()); + + // A link is tapped while the clip read is still outstanding. + assertTrue(Invites.handleUrl("https://cloud.codenameone.com/i/acme/DIRECTWINS")); + assertEquals("DIRECTWINS", + InviteStore.get(InviteStore.read(InviteStore.PENDING), "code", null)); + + // The clip finally answers, with something else. + InviteTestSupport.pendingHandoff.answer("STALECLIP"); + + assertEquals("DIRECTWINS", + InviteStore.get(InviteStore.read(InviteStore.PENDING), "code", null), + "a clip answer from before the link overwrote the newer exact claim"); + } + @FormTest void aSecondLinkDoesNotRewriteTheFirstTouchCohort() { InviteTestSupport.freshInstall(); From 3ef1b8c0dd6e40360bd9acd8435087a3d5675bba Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 14:44:33 +0300 Subject: [PATCH 43/99] Invites: the App Clip is generated now, not just entitled The iOS half of this feature was an interface with nothing behind it. AppClipHandoffSource was declared, Invites asked it for the code, and no implementation existed and nothing registered one -- so appClipSource stayed null and every iOS install settled as no_match. The associated domain said the app could be offered a clip; there was no clip. Four pieces, and the order they fail in is why each exists: - InviteAppClipBuilder emits the clip: main.m, a UIKit delegate, Info.plist and entitlements. Not a Codename One application -- a clip is capped at 15 MB and must launch instantly, and its whole job finishes before anybody reads the screen. - IPhoneBuilder creates the target. An App Clip is an application bundle with its own product type, so the ruby makes an application and assigns the type afterwards; :app_extension produces a binary Apple rejects at upload. It embeds into AppClips/, not PlugIns/ -- the wrong folder signs, uploads and never launches. - IOSAppClipHandoff and CN1InviteAppClip.m read what the clip left in the shared app group and clear it in the same step, so two launches cannot claim one code. The stub registers it under exactly the condition that produced the clip, as a direct symbol reference: obfuscation renames the class and Class.forName would answer nothing. - The app group is resolved BEFORE the stub is written, because the stub needs it as a literal, and the target generation reads the same field. Deriving it twice let the clip's entitlement and the app's registration disagree, which is a clip that stores a code nothing reads. Every one of these is silent when wrong. Nothing links the two binaries, so the defaults key and its two field names are duplicated between the generator and the reader and are asserted in InviteAppClipBuilderTest. Four review findings, all real: - A failed erasure gated only drainOutbox(). A surviving PENDING record still carried its code, so the next checkForInvite() reloaded it and claimed it under the NEW client id -- the transmission the erasure existed to prevent, made by its own aftermath. settleErasure() now gates the lookup, a tapped link and the enqueue. - create() appended to an outbox that survived an erasure. The retry inside the next drain -- which create() triggers itself through flush() -- deleted the queue whole, the fresh invite with it, and it had reported success so nothing held its code. - Emptying the outbox called deleteStorageFile() and returned success without looking. Shares the verified delete now. - The Play referrer never answered when the service disconnected before setup finished: no callback ever ran, the lookup stayed outstanding and nothing retried until the next cold launch. It reports transient now, once, without burning the once-only flag. --- .../analytics/invite/InviteStore.java | 44 +- .../codename1/analytics/invite/Invites.java | 74 ++- .../referrer/AndroidInstallReferrer.java | 64 ++- .../iOSPort/nativeSources/CN1InviteAppClip.m | 124 +++++ .../CodenameOne_GLViewController.h | 6 + .../codename1/impl/ios/IOSAppClipHandoff.java | 114 +++++ .../src/com/codename1/impl/ios/IOSNative.java | 20 + docs/developer-guide/Analytics.asciidoc | 13 +- .../build/shared/BuildHintsDynamic.java | 5 + .../codename1/build/shared/BuildHintsIos.java | 33 ++ .../com/codename1/builders/IPhoneBuilder.java | 213 ++++++++ .../codename1/util/InviteAppClipBuilder.java | 473 ++++++++++++++++++ .../util/InviteAppClipBuilderTest.java | 178 +++++++ .../invite/InviteConsentAndErasureTest.java | 78 +++ 14 files changed, 1414 insertions(+), 25 deletions(-) create mode 100644 Ports/iOSPort/nativeSources/CN1InviteAppClip.m create mode 100644 Ports/iOSPort/src/com/codename1/impl/ios/IOSAppClipHandoff.java create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/util/InviteAppClipBuilder.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/util/InviteAppClipBuilderTest.java diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java index 4310650d52c..5b9a474b612 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java @@ -155,6 +155,23 @@ static boolean write(String record, Map values) { /// /// true when nothing readable is left static boolean delete(String record) { + return deleteVerified(record, new LinkedHashMap()); + } + + /// The delete above, with the shape of the empty replacement left to the + /// caller. + /// + /// Shared with the outbox, which is a `List` and not a `Map`. Writing the + /// wrong one would not be caught by anything -- `readOutbox()` answers an + /// empty queue either way -- but it is what the next writer appends to. + /// + /// - `record`: the record name + /// - `empty`: what to leave behind when the delete cannot happen + /// + /// #### Returns + /// + /// true when nothing readable is left + private static boolean deleteVerified(String record, Object empty) { if (record != null && record.equals(failNextDeleteNamed)) { failNextDeleteNamed = null; return false; @@ -171,11 +188,19 @@ static boolean delete(String record) { if (!s.exists(record)) { return true; } - if (!s.writeObject(record, new LinkedHashMap())) { + if (!s.writeObject(record, empty)) { return false; } - Map left = read(record); - return left == null || left.isEmpty(); + Object left = s.readObject(record); + if (left instanceof Map) { + return ((Map) left).isEmpty(); + } + if (left instanceof List) { + return ((List) left).isEmpty(); + } + // Neither shape came back, so nothing readable is left -- which is + // the question, and is why this is not an error. + return true; } catch (Throwable t) { Log.e(t); return false; @@ -252,10 +277,15 @@ static boolean writeOutbox(List entries) { return false; } if (copy.isEmpty()) { - if (s.exists(OUTBOX)) { - s.deleteStorageFile(OUTBOX); - } - return true; + // Verified, exactly as delete() is, and for the same reason: + // deleteStorageFile() reports nothing useful on either port + // that matters, so a failed delete looked identical to a + // successful one. This is the path that empties the queue when + // the LAST registration is acknowledged, and reporting success + // over a surviving file meant every later flush resent an + // acknowledged registration while isRegistered() went on + // answering false about it. + return deleteVerified(OUTBOX, new ArrayList()); } return s.writeObject(OUTBOX, copy); } catch (Throwable t) { diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 57f06cda573..cdc69113d36 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -586,6 +586,14 @@ public static boolean handleUrl(String url) { return false; } ensureProvider(); + // A tapped link is a fresh answer and would ordinarily reopen + // attribution, but not while an erasure is still owed: claiming writes + // a record the failing store cannot erase either, and the claim itself + // carries the surviving old state. The retry usually succeeds, because + // what stopped it was transient. + if (!settleErasure()) { + return false; + } // The same guard beginDeferred() has. checkForInvite() treats a // consumed URL as handled and skips beginDeferred entirely, so without // this a refused user who opened an invite link still had a profile @@ -1127,6 +1135,32 @@ static boolean eraseInternal() { return true; } + /// Retries an erasure that could not finish, and says whether anything + /// else may proceed. + /// + /// `eraseInternal()` sets `erasurePending` when a delete or the tombstone + /// write failed, and what survives on the disk is exactly what the erasure + /// was asked to remove: a code, which names an inviter, and a queued + /// registration carrying the OLD client id. + /// + /// This gate lived only in `drainOutbox()`, which left two ways past it. + /// A lookup read the surviving code and claimed it under the NEW identity, + /// which is the transmission the erasure existed to prevent. And `create()` + /// appended to the surviving queue, after which the retry inside the very + /// next drain deleted the whole queue -- the freshly minted invite with + /// it, reported as enqueued and therefore not held in `unacknowledged`. + /// + /// A retry that fails again means storage is unusable, and the honest + /// answer there is to do nothing rather than write more records that + /// cannot be erased either. + /// + /// #### Returns + /// + /// true when no erasure is outstanding + private static boolean settleErasure() { + return !erasurePending || eraseInternal(); + } + // Package private: called from the provider when consent changes. static void onConsentChanged(boolean allowed) { if (allowed) { @@ -1697,6 +1731,13 @@ private static void beginDeferred() { if (deferredStarted) { return; } + // Before anything is read off the disk. A failed erasure leaves the + // PENDING record there with the code it carried, and the lookup below + // would reload that code and claim it under the new client id -- the + // one thing the erasure was asked to make impossible. + if (!settleErasure()) { + return; + } int s = getState(); // Two terminal markers can stop being true, and both carry the reason // that made them. A window of zero is the documented kill switch and an @@ -2699,6 +2740,23 @@ private static boolean queueRegistration(Invite invite, InviteRequest request) { body.put("parameters", new LinkedHashMap(request.getParameters())); } pendingRegistration = JSONParser.mapToJson(body); + // Settled before the queue is touched, and reported as a failed + // enqueue when it cannot be. + // + // An outbox that survived an erasure is deleted WHOLE by the retry + // inside the next drain -- which create() itself triggers through + // flush() -- so an entry appended to it goes with it. It had reported + // success, so nothing held its code in `unacknowledged` and + // isRegistered() answered true about a registration the server was + // guaranteed never to have seen; its campaign, channel and preview + // were gone for good. + // + // The caller's existing failure path is the right answer here: it + // sends this one registration now if consent permits, and otherwise + // remembers the code as unacknowledged. Neither touches the queue. + if (!settleErasure()) { + return false; + } List outbox = InviteStore.readOutbox(); outbox.add(pendingRegistration); return InviteStore.writeOutbox(outbox); @@ -2708,15 +2766,13 @@ private static void drainOutbox() { if (!allowed()) { return; } - if (erasurePending) { - // An erasure could not delete the queue, and these entries carry - // the OLD client id along with the campaign, payload and preview. - // Sending them once storage recovers is exactly the transmission - // the erasure was asked to prevent, so the erasure is retried and - // nothing is drained until it succeeds. - if (!eraseInternal()) { - return; - } + // An erasure could not delete the queue, and these entries carry the + // OLD client id along with the campaign, payload and preview. Sending + // them once storage recovers is exactly the transmission the erasure + // was asked to prevent, so the erasure is retried and nothing is + // drained until it succeeds. + if (!settleErasure()) { + return; } List outbox = InviteStore.readOutbox(); if (outbox.isEmpty()) { diff --git a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java index 798405f07f9..a8a437c0cd4 100644 --- a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java +++ b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java @@ -51,6 +51,12 @@ public class AndroidInstallReferrer implements InstallReferrerSource { private boolean retried; + // Whether the framework has been given its one answer. The SPI promises + // exactly one call, and the disconnect handler added below can arrive + // after a real answer as easily as instead of one -- ending a connection + // is itself what fires it. + private boolean answered; + @Override public boolean isSupported() { return AndroidNativeUtil.getContext() != null @@ -61,7 +67,7 @@ public boolean isSupported() { public void requestReferrer(InstallReferrerCallback callback) { Context context = AndroidNativeUtil.getContext(); if (context == null) { - callback.onUnavailable(Invites.REASON_UNSUPPORTED); + unavailable(callback, Invites.REASON_UNSUPPORTED); return; } try { @@ -77,6 +83,10 @@ public void requestReferrer(InstallReferrerCallback callback) { private void connect(final InstallReferrerClient client, final InstallReferrerCallback callback) { + // Per ATTEMPT, not per instance. The retry below ends this connection, + // which fires this listener's own disconnect -- and that must not be + // read as the retried connection failing. + final boolean[] superseded = new boolean[1]; client.startConnection(new InstallReferrerStateListener() { @Override public void onInstallReferrerSetupFinished(int responseCode) { @@ -91,6 +101,7 @@ public void onInstallReferrerSetupFinished(int responseCode) { // never going to answer. if (!retried) { retried = true; + superseded[0] = true; close(client); requestReferrer(callback); return; @@ -102,7 +113,7 @@ public void onInstallReferrerSetupFinished(int responseCode) { // skip the deterministic path and fall back to a // statistical guess for a referrer we could have // read exactly. - callback.onUnavailable(Invites.REASON_NO_MATCH); + unavailable(callback, Invites.REASON_NO_MATCH); break; default: // FEATURE_NOT_SUPPORTED is the ordinary answer on a @@ -118,7 +129,7 @@ public void onInstallReferrerSetupFinished(int responseCode) { // Unknown failure: treated as transient, so a later flush // can still read a referrer that is genuinely there. Log.e(t); - callback.onUnavailable(Invites.REASON_NO_MATCH); + unavailable(callback, Invites.REASON_NO_MATCH); } finally { close(client); } @@ -126,9 +137,24 @@ public void onInstallReferrerSetupFinished(int responseCode) { @Override public void onInstallReferrerServiceDisconnected() { - // Deliberately not reconnecting. The one retry above is the - // whole allowance; an automatic reconnect here is how a + // Still deliberately not reconnecting. The one retry above is + // the whole allowance; an automatic reconnect here is how a // background service bind loop starts. + // + // But the exchange has to END, and this was the one path that + // left it open. A service that drops before + // onInstallReferrerSetupFinished() ever runs answered nothing, + // so Invites kept its lookup outstanding and its deferred flag + // set: the application's listener was never told anything, and + // nothing retried until the next cold launch. + // + // Reported as transient, which is what it is -- the once-only + // flag stays unburnt, so a later flush can still read a + // referrer that was there the whole time. + if (superseded[0]) { + return; + } + unavailable(callback, Invites.REASON_NO_MATCH); } }); } @@ -160,22 +186,44 @@ private void deliver(InstallReferrerClient client, InstallReferrerCallback callb // statistical no-match settles the install as organic -- for a // referrer that was there all along and simply could not be read // this once. - callback.onUnavailable(Invites.REASON_NO_MATCH); + unavailable(callback, Invites.REASON_NO_MATCH); return; } Preferences.set(PREF_ATTEMPTED, true); if (referrer == null || referrer.length() == 0) { - callback.onUnavailable(Invites.REASON_NO_MATCH); + unavailable(callback, Invites.REASON_NO_MATCH); return; } - callback.onReferrer(referrer, clickSeconds, beginSeconds); + referrer(callback, referrer, clickSeconds, beginSeconds); } private void finish(InstallReferrerCallback callback, String reason) { Preferences.set(PREF_ATTEMPTED, true); + unavailable(callback, reason); + } + + /// Reports "no referral", at most once. + /// + /// Every terminal path goes through here so the disconnect handler can + /// close an exchange nobody else closed without risking a second answer + /// for one that somebody did. + private void unavailable(InstallReferrerCallback callback, String reason) { + if (answered) { + return; + } + answered = true; callback.onUnavailable(reason); } + private void referrer(InstallReferrerCallback callback, String value, + long clickSeconds, long beginSeconds) { + if (answered) { + return; + } + answered = true; + callback.onReferrer(value, clickSeconds, beginSeconds); + } + private void close(InstallReferrerClient client) { try { client.endConnection(); diff --git a/Ports/iOSPort/nativeSources/CN1InviteAppClip.m b/Ports/iOSPort/nativeSources/CN1InviteAppClip.m new file mode 100644 index 00000000000..04d040793e0 --- /dev/null +++ b/Ports/iOSPort/nativeSources/CN1InviteAppClip.m @@ -0,0 +1,124 @@ +/* + * 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. + */ + +// Native implementation of IOSNative.isAppClipHandoffSupported(String) and +// .consumeAppClipInviteHandoff(String), which back +// com.codename1.impl.ios.IOSAppClipHandoff. +// +// The App Clip half of the same exchange is generated by IPhoneBuilder into +// the clip's own target (InviteAppClipBuilder); the two agree on the app group, the +// defaults key and the two field names, and on nothing else. Keep them in +// step: the clip is a separate binary that ships inside the application, so a +// mismatch here is not a compile error anywhere -- the code is simply never +// found and every iOS install reads as organic. + +#include "xmlvm.h" +#ifndef NEW_CODENAME_ONE_VM +#include "xmlvm-util.h" +#endif +#import "CodenameOne_GLViewController.h" + +#ifdef CN1_INCLUDE_INVITE_APPCLIP + +#import + +#ifdef NEW_CODENAME_ONE_VM +extern JAVA_OBJECT fromNSString(CODENAME_ONE_THREAD_STATE, NSString* str); +extern NSString* toNSString(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT str); +#else +extern JAVA_OBJECT fromNSString(NSString* str); +extern NSString* toNSString(JAVA_OBJECT str); +#endif + +// The one key the clip writes and the application consumes. A dictionary +// rather than a bare string so the click time rides along: the clip is the +// only process that ever knew it, and it is gone by the time the application +// exists. +static NSString * const kCN1InviteHandoffKey = @"cn1-invite-app-clip-handoff"; +static NSString * const kCN1InviteCodeField = @"code"; +static NSString * const kCN1InviteClickedField = @"clicked"; + +// Opened fresh on each call and autoreleased rather than cached in a static. +// The port is built without ARC, and a cached suite would outlive an entitlement +// change across a background relaunch; this is called twice per install at most. +static NSUserDefaults *cn1InviteSuite(NSString *group) { + if (group == nil || group.length == 0) { + return nil; + } + NSUserDefaults *suite = [[[NSUserDefaults alloc] initWithSuiteName:group] autorelease]; + return suite; +} + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isAppClipHandoffSupported___java_lang_String_R_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT groupObj) { + // initWithSuiteName: answers nil when the process carries no such app + // group, which is exactly the question being asked -- an application built + // without a clip has no group and must report unsupported rather than + // spend an attempt discovering there is nothing to read. + return cn1InviteSuite(toNSString(CN1_THREAD_STATE_PASS_ARG groupObj)) != nil + ? JAVA_TRUE : JAVA_FALSE; +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_consumeAppClipInviteHandoff___java_lang_String_R_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT groupObj) { + NSUserDefaults *suite = cn1InviteSuite(toNSString(CN1_THREAD_STATE_PASS_ARG groupObj)); + if (suite == nil) { + return JAVA_NULL; + } + // objectForKey:, not dictionaryForKey:, because the shared container is + // writable by the clip and a wrong type there must read as "nothing was + // left" rather than raise: dictionaryForKey: answers nil for a non- + // dictionary, but the field reads below would then go to the wrong class. + id stored = [suite objectForKey:kCN1InviteHandoffKey]; + if (![stored isKindOfClass:[NSDictionary class]]) { + if (stored != nil) { + [suite removeObjectForKey:kCN1InviteHandoffKey]; + } + return JAVA_NULL; + } + NSDictionary *handoff = (NSDictionary *)stored; + id codeValue = [handoff objectForKey:kCN1InviteCodeField]; + NSString *code = [codeValue isKindOfClass:[NSString class]] ? (NSString *)codeValue : nil; + id clickedValue = [handoff objectForKey:kCN1InviteClickedField]; + long long clicked = [clickedValue isKindOfClass:[NSNumber class]] + ? [(NSNumber *)clickedValue longLongValue] : 0; + + // Cleared whatever was found, including a malformed record. Read once is + // the contract AppClipHandoffSource states, and it is what stops a second + // launch claiming a code the first already claimed. + [suite removeObjectForKey:kCN1InviteHandoffKey]; + + if (code == nil || code.length == 0) { + return JAVA_NULL; + } + // A newline separates the two, so a code carrying one would be read as a + // code plus garbage. Codes are Crockford base32 and cannot, but the clip + // writes what the link gave it and the link is somebody else's input. + if ([code rangeOfString:@"\n"].location != NSNotFound) { + return JAVA_NULL; + } + NSString *joined = [NSString stringWithFormat:@"%@\n%lld", code, clicked]; + return fromNSString(CN1_THREAD_STATE_PASS_ARG joined); +} + +#endif diff --git a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h index 7116beb106a..533e7d97946 100644 --- a/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h +++ b/Ports/iOSPort/nativeSources/CodenameOne_GLViewController.h @@ -290,6 +290,12 @@ BOOL cn1_watch_apply_mirrored_surface(NSString *kind, NSData *json, // entitlement. //#define CN1_INCLUDE_APPLESIGNIN +// CN1_INCLUDE_INVITE_APPCLIP gates the App Clip invite handoff reader in +// CN1InviteAppClip.m. IPhoneBuilder uncomments this only when it generated +// an App Clip target, which is the only thing that ever writes the shared +// app group container the reader consumes. +//#define CN1_INCLUDE_INVITE_APPCLIP + // CN1_INCLUDE_WEBAUTHN gates the com.codename1.io.webauthn native bridge // (ASAuthorizationPlatformPublicKeyCredentialProvider code in CN1WebAuthn.m, // iOS 16+). IPhoneBuilder uncomments this only when the scanner saw diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSAppClipHandoff.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSAppClipHandoff.java new file mode 100644 index 00000000000..13b911bc367 --- /dev/null +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSAppClipHandoff.java @@ -0,0 +1,114 @@ +/* + * 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.ios; + +import com.codename1.analytics.invite.AppClipHandoffCallback; +import com.codename1.analytics.invite.AppClipHandoffSource; +import com.codename1.analytics.invite.Invites; +import com.codename1.io.Log; + +/// Reads the invite code an App Clip left in the shared app group container. +/// +/// This is the deterministic half of invite attribution on iOS. The App Clip +/// is launched by the invite link itself and receives that link exactly, so it +/// writes the code into the group container before offering the App Store. +/// The code made the whole trip through the store, so nothing here is matched +/// or guessed and nothing about the visitor is collected. +/// +/// Only an iOS build that generated an App Clip registers this, and +/// `IPhoneBuilder` splices that registration into the generated stub under +/// exactly the condition that produced the clip. Nothing else in the port +/// references this class, so a build without invites strips it along with the +/// invite package -- which is deliberate, and is why it names the app group at +/// construction rather than reading a build hint of its own. +public class IOSAppClipHandoff implements AppClipHandoffSource { + private final String appGroup; + + /// Creates a source reading the named app group. + /// + /// #### Parameters + /// + /// - `appGroup`: the `group.` identifier the clip and the application both + /// carry in their entitlements + public IOSAppClipHandoff(String appGroup) { + this.appGroup = appGroup; + } + + public boolean isSupported() { + if (appGroup == null || appGroup.length() == 0) { + return false; + } + try { + return IOSImplementation.nativeInstance + .isAppClipHandoffSupported(appGroup); + } catch (Throwable t) { + Log.e(t); + return false; + } + } + + public void requestHandoff(AppClipHandoffCallback callback) { + String handoff; + try { + handoff = IOSImplementation.nativeInstance + .consumeAppClipInviteHandoff(appGroup); + } catch (Throwable t) { + // An unreachable container reads as "no clip ran", never as a + // crash: the application works, it simply has no invite behind it. + Log.e(t); + callback.onUnavailable(Invites.REASON_UNSUPPORTED); + return; + } + if (handoff == null || handoff.length() == 0) { + callback.onUnavailable(Invites.REASON_NO_MATCH); + return; + } + // "\n". Two values in one string because the + // native side clears the container as it reads, so a second call to + // fetch the timestamp would answer nothing. + String code = handoff; + long clicked = 0; + int nl = handoff.indexOf('\n'); + if (nl >= 0) { + code = handoff.substring(0, nl); + clicked = parseSeconds(handoff.substring(nl + 1)); + } + code = code.trim(); + if (code.length() == 0) { + callback.onUnavailable(Invites.REASON_NO_MATCH); + return; + } + callback.onHandoff(code, clicked); + } + + /// A timestamp that will not parse is not worth losing an attribution + /// over: the code is what the claim is made with, and the click time is + /// only reported alongside it. + private static long parseSeconds(String value) { + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException err) { + return 0; + } + } +} diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index 5222a9f8f34..fe430b94e41 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -2539,4 +2539,24 @@ native void nearbySendPayload(int requestId, String joinedEndpointIds, /** Stops advertising and browsing and drops every session. */ native void nearbyStopAllTransport(); + + /** + * Whether the app group holding the App Clip invite handoff can be opened + * at all. False when the application carries no such entitlement, which is + * every build that generated no clip. + * + * @param appGroup the group identifier + * @return true when the shared container is reachable + */ + native boolean isAppClipHandoffSupported(String appGroup); + + /** + * Reads the invite handoff an App Clip left behind and clears it in the + * same step, so two launches cannot claim one code. + * + * @param appGroup the group identifier + * @return "code\nclickedSeconds", or null when no clip ran + */ + native String consumeAppClipInviteHandoff(String appGroup); + } diff --git a/docs/developer-guide/Analytics.asciidoc b/docs/developer-guide/Analytics.asciidoc index e9515de9a01..3c1f8d0d960 100644 --- a/docs/developer-guide/Analytics.asciidoc +++ b/docs/developer-guide/Analytics.asciidoc @@ -228,7 +228,7 @@ When attribution resolves it's also written as persistent analytics dimensions ( | An iOS App Clip received the invite link and handed the code to the app the person then installed. Exact. This is the iOS path. |=== -Every match type is exact. The App Store carries no referrer parameter of its own, so on iOS the code travels a different road than it does on Android: tapping an invite link offers an App Clip, the clip is launched by the link itself and so receives the code exactly, and it leaves that code where the full app can read it after installation. Nothing is matched, estimated or guessed, and a referral bounty can be paid on any of these. +Every match type is exact. The App Store carries no referrer parameter of its own, so on iOS the code travels a different road than it does on Android: tapping the invite link offers an App Clip, the clip is launched by the link itself and so receives the code exactly, and it leaves that code where the full app can read it after installation. Nothing is matched, estimated or guessed, and a referral bounty can be paid on any of these. `Invites.setAttributionWindow(0)` stops a deferred lookup being started at all -- neither the Play install referrer nor the App Clip handoff is read. An exact code the device is already holding, one that arrived on a link, is still claimed: there is nothing to defer about it. @@ -244,6 +244,17 @@ Nothing at all is collected about someone who only taps a link. An earlier desig Referencing this package makes the build wire the platform side: an `autoVerify` App Links intent filter on Android, an associated domain on iOS, and the Play Install Referrer dependency. An app that only reports analytics gets none of it. +On iOS it also generates the App Clip, because there is no attribution without one. The clip is a separate binary embedded in your app: a few hundred lines of UIKit that show your app's name, offer to install it, and record the invite code the link handed them. It is not a Codename One application and does not run your code. You do not write it, open it or maintain it. + +Three things the generated clip needs from you, and each fails in its own way: + +* The App Store identifier of your app, in `ios.invite.appStoreId`. Without it the clip still records the code, it simply shows no install sheet -- which is the right behaviour before your first release, when the app does not exist in the store yet. +* An App Group registered on your developer account. The build derives one from your package name and adds it to `ios.app_groups`; override it with `ios.invite.appGroup` if you already have one. A group that is not registered signs cleanly and the clip and the app can then never reach each other, which is the failure with no symptom. +* An App Clip enabled on your App ID, alongside Associated Domains. +* An Advanced App Clip Experience registered in App Store Connect for your invite link prefix. Codename One authorises your clip for the domain; what maps a particular link to a particular clip is that experience, and until it exists tapping an invite link shows no clip card at all. The console shows the prefix to register once invites are switched on. + +Set `ios.invite.appClip` to `false` only if you ship an App Clip of your own. The app then reports every install as organic unless your clip writes the handoff itself. + WARNING: On Android, App Links verification checks the certificate the installed APK is signed with. Under Play App Signing that's Google's key, not your upload key, so add the app-signing SHA-256 from the Play Console to `android.invite.signingFingerprint`. Without it verification fails on every Play install, the link opens the browser instead of your app, and nothing reports an error. [[analytics-migration]] diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDynamic.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDynamic.java index fda89976f9b..ce8763c1036 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDynamic.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsDynamic.java @@ -86,6 +86,11 @@ static void register(List h) { + "CN1CallDirectoryExtensionIdentifier the host plist carries -- so an override has to " + "reach both or the app asks the system to reload an identifier nothing installed."); + family(h, "ios.invite.buildSettings.*", "ios", + "Xcode build settings for the generated invite App Clip target. The clip is a " + + "separate application bundle embedded in the app, so its deployment " + + "target and device family are its own and an override reaches only it."); + family(h, "ios.vpn.tunnel.buildSettings.*", "ios", "Xcode build settings for the generated packet tunnel extension target. " + "PRODUCT_BUNDLE_IDENTIFIER is read in two places -- the target's own settings and the " diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java index fbc707e4925..1b0d4846d75 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java @@ -221,6 +221,39 @@ static void register(List h) { + "Associated Domains capability either way, or invite links open Safari " + "instead of the app with no error reported.")); + h.add(new Hint("ios.invite.appClip") + .group(HintGroup.IOS) + .type(HintType.BOOLEAN) + .def("true") + .platform("ios") + .doc("Whether the build generates and embeds the App Clip that makes invite " + + "attribution exact on iOS. The App Store carries no referrer of its " + + "own, so without the clip an iOS install cannot be attributed at all. " + + "Set it to `false` only if you ship an App Clip of your own; the build " + + "then writes no clip, and the app reports every install as organic " + + "unless your clip writes the handoff itself. Ignored when " + + "`ios.invite.universalLinks` is `false`, because iOS can only offer a " + + "clip for a link the app has an associated domain for.")); + + h.add(new Hint("ios.invite.appGroup") + .group(HintGroup.IOS) + .type(HintType.STRING) + .platform("ios") + .doc("The app group the invite App Clip hands the invite code to the installed " + + "app through. Defaults to `group..cn1invite`, and is " + + "added to `ios.app_groups` automatically. It must start with `group.` " + + "and must be registered on your developer account, or the clip and the " + + "app both sign and neither can read what the other wrote.")); + + h.add(new Hint("ios.invite.appStoreId") + .group(HintGroup.IOS) + .type(HintType.STRING) + .platform("ios") + .doc("The numeric App Store identifier of this app, which the invite App Clip " + + "uses to offer the full app through `SKOverlay`. Leave it unset before " + + "your first release: the clip still records the invite code, it simply " + + "shows no install sheet until the app exists in the store.")); + h.add(new Hint("ios.associatedDomains") .group(HintGroup.IOS) .type(HintType.STRING) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 4b792c452a7..b3203a644d3 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -26,6 +26,7 @@ import com.codename1.util.IOSAppIntentsBuilder; import com.codename1.util.IOSCallDirectoryExtensionBuilder; import com.codename1.util.IOSDocumentProviderExtensionBuilder; +import com.codename1.util.InviteAppClipBuilder; import com.codename1.util.IOSVpnTunnelExtensionBuilder; import com.codename1.util.IOSWalletExtensionBuilder; import com.codename1.util.MatterExtensionBuilder; @@ -1431,6 +1432,11 @@ private java.util.Set foldInCallAndVpnLibraryUsage( /// alongside the extension and read again when the target is written. private String matterAppGroup; + /// The app group the invite App Clip hands the code to the application + /// through. Empty when no clip was generated, which is also what the + /// generated stub tests before registering a reader. + private String inviteAppClipGroup = ""; + /// The App Group the Call Directory extension and the app share. private String callDirectoryAppGroup; @@ -3707,6 +3713,22 @@ public void usesClassMethod(String cls, String method) { + inviteSlug.trim() + "\");\n"; } } + resolveInviteAppClipGroup(request); + // The reader for what the App Clip left behind. A direct symbol + // reference, not a name lookup: obfuscation renames the class and + // Class.forName would answer nothing in a release build. + // + // Registered before i.init(), because Invites reads the handoff on its + // first checkForInvite() and a source registered after that has missed + // the only launch that had a code to give. Nothing else in the port + // names IOSAppClipHandoff, so a build without a clip strips it. + String inviteAppClipRegister = ""; + if (inviteAppClipGroup != null && inviteAppClipGroup.length() > 0) { + inviteAppClipRegister = " com.codename1.analytics.invite.Invites" + + ".registerAppClipHandoffSource(new " + + "com.codename1.impl.ios.IOSAppClipHandoff(\"" + + inviteAppClipGroup + "\"));\n"; + } String dbLegacy = databaseLegacyStubProperty(request, usesDatabase); // If the build-time SVG transcoder produced a registry class, weave @@ -3905,6 +3927,7 @@ public void usesClassMethod(String cls, String method) { + " if(!initialized) {\n" + " initialized = true;\n" + firebaseRegisterInstall + + inviteAppClipRegister + svgRegistryInstall + phoneHealthBindingsInstall + " i.init(this);\n" @@ -4443,6 +4466,57 @@ public void usesClassMethod(String cls, String method) { request.putArgument("ios.associatedDomains", existingDomains); } + // The App Clip. This is what makes iOS attribution deterministic: + // the clip is launched BY the invite link and is handed it exactly, + // so it knows the code with certainty and writes it into a + // container the installed application reads. Without it the only + // iOS answer is a statistical match against a profile of somebody + // who installed nothing -- which is what this replaced. + // + // Gated on the same usesInvites scan as everything else here, so a + // second binary, a second provisioning profile and an app group + // land only on an app that asked for invites, and on the same + // universalLinks hint: an app that suppressed the associated + // domain has no way for iOS to offer a clip and would ship one + // that can never launch. + if (inviteAppClipGroup.length() > 0) { + String inviteHost = request.getArg("invite.domain", "cloud.codenameone.com"); + // Already resolved and validated before the stub was written, + // which needed it to decide whether to register a reader at + // all. Re-deriving it here would let the two disagree. + String group = inviteAppClipGroup; + // Entry by entry, never a substring test: group.com.acme.shared + // contains group.com.acme, and deciding the group is already + // present on that basis entitles the clip for one group and the + // application for another -- two processes that sign, install, + // and never meet. + String appGroups = request.getArg("ios.app_groups", ""); + boolean present = false; + for (String candidate : appGroups.split(",")) { + if (candidate.trim().equals(group)) { + present = true; + break; + } + } + if (!present) { + request.putArgument("ios.app_groups", + appGroups.trim().length() == 0 ? group + : appGroups.trim() + "," + group); + } + try { + replaceInFile(new File(buildinRes, + "CodenameOne_GLViewController.h"), + "//#define CN1_INCLUDE_INVITE_APPCLIP", + "#define CN1_INCLUDE_INVITE_APPCLIP"); + } catch (IOException ex) { + throw new BuildException( + "Failed to enable CN1_INCLUDE_INVITE_APPCLIP", ex); + } + debug("Invite attribution: generating the App Clip " + + InviteAppClipBuilder.CLIP_NAME + " for " + inviteHost + + " (app group " + group + ")"); + } + if (request.getArg("ios.associatedDomains", null) != null) { // If the user has provided the ios.associatedDomains build hint, then we will need to // enable handling for these events. @@ -7306,6 +7380,15 @@ && conditionCovers(governingKey, appendWidgetExtensionTargets(appExtensionsBuilder, request, new File(tmpFile, "dist")); } + if (inviteAppClipGroup.length() > 0) { + // Same ordering note: appended after the global deployment-target + // pass, so the clip keeps its own iOS 14 floor -- which is not a + // preference. App Clips do not exist below it, and one built against + // an app targeting less does not launch. + appendInviteAppClipTarget(appExtensionsBuilder, request, + new File(tmpFile, "dist")); + } + if (documentProviderEnabled) { // Same ordering note: appended after the global deployment-target pass, // so the extension keeps its own floor while the app keeps whatever it @@ -12174,6 +12257,136 @@ displayName, embeddedExtensionShortVersion(request), sb.append("}\nend\n"); } + /// Decides whether this build gets an invite App Clip, and under which + /// app group. + /// + /// Called before the stub is written, because the stub is what registers + /// the reader and it needs the group as a literal. The target generation + /// runs much later and reads the same field, so the clip's entitlement and + /// the application's registration cannot disagree -- they did while this + /// was derived twice, and the symptom was a clip that stored a code into a + /// container nothing read. + /// + /// @param request the build request, whose ios.app_groups is left alone + /// here; the enablement block adds the group + private void resolveInviteAppClipGroup(BuildRequest request) throws BuildException { + inviteAppClipGroup = ""; + if (!usesInvites + || !"true".equals(request.getArg("ios.invite.universalLinks", "true")) + || !"true".equals(request.getArg("ios.invite.appClip", "true"))) { + return; + } + String group = request.getArg("ios.invite.appGroup", + InviteAppClipBuilder.defaultAppGroup(request.getPackageName())); + group = group == null ? "" : group.trim(); + if (!group.startsWith("group.")) { + throw new BuildException( + "ios.invite.appGroup must be an app group identifier starting " + + "\"group.\", got \"" + group + "\"."); + } + inviteAppClipGroup = group; + } + + /// Emits the App Clip target into the schemes ruby. + /// + /// Modelled on [#appendMatterExtensionTarget], with one structural + /// difference that is the whole reason this is not an app extension: a + /// clip is a full application bundle with its own product type, and + /// `new_target` has no symbol for that type in every xcodeproj version we + /// might meet -- so it is created as an application and the product type + /// assigned afterwards. An extension's `:app_extension` would produce a + /// binary Apple rejects at upload with a message about the extension + /// point, which names nothing a developer could act on. + /// + /// It also embeds into `AppClips/`, not `PlugIns/`. Copied into the wrong + /// folder the clip signs, uploads and never launches. + /// + /// @param sb the ruby being assembled + /// @param request the build request + /// @param distDir the dist directory the clip's sources are staged under + private void appendInviteAppClipTarget(StringBuilder sb, BuildRequest request, + File distDir) throws IOException, BuildException { + String name = InviteAppClipBuilder.CLIP_NAME; + String inviteHost = request.getArg("invite.domain", "cloud.codenameone.com"); + String displayName = request.getDisplayName() == null + ? request.getMainClass() : request.getDisplayName(); + IOSWalletExtensionBuilder.writeFileMap( + InviteAppClipBuilder.buildFileMap(request.getPackageName(), + inviteAppClipGroup, inviteHost, displayName, + embeddedExtensionShortVersion(request), + embeddedExtensionBundleVersion(request), + request.getArg("ios.invite.appStoreId", "").trim()), + new File(distDir, name)); + log("Adding invite App Clip target " + name + " (app group " + + inviteAppClipGroup + ")"); + + Map buildSettingsMap = new LinkedHashMap(); + buildSettingsMap.put("PRODUCT_BUNDLE_IDENTIFIER", + InviteAppClipBuilder.bundleId(request.getPackageName())); + buildSettingsMap.put("PRODUCT_NAME", "$(TARGET_NAME)"); + buildSettingsMap.put("INFOPLIST_FILE", name + "/Info.plist"); + buildSettingsMap.put("CODE_SIGN_ENTITLEMENTS", name + "/" + name + ".entitlements"); + buildSettingsMap.put("IPHONEOS_DEPLOYMENT_TARGET", + InviteAppClipBuilder.DEPLOYMENT_TARGET); + // iPhone only. App Clips do not run on iPad-only or Mac destinations, + // and a clip claiming a family the host does not ship fails validation. + buildSettingsMap.put("TARGETED_DEVICE_FAMILY", "1"); + buildSettingsMap.put("LD_RUNPATH_SEARCH_PATHS", + "$(inherited) @executable_path/Frameworks"); + buildSettingsMap.put("SKIP_INSTALL", "YES"); + // The clip is generated, self-contained UIKit and owns no Codename One + // objects, so it is built the way Apple's own template is rather than + // the way the port is. + buildSettingsMap.put("CLANG_ENABLE_OBJC_ARC", "YES"); + buildSettingsMap.put("CLANG_ENABLE_MODULES", "YES"); + buildSettingsMap.put("ASSETCATALOG_COMPILER_APPICON_NAME", ""); + for (String key : request.getArgs()) { + if (key.startsWith("ios.invite.buildSettings.")) { + buildSettingsMap.put( + key.substring("ios.invite.buildSettings.".length()), + request.getArg(key, "")); + } + } + // Guarded so re-running the script does not create a duplicate target; + // the build re-executes fix_xcode_schemes.rb after dependency + // integration. + sb.append("\nif xcproj.targets.find{|e| e.name=='" + name + "'}.nil?\n" + + "clip_target = xcproj.new_target(:application, '" + name + "', :ios, '" + + InviteAppClipBuilder.DEPLOYMENT_TARGET + "')\n" + + "clip_target.product_type = '" + InviteAppClipBuilder.PRODUCT_TYPE + "'\n" + + "clip_target.add_system_framework('UIKit')\n" + // SKOverlay is the install affordance, and it is what carries + // the clip's stored data forward to the installed app. + + "clip_target.add_system_framework('StoreKit')\n" + + "clip_group = xcproj.new_group('" + name + "')\n"); + appendFilesToXcodeProjGroup(sb, new File(distDir, name), "clip_group", "clip_target", + distDir); + sb.append("main_app_target = xcproj.targets.find{|e| e.name==main_class_name}\n" + + "main_app_target.add_dependency(clip_target)\n" + + "fileref = xcproj.groups.find{|e| e.display_name=='Products'}.new_file('" + + name + ".app', \"BUILT_PRODUCTS_DIR\")\n" + + "embed_phase = main_app_target.copy_files_build_phases.find{|p| " + + "p.name=='Embed App Clips'} || " + + "main_app_target.new_copy_files_build_phase('Embed App Clips')\n" + + "embed_phase.build_action_mask = \"2147483647\"\n" + // 16 is the products directory, and the destination path below + // is what puts the clip in AppClips/ rather than beside the + // executable. PlugIns (13) is where extensions go and is wrong + // here: the bundle signs and uploads and the clip never runs. + + "embed_phase.dst_subfolder_spec = \"16\"\n" + + "embed_phase.dst_path = \"$(CONTENTS_FOLDER_PATH)/AppClips\"\n" + + "embed_phase.run_only_for_deployment_postprocessing=\"0\"\n" + + "embed_phase.add_file_reference(fileref)\n" + + "clip_target.build_configurations.each{|e| \n"); + for (String buildSettingKey : buildSettingsMap.keySet()) { + sb.append(" e.build_settings['" + escapeRuby(buildSettingKey) + "'] = \"" + + escapeRubyDoubleQuoted(buildSettingsMap.get(buildSettingKey)) + "\"\n"); + } + sb.append("}\n"); + sb.append("end\n"); + sb.append("xcproj.save(project_file)\n"); + } + private void appendMatterExtensionTarget(StringBuilder sb, BuildRequest request, File distDir) throws IOException, BuildException { String name = MatterExtensionBuilder.EXTENSION_NAME; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/InviteAppClipBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/InviteAppClipBuilder.java new file mode 100644 index 00000000000..39bb5615d32 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/InviteAppClipBuilder.java @@ -0,0 +1,473 @@ +/* + * 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.util; + +import java.io.UnsupportedEncodingException; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Generates the App Clip that makes invite attribution deterministic on iOS. + * + *

Why a second binary exists

+ * + *

The App Store carries no referrer parameter. That is a platform fact, not + * a gap in this implementation: an iOS install knows nothing about the link + * that led to it, which is why every other product in this space answers the + * question statistically, by matching a hashed profile of the visitor against + * a hashed profile of the installer. Codename One did that too, once, and it + * collected data about people who had installed nothing and agreed to + * nothing.

+ * + *

An App Clip removes the guess. The clip is launched by the invite link + * itself and is handed that link exactly, so it knows the code with + * certainty. It writes the code into the app group container it shares with + * the full application and offers the App Store. When the person installs, the + * application reads the container and the code has made the whole trip + * intact -- no profile, no window, no probability.

+ * + *

What the generated clip is

+ * + *

Deliberately not a Codename One application. A clip is capped at 15 MB + * uncompressed and must launch instantly, and it has exactly one job that + * finishes before the person reads the screen. So it is a few hundred lines of + * UIKit: one label, one button, and {@code SKOverlay} to offer the full app -- + * which is Apple's own install affordance and the one that carries the clip's + * stored data forward.

+ * + *

Pure static string-building with no build state, so the emitted files can + * be asserted in a unit test rather than only by running a device build. The + * three names it shares with the port's reader -- the defaults key and the two + * field names -- are duplicated in {@code CN1InviteAppClip.m} and must move + * together; nothing links the two binaries, so a mismatch is silent.

+ */ +public final class InviteAppClipBuilder { + + /** Xcode target and folder name of the generated clip. */ + public static final String CLIP_NAME = "CN1InviteClip"; + + /** + * App Clips exist from iOS 14. Named here rather than inherited from the + * application because the application's own floor is lower, and a clip + * built against it does not launch. + */ + public static final String DEPLOYMENT_TARGET = "14.0"; + + /** + * The product type Xcode gives an App Clip. {@code new_target} has no + * symbol for it in every xcodeproj version we might meet, so the ruby + * creates an application and assigns this afterwards. + */ + public static final String PRODUCT_TYPE = + "com.apple.product-type.application.on-demand-install-capable"; + + /** The defaults key the clip writes and {@code CN1InviteAppClip.m} consumes. */ + public static final String HANDOFF_KEY = "cn1-invite-app-clip-handoff"; + + /** The invite code, inside the handoff dictionary. */ + public static final String CODE_FIELD = "code"; + + /** Seconds since the epoch at which the link was tapped. */ + public static final String CLICKED_FIELD = "clicked"; + + private InviteAppClipBuilder() { + } + + /** + * The bundle identifier Apple requires of a clip: the application's own + * with a suffix, so the pair is recognised as one product. + * + * @param packageName the application's bundle identifier + * @return the clip's bundle identifier + */ + public static String bundleId(String packageName) { + return packageName + ".Clip"; + } + + /** + * The app group the clip and the application exchange the code through, + * when the developer named none. + * + *

Derived rather than fixed: an app group is namespaced to a developer + * account, so a constant would collide between two Codename One apps on + * the same account and let one read the other's invites.

+ * + * @param packageName the application's bundle identifier + * @return a {@code group.} identifier + */ + public static String defaultAppGroup(String packageName) { + return "group." + packageName + ".cn1invite"; + } + + /** + * Builds the clip's sources and resources. + * + * @param packageName the application's bundle identifier + * @param appGroup the shared app group, already validated + * @param inviteHost the host the invite links are served from + * @param displayName what the clip card calls the app + * @param shortVersion the host's marketing version + * @param bundleVersion the host's build version + * @param storeItemId the App Store item identifier, or empty when it is + * not known at build time + * @return path to content, in a stable order + */ + public static Map buildFileMap(String packageName, + String appGroup, String inviteHost, String displayName, + String shortVersion, String bundleVersion, String storeItemId) { + Map files = new LinkedHashMap(); + files.put("main.m", utf8(mainSource())); + files.put("CN1InviteClipDelegate.h", utf8(delegateHeader())); + files.put("CN1InviteClipDelegate.m", + utf8(delegateSource(appGroup, displayName, storeItemId))); + files.put("Info.plist", + utf8(infoPlist(displayName, shortVersion, bundleVersion))); + files.put(CLIP_NAME + ".entitlements", + utf8(entitlements(packageName, appGroup, inviteHost))); + return files; + } + + private static String mainSource() { + return "// Generated by Codename One. Do not edit.\n" + + "#import \n" + + "#import \"CN1InviteClipDelegate.h\"\n\n" + + "int main(int argc, char * argv[]) {\n" + + " @autoreleasepool {\n" + + " return UIApplicationMain(argc, argv, nil,\n" + + " NSStringFromClass([CN1InviteClipDelegate class]));\n" + + " }\n" + + "}\n"; + } + + private static String delegateHeader() { + return "// Generated by Codename One. Do not edit.\n" + + "#import \n\n" + + "@interface CN1InviteClipDelegate : UIResponder \n" + + "@property (nonatomic, strong) UIWindow *window;\n" + + "@end\n"; + } + + /** + * The clip itself. + * + *

Two things here are load-bearing and easy to get wrong. The invite + * code is recorded in {@code continueUserActivity}, which on a cold launch + * arrives after {@code didFinishLaunching} -- so the recording + * cannot live in the launch path, and the launch path must tolerate having + * no code yet. And the write is flushed immediately rather than at the + * clip's convenience: a clip is terminated without warning the moment the + * person taps through to the App Store, and an unflushed write is the + * attribution.

+ * + * @param appGroup the shared container + * @param displayName what the card calls the app + * @param storeItemId the numeric App Store id, or empty + * @return the source + */ + private static String delegateSource(String appGroup, String displayName, + String storeItemId) { + StringBuilder sb = new StringBuilder(); + sb.append("// Generated by Codename One. Do not edit.\n") + .append("#import \"CN1InviteClipDelegate.h\"\n") + .append("#import \n\n") + .append("static NSString * const kAppGroup = @\"") + .append(escapeObjC(appGroup)).append("\";\n") + .append("static NSString * const kHandoffKey = @\"") + .append(HANDOFF_KEY).append("\";\n") + .append("static NSString * const kDisplayName = @\"") + .append(escapeObjC(displayName)).append("\";\n") + .append("static NSString * const kStoreItemId = @\"") + .append(escapeObjC(storeItemId)).append("\";\n\n") + .append("@interface CN1InviteClipDelegate ()\n") + .append("@property (nonatomic, strong) UILabel *status;\n") + .append("@end\n\n") + .append("@implementation CN1InviteClipDelegate\n\n"); + + // The code extraction, kept in one function so the clip and any future + // reader of this file can see the whole grammar at once. + sb.append("// /i//, /i/, or ?code=. The last\n") + .append("// non-empty path component after /i/ is the code in both path\n") + .append("// forms, so one rule covers them and a third form would only\n") + .append("// need the query fallback below.\n") + .append("static NSString *cn1InviteCodeFromURL(NSURL *url) {\n") + .append(" if (url == nil) { return nil; }\n") + .append(" NSURLComponents *c = [NSURLComponents componentsWithURL:url\n") + .append(" resolvingAgainstBaseURL:NO];\n") + .append(" for (NSURLQueryItem *item in c.queryItems) {\n") + .append(" if ([item.name isEqualToString:@\"code\"] && item.value.length > 0) {\n") + .append(" return item.value;\n") + .append(" }\n") + .append(" }\n") + .append(" NSMutableArray *parts = [NSMutableArray array];\n") + .append(" for (NSString *p in [c.percentEncodedPath componentsSeparatedByString:@\"/\"]) {\n") + .append(" if (p.length > 0) { [parts addObject:p]; }\n") + .append(" }\n") + .append(" if (parts.count < 2 || ![parts[0] isEqualToString:@\"i\"]) { return nil; }\n") + .append(" NSString *last = [parts lastObject];\n") + .append(" return [last stringByRemovingPercentEncoding];\n") + .append("}\n\n"); + + sb.append("// Only characters an invite code can contain. The url is\n") + .append("// somebody else's input and this value is handed to the\n") + .append("// application, which claims with it -- so it is constrained\n") + .append("// here, where the grammar is known, rather than trusted there.\n") + .append("static BOOL cn1InviteCodeIsWellFormed(NSString *code) {\n") + .append(" if (code.length == 0 || code.length > 64) { return NO; }\n") + .append(" NSCharacterSet *allowed = [NSCharacterSet\n") + .append(" characterSetWithCharactersInString:\n") + .append(" @\"ABCDEFGHIJKLMNOPQRSTUVWXYZ\"\n") + .append(" @\"abcdefghijklmnopqrstuvwxyz0123456789-_\"];\n") + .append(" NSCharacterSet *rejected = [allowed invertedSet];\n") + .append(" return [code rangeOfCharacterFromSet:rejected].location == NSNotFound;\n") + .append("}\n\n"); + + sb.append("- (void)recordInviteFromURL:(NSURL *)url {\n") + .append(" NSString *code = cn1InviteCodeFromURL(url);\n") + .append(" if (!cn1InviteCodeIsWellFormed(code)) { return; }\n") + .append(" NSUserDefaults *suite = [[NSUserDefaults alloc] initWithSuiteName:kAppGroup];\n") + .append(" if (suite == nil) { return; }\n") + .append(" [suite setObject:@{ @\"").append(CODE_FIELD).append("\": code,\n") + .append(" @\"").append(CLICKED_FIELD) + .append("\": @((long long)[[NSDate date] timeIntervalSince1970]) }\n") + .append(" forKey:kHandoffKey];\n") + .append(" // Flushed now. The clip is killed without notice the moment\n") + .append(" // the App Store sheet takes over, and the write IS the\n") + .append(" // attribution -- there is no second chance to make it.\n") + .append(" [suite synchronize];\n") + .append(" self.status.text = [NSString stringWithFormat:\n") + .append(" @\"You were invited to %@\", kDisplayName];\n") + .append("}\n\n"); + + sb.append("- (BOOL)application:(UIApplication *)application\n") + .append(" continueUserActivity:(NSUserActivity *)userActivity\n") + .append(" restorationHandler:(void (^)(NSArray> *))handler {\n") + .append(" if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) {\n") + .append(" [self recordInviteFromURL:userActivity.webpageURL];\n") + .append(" }\n") + .append(" return YES;\n") + .append("}\n\n"); + + sb.append("- (BOOL)application:(UIApplication *)application\n") + .append(" didFinishLaunchingWithOptions:(NSDictionary *)options {\n") + .append(" self.window = [[UIWindow alloc] initWithFrame:UIScreen.mainScreen.bounds];\n") + .append(" UIViewController *root = [[UIViewController alloc] init];\n") + .append(" root.view.backgroundColor = UIColor.systemBackgroundColor;\n") + .append(" self.status = [[UILabel alloc] init];\n") + .append(" self.status.numberOfLines = 0;\n") + .append(" self.status.textAlignment = NSTextAlignmentCenter;\n") + .append(" self.status.font = [UIFont preferredFontForTextStyle:UIFontTextStyleTitle2];\n") + .append(" self.status.text = kDisplayName;\n") + .append(" self.status.translatesAutoresizingMaskIntoConstraints = NO;\n") + .append(" [root.view addSubview:self.status];\n") + .append(" UIButton *get = [UIButton buttonWithType:UIButtonTypeSystem];\n") + .append(" [get setTitle:@\"Get the app\" forState:UIControlStateNormal];\n") + .append(" get.titleLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleHeadline];\n") + .append(" [get addTarget:self action:@selector(offerFullApp)\n") + .append(" forControlEvents:UIControlEventTouchUpInside];\n") + .append(" get.translatesAutoresizingMaskIntoConstraints = NO;\n") + .append(" [root.view addSubview:get];\n") + .append(" UILayoutGuide *g = root.view.layoutMarginsGuide;\n") + .append(" [NSLayoutConstraint activateConstraints:@[\n") + .append(" [self.status.centerYAnchor constraintEqualToAnchor:g.centerYAnchor constant:-40],\n") + .append(" [self.status.leadingAnchor constraintEqualToAnchor:g.leadingAnchor],\n") + .append(" [self.status.trailingAnchor constraintEqualToAnchor:g.trailingAnchor],\n") + .append(" [get.topAnchor constraintEqualToAnchor:self.status.bottomAnchor constant:24],\n") + .append(" [get.centerXAnchor constraintEqualToAnchor:g.centerXAnchor]\n") + .append(" ]];\n") + .append(" self.window.rootViewController = root;\n") + .append(" [self.window makeKeyAndVisible];\n") + .append(" // A warm launch delivers the activity in the launch options\n") + .append(" // instead of calling continueUserActivity:, so both are read.\n") + .append(" NSDictionary *activityDict = options[UIApplicationLaunchOptionsUserActivityDictionaryKey];\n") + .append(" for (id value in activityDict.allValues) {\n") + .append(" if ([value isKindOfClass:[NSUserActivity class]]) {\n") + .append(" [self recordInviteFromURL:((NSUserActivity *)value).webpageURL];\n") + .append(" }\n") + .append(" }\n") + .append(" [self offerFullApp];\n") + .append(" return YES;\n") + .append("}\n\n"); + + sb.append("// SKOverlay is Apple's own App Clip install affordance, and the\n") + .append("// only one that carries the clip's stored data to the installed\n") + .append("// app. Without a store id -- which a build before first release\n") + .append("// does not have -- the clip still records the code and simply\n") + .append("// shows no sheet; the handoff works the moment the app exists.\n") + .append("- (void)offerFullApp {\n") + .append(" if (kStoreItemId.length == 0) { return; }\n") + .append(" if (@available(iOS 14.0, *)) {\n") + .append(" SKOverlayAppClipConfiguration *config =\n") + .append(" [[SKOverlayAppClipConfiguration alloc] initWithPosition:SKOverlayPositionBottom];\n") + .append(" SKOverlay *overlay = [[SKOverlay alloc] initWithConfiguration:config];\n") + .append(" UIWindowScene *scene = (UIWindowScene *)self.window.windowScene;\n") + .append(" if (scene != nil) { [overlay presentInScene:scene]; }\n") + .append(" }\n") + .append("}\n\n") + .append("@end\n"); + return sb.toString(); + } + + private static String infoPlist(String displayName, String shortVersion, + String bundleVersion) { + return "\n" + + "\n" + + "\n" + + "\n" + + " CFBundleDevelopmentRegion\n" + + " en\n" + + " CFBundleDisplayName\n" + + " " + escapeXml(displayName) + "\n" + + " CFBundleExecutable\n" + + " $(EXECUTABLE_NAME)\n" + + " CFBundleIdentifier\n" + + " $(PRODUCT_BUNDLE_IDENTIFIER)\n" + + " CFBundleInfoDictionaryVersion\n" + + " 6.0\n" + + " CFBundleName\n" + + " $(PRODUCT_NAME)\n" + + " CFBundlePackageType\n" + + " APPL\n" + // Both versions must equal the host's or archive validation + // rejects the whole app, the same rule the extensions follow. + + " CFBundleShortVersionString\n" + + " " + escapeXml(shortVersion) + "\n" + + " CFBundleVersion\n" + + " " + escapeXml(bundleVersion) + "\n" + + " LSRequiresIPhoneOS\n" + + " \n" + + " NSAppClip\n" + + " \n" + // False deliberately. The ephemeral notification asks for + // permission to message somebody who has installed nothing; + // this clip records a code and offers the store, and has no + // reason to speak to them again. + + " NSAppClipRequestEphemeralUserNotification\n" + + " \n" + + " NSAppClipRequestLocationConfirmation\n" + + " \n" + + " \n" + + " UILaunchScreen\n" + + " \n" + + " UIRequiredDeviceCapabilities\n" + + " \n" + + " armv7\n" + + " \n" + + " UISupportedInterfaceOrientations\n" + + " \n" + + " UIInterfaceOrientationPortrait\n" + + " UIInterfaceOrientationLandscapeLeft\n" + + " UIInterfaceOrientationLandscapeRight\n" + + " \n" + + "\n" + + "\n"; + } + + /** + * The clip's entitlements. + * + *

All three are required and each fails differently when absent. Without + * the parent identifier the clip is not recognised as belonging to the app + * and does not install. Without the associated domain iOS never offers the + * clip for the link, so nothing runs. Without the app group the clip runs, + * shows its card, records nothing, and every install reads as organic -- + * the failure with no symptom.

+ * + * @param packageName the application's bundle identifier + * @param appGroup the shared container + * @param inviteHost the host serving the invite links + * @return the plist + */ + private static String entitlements(String packageName, String appGroup, + String inviteHost) { + return "\n" + + "\n" + + "\n" + + "\n" + + " com.apple.developer.parent-application-identifiers\n" + + " \n" + + " $(AppIdentifierPrefix)" + escapeXml(packageName) + "\n" + + " \n" + + " com.apple.developer.associated-domains\n" + + " \n" + + " appclips:" + escapeXml(inviteHost) + "\n" + + " \n" + + " com.apple.security.application-groups\n" + + " \n" + + " " + escapeXml(appGroup) + "\n" + + " \n" + + "\n" + + "\n"; + } + + /** + * Objective-C string-literal escaping for the handful of values + * interpolated into generated source. + * + *

Not cosmetic: the display name is the developer's, and a quotation + * mark in it would end the literal and leave the rest as code. A newline + * would do the same, so both are removed rather than escaped.

+ * + * @param value the raw value + * @return a value safe inside an {@code @"..."} literal + */ + static String escapeObjC(String value) { + if (value == null) { + return ""; + } + StringBuilder sb = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c == '"' || c == '\\') { + sb.append('\\').append(c); + } else if (c == '\n' || c == '\r' || c == '\t') { + sb.append(' '); + } else if (c >= ' ' && c < 127) { + sb.append(c); + } else if (c >= 127) { + // The generated file is compiled as UTF-8 and a display name + // legitimately carries accents and CJK; only the control range + // is dropped. + sb.append(c); + } + } + return sb.toString(); + } + + static String escapeXml(String value) { + if (value == null) { + return ""; + } + return value.replace("&", "&").replace("<", "<") + .replace(">", ">"); + } + + private static byte[] utf8(String value) { + try { + return value.getBytes("UTF-8"); + } catch (UnsupportedEncodingException impossible) { + throw new IllegalStateException("UTF-8 is unavailable", impossible); + } + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/InviteAppClipBuilderTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/InviteAppClipBuilderTest.java new file mode 100644 index 00000000000..b18bb8f1da0 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/InviteAppClipBuilderTest.java @@ -0,0 +1,178 @@ +/* + * 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.util; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// The generated invite App Clip. +/// +/// Everything asserted here fails silently on a device. The clip is a separate +/// binary, nothing links it to the application, and its entire job finishes +/// before anybody looks at the screen -- so a clip that records nothing looks +/// exactly like a clip that recorded something, and the symptom is an install +/// that reads as organic weeks later in a report. +class InviteAppClipBuilderTest { + + private static final String PKG = "com.example.myapp"; + private static final String GROUP = "group.com.example.myapp.cn1invite"; + + private static Map files() { + return InviteAppClipBuilder.buildFileMap(PKG, GROUP, + "cloud.codenameone.com", "My App", "1.4", "17", "123456789"); + } + + private static String text(Map files, String name) + throws Exception { + byte[] content = files.get(name); + assertNotNull(content, name + " was not generated"); + return new String(content, "UTF-8"); + } + + /// The three names the clip shares with the port's reader + /// (`CN1InviteAppClip.m`). Nothing links the two binaries, so a rename on + /// one side compiles, signs, ships and reads nothing. + @Test + void theHandoffNamesAreTheOnesTheReaderLooksFor() throws Exception { + String src = text(files(), "CN1InviteClipDelegate.m"); + assertEquals("cn1-invite-app-clip-handoff", InviteAppClipBuilder.HANDOFF_KEY); + assertEquals("code", InviteAppClipBuilder.CODE_FIELD); + assertEquals("clicked", InviteAppClipBuilder.CLICKED_FIELD); + assertTrue(src.contains("@\"cn1-invite-app-clip-handoff\""), + "the clip must write the key the reader reads"); + assertTrue(src.contains("@\"code\":"), "the code field name"); + assertTrue(src.contains("@\"clicked\":"), "the click time field name"); + } + + /// The write has to be flushed inside the handler. A clip is terminated + /// without notice the moment the store sheet takes over, and the write IS + /// the attribution. + @Test + void theHandoffIsFlushedBeforeTheClipCanBeKilled() throws Exception { + String src = text(files(), "CN1InviteClipDelegate.m"); + int write = src.indexOf("forKey:kHandoffKey"); + int flush = src.indexOf("[suite synchronize]"); + assertTrue(write > 0 && flush > write, + "the shared container must be synchronized after the write"); + } + + /// A cold launch delivers the activity through continueUserActivity: and a + /// warm one through the launch options. Reading only the first loses every + /// second and subsequent tap, which is most of them. + @Test + void bothActivityDeliveryPathsAreRead() throws Exception { + String src = text(files(), "CN1InviteClipDelegate.m"); + assertTrue(src.contains("continueUserActivity:(NSUserActivity *)userActivity"), + "the cold-launch path"); + assertTrue(src.contains("UIApplicationLaunchOptionsUserActivityDictionaryKey"), + "the warm-launch path"); + } + + /// All three entitlements, because each is absent in a different way. The + /// app group is the one whose absence has no symptom at all. + @Test + void theClipCarriesEveryEntitlementItNeeds() throws Exception { + String ent = text(files(), "CN1InviteClip.entitlements"); + assertTrue(ent.contains("$(AppIdentifierPrefix)com.example.myapp"), + "parent application identifier"); + assertTrue(ent.contains("appclips:cloud.codenameone.com"), + "the associated domain iOS offers the clip for"); + assertTrue(ent.contains(GROUP), "the shared app group"); + } + + /// Archive validation rejects the whole application when an embedded + /// bundle's versions differ from the host's. + @Test + void theVersionsMatchTheHostApplication() throws Exception { + String plist = text(files(), "Info.plist"); + assertTrue(plist.contains("CFBundleShortVersionString\n 1.4"), + plist); + assertTrue(plist.contains("CFBundleVersion\n 17"), + plist); + assertTrue(plist.contains("NSAppClip"), "the clip marker"); + } + + /// Apple requires the clip's bundle identifier to extend the + /// application's; an unrelated one is not recognised as its clip. + @Test + void theBundleIdExtendsTheApplications() { + assertTrue(InviteAppClipBuilder.bundleId(PKG).startsWith(PKG + "."), + InviteAppClipBuilder.bundleId(PKG)); + } + + /// Derived per application. A constant group would be shared by every + /// Codename One app on one developer account, and each could read the + /// others' invite codes. + @Test + void theDefaultAppGroupIsPerApplication() { + assertFalse(InviteAppClipBuilder.defaultAppGroup("com.example.a") + .equals(InviteAppClipBuilder.defaultAppGroup("com.example.b")), + "two applications must not share a container"); + assertTrue(InviteAppClipBuilder.defaultAppGroup(PKG).startsWith("group."), + "an app group identifier must start with group."); + } + + /// A build before the first release has no store id, and that must cost + /// the attribution nothing: the code is still recorded, only the install + /// sheet is absent. + @Test + void noStoreIdStillRecordsTheCode() throws Exception { + Map f = InviteAppClipBuilder.buildFileMap(PKG, GROUP, + "cloud.codenameone.com", "My App", "1.4", "17", ""); + String src = text(f, "CN1InviteClipDelegate.m"); + assertTrue(src.contains("kStoreItemId.length == 0"), + "the overlay must be skipped, not the recording"); + int guard = src.indexOf("kStoreItemId.length == 0"); + int record = src.indexOf("- (void)recordInviteFromURL:"); + assertTrue(record < guard, "recording must not sit behind the store-id guard"); + } + + /// The url is somebody else's input and the code taken out of it is what + /// the application claims with, so the grammar is enforced where it is + /// known rather than trusted downstream. + @Test + void theCodeIsConstrainedBeforeItIsStored() throws Exception { + String src = text(files(), "CN1InviteClipDelegate.m"); + assertTrue(src.contains("cn1InviteCodeIsWellFormed"), "a grammar check exists"); + int check = src.indexOf("if (!cn1InviteCodeIsWellFormed(code)) { return; }"); + int store = src.indexOf("forKey:kHandoffKey"); + assertTrue(check > 0 && check < store, + "the check must precede the write, not follow it"); + } + + /// A display name is the developer's text and is interpolated into an + /// Objective-C string literal. A quotation mark in it would end the + /// literal and leave the rest as code. + @Test + void aQuotedDisplayNameCannotEscapeItsLiteral() { + assertEquals("Bob\\\"s \\\\ App", + InviteAppClipBuilder.escapeObjC("Bob\"s \\ App")); + assertEquals("one two", InviteAppClipBuilder.escapeObjC("one\ntwo")); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java index c356efd8c6d..0ccf7557451 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java @@ -220,6 +220,84 @@ void asurvivingOutboxIsNotDrainedUntilTheErasureFinishes() { "the erasure was never retried"); } + @FormTest + void asurvivingCodeIsNotClaimedUnderTheNewIdentity() { + // The retry gate lived only in drainOutbox(), and the lookup path had + // none. A PENDING record that outlived its erasure still carried the + // code a direct link left on the device, and the next checkForInvite() + // reloaded it and claimed it under the NEW client id -- which is the + // transmission the erasure existed to prevent, made by the erasure's + // own aftermath. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.handleUrl("https://cloud.codenameone.com/i/ABC123"); + implementation.clearQueuedRequests(); + + InviteStore.failNextDeleteForTest(InviteStore.PENDING); + assertFalse(Invites.eraseInternal(), "the fixture's erasure did not fail"); + // The record really did survive, or this test proves nothing about the + // gate: a deleted record cannot be claimed either way. + assertFalse(InviteStore.read(InviteStore.PENDING) == null + || InviteStore.read(InviteStore.PENDING).isEmpty(), + "the fixture did not leave a surviving record to claim"); + + // Storage is still refusing, so the retry inside the gate fails too and + // nothing may proceed. + InviteStore.failNextDeleteForTest(InviteStore.PENDING); + Invites.checkForInvite(); + + assertEquals(0, implementation.getQueuedRequests().size(), + "a code that survived an erasure was claimed under the new identity"); + } + + @FormTest + void afreshInviteIsNotAppendedToAQueueTheErasureWillDelete() { + // create() appended to whatever outbox was on the disk. An outbox that + // survived an erasure is deleted WHOLE by the retry inside the next + // drain -- which create() itself triggers through flush() -- so the + // invite just minted went with it. Having reported success, nothing + // held its code, and isRegistered() answered true about a registration + // the server was guaranteed never to have seen. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.create(InviteRequest.create().campaign("old").build()); + assertFalse(InviteStore.readOutbox().isEmpty(), "the fixture queued nothing"); + + InviteStore.failNextDeleteForTest(InviteStore.OUTBOX); + assertFalse(Invites.eraseInternal(), "the fixture's erasure did not fail"); + + // Storage recovers, which is the case the finding is about: the retry + // inside create() now succeeds, so the stale queue goes and the new + // invite is appended to a clean one rather than to a doomed one. + Invite fresh = Invites.create(InviteRequest.create().campaign("new").build()); + Invites.flush(); + + assertFalse(Invites.isRegistered(fresh), + "an invite that was never acknowledged reported itself registered"); + } + + @FormTest + void emptyingTheQueueIsVerifiedLikeEveryOtherDelete() { + // The last acknowledged registration empties the outbox, and that path + // called deleteStorageFile() and returned success without looking. + // On a port where the delete silently fails the entry stays durable, + // so every later flush resends an already acknowledged registration + // while isRegistered() goes on answering false about it. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.create(InviteRequest.create().campaign("launch").build()); + assertFalse(InviteStore.readOutbox().isEmpty(), "the fixture queued nothing"); + + InviteStore.failNextDeleteForTest(InviteStore.OUTBOX); + assertFalse(InviteStore.writeOutbox(new java.util.ArrayList()), + "emptying the queue reported success without verifying the delete"); + + // And the ordinary case still empties it and says so. + assertTrue(InviteStore.writeOutbox(new java.util.ArrayList()), + "emptying the queue failed when the store was willing"); + assertTrue(InviteStore.readOutbox().isEmpty(), "the queue survived"); + } + @FormTest void registeringTheProviderIsNotMistakenForAnErasure() { InviteTestSupport.freshInstall(); From 01321066418920089d61eb3fa0ea92133a701fc2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:08:24 +0300 Subject: [PATCH 44/99] Invites: a transient store failure is not an answer, and the clip is iOS only Two review findings, both real. fallBackToMatch(true) records referrerRetry for a Play failure that may be readable next launch -- the service was busy, the bind did not take, the service dropped before answering -- and then hands over to the App Clip handoff. On Android there is no clip, so control arrives at the settle path immediately, and that path consulted the retry flag nowhere. It wrote a permanent STATE_NONE_FOUND over a referrer that was there the whole time, and no later flush or relaunch could reach it. Making the disconnect callback answer at all, in the previous commit, is what made this reachable often rather than rarely. The exact "referrer read, no invite" answer stays final, which is the half that keeps an ordinary uninvited install from asking on every launch for ever. Both are pinned, and the fixture had to register a null App Clip source to model Android at all -- with the test harness's parked clip source in place the settle path is never reached and either assertion passes for the wrong reason. The App Clip target also needed the Catalyst guard every other iOS-only target here carries, and needed it more than most: an App Clip does not exist on the Mac, so an unfiltered dependency makes the Catalyst destination build a target whose product type is unsupported there and then embed it in the Mac app, failing the archive for a slice that could never have used it. Also the developer-guide prose gates: contractions, one British spelling and one "an invite" the style checks reject. --- .../codename1/analytics/invite/Invites.java | 38 +++++++ docs/developer-guide/Analytics.asciidoc | 8 +- .../codename1/build/shared/BuildHintsIos.java | 2 +- .../com/codename1/builders/IPhoneBuilder.java | 19 +++- .../invite/InviteResilienceTest.java | 103 ++++++++++++++++++ 5 files changed, 163 insertions(+), 7 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index cdc69113d36..54e207e6b87 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -2135,6 +2135,27 @@ private static void settleNoHandoff(String reason) { if (abandonReplacement()) { return; } + // A transient referrer failure is not an answer about this install. + // + // fallBackToMatch(true) records referrerRetry for exactly that case -- + // the Play service was busy, the bind did not take, the service + // dropped before it answered -- and then hands over to the clip + // handoff. On Android there is no clip, so control arrives here + // immediately and wrote a PERMANENT no-match over a referrer that was + // there the whole time and readable on the next launch. The retry + // marker was consulted on the server's answer and nowhere else, so + // this path discarded it. + // + // Silent, like the matching branch in handleResolution: + // attributionUnavailable() means no invite will ever be attributed, + // and this is the opposite of terminal. The attempt cap and the + // attribution window still bound how long it can go on. + Map outstanding = readPending(); + if (outstanding != null + && "true".equals(InviteStore.get(outstanding, "referrerRetry", null))) { + setState(STATE_PENDING); + return; + } if (markTerminal(reason)) { notifyUnavailable(reason); } @@ -2350,6 +2371,23 @@ static void handleResolution(String payload, String matchType, boolean deferred, } applySlug(payload); if (!truthy(json.get("resolved"))) { + if (truthy(json.get("retry"))) { + // "Not yet", not "no". The server has never seen this code + // at all, which during the offline-mint window is the + // normal state of a perfectly good invite: the inviter + // minted it with no network and their registration has not + // landed yet. Settling here reported an invited install as + // organic, permanently, seconds before the code became + // claimable. + // + // Silent for the same reason the referrer retry below is: + // attributionUnavailable() means no invite will ever be + // attributed, and this is the opposite of terminal. The + // existing attempt cap and attribution window bound how + // long this can go on. + setState(STATE_PENDING); + return; + } // Not terminal while a deterministic answer is still // reachable. The Play referrer failed transiently -- the store // was busy, the bind did not take -- and the source keeps its diff --git a/docs/developer-guide/Analytics.asciidoc b/docs/developer-guide/Analytics.asciidoc index 3c1f8d0d960..627cc0e4f73 100644 --- a/docs/developer-guide/Analytics.asciidoc +++ b/docs/developer-guide/Analytics.asciidoc @@ -244,14 +244,14 @@ Nothing at all is collected about someone who only taps a link. An earlier desig Referencing this package makes the build wire the platform side: an `autoVerify` App Links intent filter on Android, an associated domain on iOS, and the Play Install Referrer dependency. An app that only reports analytics gets none of it. -On iOS it also generates the App Clip, because there is no attribution without one. The clip is a separate binary embedded in your app: a few hundred lines of UIKit that show your app's name, offer to install it, and record the invite code the link handed them. It is not a Codename One application and does not run your code. You do not write it, open it or maintain it. +On iOS it also generates the App Clip, because there is no attribution without one. The clip is a separate binary embedded in your app: a few hundred lines of UIKit that show your app's name, offer to install it, and record the invite code the link handed them. It's not a Codename One application and doesn't run your code. You don't write it, open it or maintain it. Three things the generated clip needs from you, and each fails in its own way: -* The App Store identifier of your app, in `ios.invite.appStoreId`. Without it the clip still records the code, it simply shows no install sheet -- which is the right behaviour before your first release, when the app does not exist in the store yet. -* An App Group registered on your developer account. The build derives one from your package name and adds it to `ios.app_groups`; override it with `ios.invite.appGroup` if you already have one. A group that is not registered signs cleanly and the clip and the app can then never reach each other, which is the failure with no symptom. +* The App Store identifier of your app, in `ios.invite.appStoreId`. Without it the clip still records the code, it simply shows no install sheet -- which is the right behavior before your first release, when the app doesn't exist in the store yet. +* An App Group registered on your developer account. The build derives one from your package name and adds it to `ios.app_groups`; override it with `ios.invite.appGroup` if you already have one. A group that isn't registered signs cleanly and the clip and the app can then never reach each other, which is the failure with no symptom. * An App Clip enabled on your App ID, alongside Associated Domains. -* An Advanced App Clip Experience registered in App Store Connect for your invite link prefix. Codename One authorises your clip for the domain; what maps a particular link to a particular clip is that experience, and until it exists tapping an invite link shows no clip card at all. The console shows the prefix to register once invites are switched on. +* An Advanced App Clip Experience registered in App Store Connect for your invite link prefix. Codename One authorizes your clip for the domain; what maps a particular link to a particular clip is that experience, and until it exists tapping the invite link shows no clip card at all. The console shows the prefix to register once invites are switched on. Set `ios.invite.appClip` to `false` only if you ship an App Clip of your own. The app then reports every install as organic unless your clip writes the handoff itself. diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java index 1b0d4846d75..9c95f57b701 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java @@ -228,7 +228,7 @@ static void register(List h) { .platform("ios") .doc("Whether the build generates and embeds the App Clip that makes invite " + "attribution exact on iOS. The App Store carries no referrer of its " - + "own, so without the clip an iOS install cannot be attributed at all. " + + "own, so without the clip an iOS install can't be attributed at all. " + "Set it to `false` only if you ship an App Clip of your own; the build " + "then writes no clip, and the app reports every install as organic " + "unless your clip writes the handoff itself. Ignored when " diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index b3203a644d3..0964e39124c 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -12376,8 +12376,23 @@ private void appendInviteAppClipTarget(StringBuilder sb, BuildRequest request, + "embed_phase.dst_subfolder_spec = \"16\"\n" + "embed_phase.dst_path = \"$(CONTENTS_FOLDER_PATH)/AppClips\"\n" + "embed_phase.run_only_for_deployment_postprocessing=\"0\"\n" - + "embed_phase.add_file_reference(fileref)\n" - + "clip_target.build_configurations.each{|e| \n"); + + "embed_file = embed_phase.add_file_reference(fileref)\n"); + if (macNativeBuilder.isEnabled()) { + // Same guard every other iOS-only target here carries, and this + // one needs it more than most: an App Clip does not exist on the + // Mac at all. Left unfiltered, the Catalyst destination builds a + // target whose whole product type is unsupported there and then + // tries to place it inside the Mac app, which fails the archive -- + // for a slice that could never have used it. The iOS app keeps its + // clip; the Mac slice ships without one, which costs nothing, + // because a Mac install was never attributed through a clip. + sb.append("dep = main_app_target.dependencies.find{|d| d.target" + + " && d.target.uuid == clip_target.uuid}\n" + + "dep.platform_filter = 'ios' if dep\n" + + "embed_file.platform_filter = 'ios'\n"); + buildSettingsMap.put("SUPPORTS_MACCATALYST", "NO"); + } + sb.append("clip_target.build_configurations.each{|e| \n"); for (String buildSettingKey : buildSettingsMap.keySet()) { sb.append(" e.build_settings['" + escapeRuby(buildSettingKey) + "'] = \"" + escapeRubyDoubleQuoted(buildSettingsMap.get(buildSettingKey)) + "\"\n"); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index f5ecc49ebb2..b4d8cf95e9c 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -126,6 +126,40 @@ void aNoMatchAnswerIsNotAskedAgainOnTheNextLaunch() { "the terminal answer did not survive a relaunch"); } + @Test + @EdtTest + void aCodeTheServerHasNotSeenYetIsNotSettledAsOrganic() { + // The offline-mint window. An invite minted with no network is handed + // over before its registration reaches the server, so a claim can + // arrive first -- and the server has never heard of the code. Read as + // a final "no invite", that settled the install as organic + // permanently, seconds before the code became claimable, which loses + // exactly the attribution the offline mint exists to preserve. + Invites.checkForInvite(); + Invites.handleResolution("{\"resolved\":false,\"retry\":true}", + Invites.MATCH_APP_CLIP, true); + assertEquals(Invites.STATE_PENDING, Invites.getState(), + "a not-yet answer was treated as a final no"); + + // And the real answer still lands when the registration catches up. + Invites.handleResolution(InviteTestSupport.resolvedJson("LATE1", "spring", "sms"), + Invites.MATCH_APP_CLIP, true); + assertEquals(Invites.STATE_RESOLVED, Invites.getState()); + assertNotNull(Invites.getAttribution(), "the late answer was refused"); + } + + @Test + @EdtTest + void aPlainNoIsStillFinalEvenBesideTheRetryAnswer() { + // The retry flag must not soften the ordinary case. Most installs are + // not invited, and an uninvited one that keeps asking contacts the + // server on every launch for ever. + Invites.checkForInvite(); + Invites.handleResolution("{\"resolved\":false,\"retry\":false}", + Invites.MATCH_APP_CLIP, true); + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState()); + } + @Test @EdtTest void theTerminalMarkerKeepsNoDeviceProfile() { @@ -475,6 +509,75 @@ public void attributionUnavailable(String reason) { "a refused direct link told the listener nothing"); } + @Test + @EdtTest + void aTransientReferrerFailureIsNotSettledAsOrganic() { + // The Play service was busy, the bind did not take, or it dropped + // before answering. None of those is an answer about this install, and + // the source keeps its once-only flag unset precisely so a later + // launch can read the exact referrer. + // + // On Android the retry marker was written and then ignored: there is + // no App Clip to fall through to, so control reached the settle path + // immediately and wrote a PERMANENT no-match over a referrer that was + // readable the whole time. + // Android, so there is no clip to fall through to -- which is the + // whole point: the settle path is reached immediately instead of + // parking on a handoff that would keep the lookup alive by itself. + Invites.registerAppClipHandoffSource(null); + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + callback.onUnavailable(Invites.REASON_NO_MATCH); + } + }); + Invites.checkForInvite(); + + assertEquals(Invites.STATE_PENDING, Invites.getState(), + "a transient store failure was settled as a final no"); + assertFalse(Invites.getState() == Invites.STATE_NONE_FOUND); + + // And the exact answer still lands when the store recovers. + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + callback.onReferrer("utm_source=cn1_invite&cn1_invite=LATER1", 0L, 0L); + } + }); + Invites.flush(); + Map pending = InviteStore.read(InviteStore.PENDING); + assertEquals("LATER1", InviteStore.get(pending, "code", null), + "the retried referrer was never read"); + } + + @Test + @EdtTest + void anExactReferrerAnswerOfNoInviteIsStillFinal() { + // The referrer was READ and carries no invite: a real answer, and a + // permanent one. The retry path must not swallow this case, or an + // ordinary uninvited install asks again on every launch for ever. + Invites.registerAppClipHandoffSource(null); + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + callback.onReferrer("utm_source=google-play&utm_medium=organic", 0L, 0L); + } + }); + Invites.checkForInvite(); + + assertEquals(Invites.STATE_NONE_FOUND, Invites.getState(), + "an exact 'no invite' answer was left pending"); + } + @Test @EdtTest void theReferrerCodeIsPersistedBeforeTheClaimGoesOut() { From e18bff55bbac569e0e663e9e99f4e1101a34ff52 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:23:42 +0300 Subject: [PATCH 45/99] Invites: the retry could not deliver, and the app group was comma-joined Three review findings, all real. The completion guard added in the previous commit was per INSTANCE, and Invites keeps one source and calls it again on a later flush. So a transient failure set `answered` for good: the retry then read an exact referrer, deliver() recorded PREF_ATTEMPTED, and referrer() suppressed the callback -- the code was read and thrown away, and no relaunch could ask for it again. The guard that was meant to stop a stale disconnect answering twice stopped the real answer arriving at all. It resets per exchange now, and a per-attempt sequence replaces the boolean that only covered the retry. Two different things supersede a listener and both had to be caught: the retry, whose close() fires the OLD listener's disconnect, and a later flush, which starts an exchange a lingering listener from the previous one would otherwise answer. The sequence advances BEFORE the retry's close, because ending a connection is what fires its own disconnect -- closing while the attempt is still current lets that disconnect answer "no referral" for a store that had not been asked yet. Not unit tested, and cannot be here: Ports/Android/.../referrer is excluded from the module build because it names com.android.installreferrer, so the whole class has no test coverage in this repo. Verified by compiling it against the platform jar and a stub of that API. The App Clip's app group was appended to ios.app_groups with a comma. The cloud builder's generateEntitlements splits that argument on " " alone, so an app that already had a group received one malformed identifier matching neither -- it signs, and then cannot open the container it shares with its own clip. An app with no other group never saw it, because there was nothing to join to. Space now, through declaresAppGroup, which compares entry by entry rather than by substring. Note the same comma appears in the Surfaces, Documents and Matter blocks here and is the same bug; it is left alone deliberately rather than swept into an invite change. And the public API contract still promised MATCH_FINGERPRINT and probabilistic attribution. Callers could branch on a constant that no longer exists, or discount an App Clip match that is exact. InviteAttribution now documents the three real match types and says plainly that all of them are exact; getConfidence() keeps answering 1 and says why it still exists. --- .../analytics/invite/InviteAttribution.java | 31 ++++-- .../codename1/analytics/invite/Invites.java | 7 +- .../referrer/AndroidInstallReferrer.java | 95 +++++++++++++------ .../com/codename1/builders/IPhoneBuilder.java | 33 ++++--- 4 files changed, 109 insertions(+), 57 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteAttribution.java b/CodenameOne/src/com/codename1/analytics/invite/InviteAttribution.java index 1f9dce1eb64..995a895b217 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteAttribution.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteAttribution.java @@ -29,12 +29,22 @@ /// The invite that caused this install or open. Immutable; delivered to an /// [InviteListener]. /// -/// Read [#getMatchType] before acting on this. A deterministic match came -/// through the store or from a code the user entered and is exact. A -/// [Invites#MATCH_FINGERPRINT] match is a statistical guess made on the -/// server, because the App Store carries no referrer of its own, and it is -/// occasionally wrong. Do not pay a referral bounty on a probabilistic match -/// without saying so. +/// [#getMatchType] says how the invite was identified, and every answer is +/// exact: +/// +/// - [Invites#MATCH_DIRECT] -- the link opened an app that was already +/// installed. +/// - [Invites#MATCH_REFERRER] -- the code travelled through Google Play and +/// came back verbatim. +/// - [Invites#MATCH_APP_CLIP] -- an iOS App Clip was launched by the invite +/// link, so it received the code exactly, and handed it to the app the +/// person then installed. +/// +/// Nothing here is matched, estimated or guessed, so a referral bounty can be +/// paid on any of them. An earlier design added a statistical match for iOS, +/// because the App Store carries no referrer of its own; App Clips made it +/// unnecessary and it is gone, along with the profile of the visitor it +/// needed. public final class InviteAttribution { private final String code; private final String campaign; @@ -104,7 +114,7 @@ public String getPayload() { } /// How this attribution was established: [Invites#MATCH_DIRECT], - /// [Invites#MATCH_REFERRER] or [Invites#MATCH_FINGERPRINT]. + /// [Invites#MATCH_REFERRER] or [Invites#MATCH_APP_CLIP]. /// /// #### Returns /// @@ -113,8 +123,11 @@ public String getMatchType() { return matchType; } - /// How much to trust this attribution, from 0 to 1. Both deterministic - /// match types report 1; a fingerprint match reports the server's score. + /// How much to trust this attribution, from 0 to 1. + /// + /// Always 1. Every match type is exact now, so there is nothing left for + /// this to discount -- it survives because an application that branched on + /// it should keep compiling and keep taking the same branch. /// /// #### Returns /// diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 54e207e6b87..83d2a189780 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -250,9 +250,8 @@ public final class Invites { /// A direct link was the worst case: `handleUrl` committed STATE_PENDING /// and issued the claim, and if that request also failed, the exact code /// existed nowhere. The retry then read a record with no code in it and - /// fell back to the install referrer or the fingerprint -- answering a - /// question the device already had an exact answer to, with a guess or not - /// at all. + /// fell back to the install referrer or the App Clip handoff -- asking a + /// question the device already had an exact answer to. /// /// Held only while the durable copy is missing: a successful write clears /// it, so this can never disagree with what is on the disk. It does not @@ -994,7 +993,7 @@ public static void flush() { } /// Forgets every trace of invite attribution on this device: the pending - /// fingerprint, the resolved attribution and the referral dimensions. + /// lookup, the resolved attribution and the referral dimensions. /// /// [Analytics#resetClientId] triggers this for you, because an erasure /// that left the referral dimensions behind would re-link the fresh diff --git a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java index a8a437c0cd4..d158354f827 100644 --- a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java +++ b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java @@ -51,12 +51,28 @@ public class AndroidInstallReferrer implements InstallReferrerSource { private boolean retried; - // Whether the framework has been given its one answer. The SPI promises - // exactly one call, and the disconnect handler added below can arrive - // after a real answer as easily as instead of one -- ending a connection - // is itself what fires it. + // Whether the framework has been given its one answer FOR THIS EXCHANGE. + // The SPI promises exactly one call, and the disconnect handler below can + // arrive after a real answer as easily as instead of one -- ending a + // connection is itself what fires it. + // + // Reset by requestReferrer, and that reset is load bearing. Invites keeps + // one source instance and calls it again on a later flush; left set from a + // transient failure, this suppressed the retry's answer while deliver() + // had already recorded the read as attempted, so an exact code was read + // and thrown away and no relaunch could ask for it again. private boolean answered; + // Which attempt is current. Every bind captures this and answers only + // while it still matches. + // + // One counter rather than a flag per attempt, because two different things + // supersede a listener and both have to be caught: the retry below, whose + // close() fires the OLD listener's disconnect, and a later flush, which + // starts a whole new exchange that a lingering listener from the previous + // one would otherwise answer. + private int attemptSeq; + @Override public boolean isSupported() { return AndroidNativeUtil.getContext() != null @@ -65,9 +81,19 @@ public boolean isSupported() { @Override public void requestReferrer(InstallReferrerCallback callback) { + // A NEW exchange, so both guards start clean. The internal retry does + // not come through here -- it calls attempt() directly -- because + // resetting `retried` there would turn one allowance into a loop. + answered = false; + retried = false; + attempt(callback); + } + + private void attempt(InstallReferrerCallback callback) { + attemptSeq++; Context context = AndroidNativeUtil.getContext(); if (context == null) { - unavailable(callback, Invites.REASON_UNSUPPORTED); + unavailable(attemptSeq, callback, Invites.REASON_UNSUPPORTED); return; } try { @@ -77,23 +103,25 @@ public void requestReferrer(InstallReferrerCallback callback) { // never as a crash: the application still works, it simply has no // invite behind it. Log.e(t); - finish(callback, Invites.REASON_UNSUPPORTED); + finish(attemptSeq, callback, Invites.REASON_UNSUPPORTED); } } private void connect(final InstallReferrerClient client, final InstallReferrerCallback callback) { - // Per ATTEMPT, not per instance. The retry below ends this connection, - // which fires this listener's own disconnect -- and that must not be - // read as the retried connection failing. - final boolean[] superseded = new boolean[1]; + // Captured, not read at callback time. The retry below ends this + // connection, which fires this listener's own disconnect, and a later + // flush starts a whole new exchange -- a listener that read the field + // when it fired would inherit whichever attempt is current and answer + // for it. + final int issued = attemptSeq; client.startConnection(new InstallReferrerStateListener() { @Override public void onInstallReferrerSetupFinished(int responseCode) { try { switch (responseCode) { case InstallReferrerClient.InstallReferrerResponse.OK: - deliver(client, callback); + deliver(issued, client, callback); break; case InstallReferrerClient.InstallReferrerResponse.SERVICE_UNAVAILABLE: // Transient. Exactly one retry: a loop here would @@ -101,9 +129,18 @@ public void onInstallReferrerSetupFinished(int responseCode) { // never going to answer. if (!retried) { retried = true; - superseded[0] = true; + // The sequence advances BEFORE the close, and + // that order is the whole guard. Ending a + // connection is what fires its own listener's + // disconnect, so closing while this attempt is + // still current lets that disconnect answer + // the exchange the retry was about to make -- + // with "no referral", for a store that had not + // been asked yet. attempt() advances it again, + // which only skips a number. + attemptSeq++; close(client); - requestReferrer(callback); + attempt(callback); return; } // Transient, so it is NOT recorded as attempted. @@ -113,7 +150,7 @@ public void onInstallReferrerSetupFinished(int responseCode) { // skip the deterministic path and fall back to a // statistical guess for a referrer we could have // read exactly. - unavailable(callback, Invites.REASON_NO_MATCH); + unavailable(issued, callback, Invites.REASON_NO_MATCH); break; default: // FEATURE_NOT_SUPPORTED is the ordinary answer on a @@ -122,14 +159,14 @@ public void onInstallReferrerSetupFinished(int responseCode) { // store. Terminal: this device will never have a // referrer, so the flag is recorded and the bind is // not attempted again. - finish(callback, Invites.REASON_UNSUPPORTED); + finish(issued, callback, Invites.REASON_UNSUPPORTED); break; } } catch (Throwable t) { // Unknown failure: treated as transient, so a later flush // can still read a referrer that is genuinely there. Log.e(t); - unavailable(callback, Invites.REASON_NO_MATCH); + unavailable(issued, callback, Invites.REASON_NO_MATCH); } finally { close(client); } @@ -151,15 +188,13 @@ public void onInstallReferrerServiceDisconnected() { // Reported as transient, which is what it is -- the once-only // flag stays unburnt, so a later flush can still read a // referrer that was there the whole time. - if (superseded[0]) { - return; - } - unavailable(callback, Invites.REASON_NO_MATCH); + unavailable(issued, callback, Invites.REASON_NO_MATCH); } }); } - private void deliver(InstallReferrerClient client, InstallReferrerCallback callback) { + private void deliver(int issued, InstallReferrerClient client, + InstallReferrerCallback callback) { String referrer = ""; long clickSeconds = 0; long beginSeconds = 0; @@ -186,20 +221,20 @@ private void deliver(InstallReferrerClient client, InstallReferrerCallback callb // statistical no-match settles the install as organic -- for a // referrer that was there all along and simply could not be read // this once. - unavailable(callback, Invites.REASON_NO_MATCH); + unavailable(issued, callback, Invites.REASON_NO_MATCH); return; } Preferences.set(PREF_ATTEMPTED, true); if (referrer == null || referrer.length() == 0) { - unavailable(callback, Invites.REASON_NO_MATCH); + unavailable(issued, callback, Invites.REASON_NO_MATCH); return; } - referrer(callback, referrer, clickSeconds, beginSeconds); + referrer(issued, callback, referrer, clickSeconds, beginSeconds); } - private void finish(InstallReferrerCallback callback, String reason) { + private void finish(int issued, InstallReferrerCallback callback, String reason) { Preferences.set(PREF_ATTEMPTED, true); - unavailable(callback, reason); + unavailable(issued, callback, reason); } /// Reports "no referral", at most once. @@ -207,17 +242,17 @@ private void finish(InstallReferrerCallback callback, String reason) { /// Every terminal path goes through here so the disconnect handler can /// close an exchange nobody else closed without risking a second answer /// for one that somebody did. - private void unavailable(InstallReferrerCallback callback, String reason) { - if (answered) { + private void unavailable(int issued, InstallReferrerCallback callback, String reason) { + if (answered || issued != attemptSeq) { return; } answered = true; callback.onUnavailable(reason); } - private void referrer(InstallReferrerCallback callback, String value, + private void referrer(int issued, InstallReferrerCallback callback, String value, long clickSeconds, long beginSeconds) { - if (answered) { + if (answered || issued != attemptSeq) { return; } answered = true; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 0964e39124c..bed334f6de2 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4485,23 +4485,28 @@ public void usesClassMethod(String cls, String method) { // which needed it to decide whether to register a reader at // all. Re-deriving it here would let the two disagree. String group = inviteAppClipGroup; - // Entry by entry, never a substring test: group.com.acme.shared - // contains group.com.acme, and deciding the group is already - // present on that basis entitles the clip for one group and the - // application for another -- two processes that sign, install, - // and never meet. + // SPACE, not a comma, and read through declaresAppGroup. + // + // generateEntitlements splits ios.app_groups on " " alone, so + // a comma-joined pair reaches the device as a single + // "group.a,group.b", which matches neither configured group. + // The app then signs and cannot open the container it shares + // with its own clip, which + // is this feature failing with no error anywhere. An app with + // no other app group never saw it, because there was nothing + // to join to. + // + // declaresAppGroup compares entry by entry and tolerates + // either separator when reading, which is both what makes this + // safe against a hand-written comma list and what hid the bug: + // group.com.acme.shared contains group.com.acme, and a + // substring test would decide the group was already present + // and entitle the clip for one group and the app for another. String appGroups = request.getArg("ios.app_groups", ""); - boolean present = false; - for (String candidate : appGroups.split(",")) { - if (candidate.trim().equals(group)) { - present = true; - break; - } - } - if (!present) { + if (!declaresAppGroup(appGroups, group)) { request.putArgument("ios.app_groups", appGroups.trim().length() == 0 ? group - : appGroups.trim() + "," + group); + : appGroups.trim() + " " + group); } try { replaceInFile(new File(buildinRes, From d2383285ea1369d16536dab1a28fc30edd0bc052 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:09:54 +0300 Subject: [PATCH 46/99] Invites: a reset that could not finish, and a tap time nobody kept Two review findings, plus two defects of my own found while verifying them. reset() threw away resetVerified()'s answer. The detection added last round was real and then dropped at the one call site an application reaches, so a PENDING record that outlived its erasure was still claimed on the next launch and a surviving outbox entry still went out under the old client id. It latches the gate now -- but only when something really did survive. resetVerified() also answers false for a reason that leaves nothing behind, no Storage at all, and latching on that would block a device with no invite data to block over: nothing else proceeds until an erasure succeeds, and that is a severe consequence to hang on a device state. The mirror image was also wrong, and was mine from the previous commit: only eraseInternal() ever cleared the flag, so a plain reset() that SUCCEEDED left a stale latch standing, and the next gated call then ran a full erasure -- tombstone included -- turning an ordinary reset() into a terminal state the application never asked for. A successful reset clears it, which is what the flag has always claimed to mean. The tap time is kept now. An App Clip invocation is resolved by iOS from the association file, so it never reaches our redirect: the clip is the only witness, and the native side clears the handoff as it reads it. Dropped in the callback it was gone, and getClickTimestamp() answered zero for every App Clip attribution. It is persisted in the pending record -- the claim can fail and be resent from there -- and sent as clickedMillis. The Play referrer had the identical bug and the finding did not mention it: onReferrer carries clickSeconds and nothing read it either. Fixed together, because fixing one platform and not the other leaves the two reporting the same field differently, which is worse than both being wrong. Asserted as a bare JSON number rather than a quoted one, because the server binds it to a long: a string coerces today and stops the moment anything there gets stricter. And the PMD gate caught five violations I had pushed: PATH_MATCH left behind by the deleted /match endpoint, and four missing @Override on the App Clip callback, which the install-referrer callback directly above it has. I had been skipping the static-analysis gates locally because SpotBugs will not run under this JDK; PMD does, through pmd:pmd, and it is clean now -- verified by injecting an unused field and confirming it is reported, because a report that is empty because nothing was analysed looks exactly like one that is empty because nothing is wrong. --- .../analytics/invite/InviteAttribution.java | 13 +- .../codename1/analytics/invite/Invites.java | 119 +++++++++++++++++- .../analytics/invite/InviteDeliveryTest.java | 36 ++++++ .../analytics/invite/InviteTestSupport.java | 7 +- 4 files changed, 166 insertions(+), 9 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteAttribution.java b/CodenameOne/src/com/codename1/analytics/invite/InviteAttribution.java index 995a895b217..e620a1aa5e1 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteAttribution.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteAttribution.java @@ -146,12 +146,19 @@ public boolean isDeferred() { return deferred; } - /// When the link was clicked, in milliseconds since the epoch, or 0 when - /// unknown. + /// When the link was tapped, in milliseconds since the epoch, or 0 when + /// nothing observed it. + /// + /// Zero is a real answer and not rare. The tap is observed by whichever + /// side of the exchange saw it: the invite redirect for a link that + /// reached it, or the App Clip for an iOS invocation, which iOS resolves + /// from the association file without ever reaching the redirect. An + /// install whose tap neither side recorded has no time to report, so + /// compare against 0 before subtracting it from anything. /// /// #### Returns /// - /// the click time + /// the click time, or 0 public long getClickTimestamp() { return clickTimestamp; } diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 83d2a189780..20674396ee7 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -186,7 +186,6 @@ public final class Invites { private static final String DEFAULT_BASE_URL = "https://cloud.codenameone.com"; private static final String PATH_MINT = "/api/v2/analytics/invites"; private static final String PATH_CLAIM = "/api/v2/analytics/invites/claim"; - private static final String PATH_MATCH = "/api/v2/analytics/invites/match"; // Package private so the unit tests can clear them between cases. /// Display property carrying the invite host the build registered, stamped @@ -999,7 +998,58 @@ public static void flush() { /// that left the referral dimensions behind would re-link the fresh /// identity to the same inviter. public static void reset() { - resetVerified(); + if (!resetVerified()) { + // The records did not go, and this method promised they would. + // + // Dropping the answer here left nothing blocked and nothing + // retrying: a surviving PENDING record still carried its code, so + // the next checkForInvite() claimed it, and a surviving outbox + // entry still carried the old client id for the next flush. The + // detection added inside resetVerified() was real and then thrown + // away at the one call site an application reaches. + // + // Setting the flag is what settleErasure() gates every lookup, + // claim and enqueue on, so nothing proceeds until a retry + // succeeds. That retry runs eraseInternal(), which also writes the + // tombstone -- so a reset that had to be retried ends terminal + // rather than looking like a fresh install. That divergence is + // deliberate: it only happens when the store refused, and there + // the safe answer is to attribute nothing rather than to start a + // fresh lookup over records that are still on the disk. + // + // Latched only when something really did survive, because the + // consequence is severe and permanent-looking: nothing else + // proceeds until an erasure succeeds, and only eraseInternal() + // clears the flag. resetVerified() also answers false for a + // reason that leaves nothing behind -- no Storage at all, which + // is a device state rather than a refusal -- and latching on that + // would block a device that has no invite data to block over. + if (anythingSurvives()) { + erasurePending = true; + } + } + } + + /// Whether any durable invite record is still readable. + /// + /// The question a failed reset actually has to answer. A delete that could + /// not run because there was no storage at all leaves nothing behind and + /// is not the failure the erasure gate exists for; a record still on the + /// disk is. + /// + /// #### Returns + /// + /// true when a record, an attribution or a queued registration remains + private static boolean anythingSurvives() { + Map record = InviteStore.read(InviteStore.PENDING); + if (record != null && !record.isEmpty()) { + return true; + } + Map attribution = InviteStore.read(InviteStore.ATTRIBUTION); + if (attribution != null && !attribution.isEmpty()) { + return true; + } + return !InviteStore.readOutbox().isEmpty(); } /// The same work, reporting whether the durable records really went. @@ -1047,6 +1097,15 @@ static boolean resetVerified() { lookupIssuedAt = 0; undelivered = null; unacknowledged.clear(); + if (cleared) { + // The flag means "records survived an erasure", and they + // demonstrably did not survive this one. Only eraseInternal() + // cleared it before, so a plain reset() that succeeded left the + // stale latch standing -- and the next gated call then ran a full + // erasure, tombstone included, turning an application's ordinary + // reset() into a terminal state it never asked for. + erasurePending = false; + } return cleared; } @@ -1868,7 +1927,8 @@ private static void beginDeferred() { String matchType = InviteStore.get(pending, "codeMatch", MATCH_DIRECT); boolean deferred = InviteStore.getBoolean(pending, "codeDeferred", false); claim(code, source, InviteStore.get(pending, "codeReferrer", ""), - matchType, deferred); + matchType, deferred, + InviteStore.getLong(pending, "codeClicked", 0)); return; } InstallReferrerSource source = referrerSource; @@ -1938,11 +1998,25 @@ public void run() { pending.put("codeDeferred", "true"); InviteStore.put(pending, "codeReferrer", rawReferrer == null ? "" : rawReferrer); + // Play reports the tap time too, and it was being + // dropped for the same reason the clip's was: read + // from the callback and never written down. The + // redirect DID see this tap, so the server usually + // has its own record -- but not for a link opened + // from a place the redirect never ran, and not + // after retention has swept the click. Carrying it + // costs nothing and makes the two platforms report + // the same field the same way. + if (clickSeconds > 0) { + pending.put("codeClicked", + String.valueOf(clickSeconds * 1000L)); + } pending.remove("referrerRetry"); writePending(pending); claim(code, "install_referrer", rawReferrer == null ? "" : rawReferrer, - MATCH_REFERRER, true); + MATCH_REFERRER, true, + clickSeconds > 0 ? clickSeconds * 1000L : 0); } }); } @@ -2079,8 +2153,10 @@ private static void requestAppClipHandoff(final Map pending) { // this answer belongs to. final int issued = lookupEpoch; source.requestHandoff(new AppClipHandoffCallback() { + @Override public void onHandoff(final String code, final long clickedSeconds) { onEdt(new Runnable() { + @Override public void run() { if (issued != lookupEpoch) { return; @@ -2106,17 +2182,36 @@ public void run() { record.put("codeMatch", MATCH_APP_CLIP); record.put("codeDeferred", "true"); record.put("codeReferrer", ""); + // The tap time, and this is the only place it exists. + // + // An App Clip invocation is resolved by iOS from the + // association file, so it never reaches our redirect + // and the server has no click of its own to date the + // funnel from. The clip observed the tap and the + // native side cleared the handoff as it read it, so a + // value dropped here is gone -- and every App Clip + // attribution reported a click time of zero. + // + // Persisted in the record rather than only passed on, + // because the claim can fail and be resent from here. + if (clickedSeconds > 0) { + record.put("codeClicked", + String.valueOf(clickedSeconds * 1000L)); + } writePending(record); // Claimed exactly as a referrer code is: the trip // through the store is what makes both of them exact, // and the server treats them the same way. - claim(code, "app_clip", "", MATCH_APP_CLIP, true); + claim(code, "app_clip", "", MATCH_APP_CLIP, true, + clickedSeconds > 0 ? clickedSeconds * 1000L : 0); } }); } + @Override public void onUnavailable(final String reason) { onEdt(new Runnable() { + @Override public void run() { if (issued != lookupEpoch) { return; @@ -2162,6 +2257,17 @@ private static void settleNoHandoff(String reason) { private static void claim(String code, String source, String rawReferrer, final String matchType, final boolean deferred) { + claim(code, source, rawReferrer, matchType, deferred, 0); + } + + /// clickedMillis: when the link was tapped, as the device observed it, or + /// 0 when nothing on the device saw it. Only an App Clip has this: iOS + /// resolves a clip invocation from the association file, so that tap never + /// reaches the redirect and the server has no click to date the funnel + /// from. It is a hint, never an override -- the server prefers its own + /// observation, because this one is a number an app could put anything in. + private static void claim(String code, String source, String rawReferrer, + final String matchType, final boolean deferred, long clickedMillis) { if (!allowed()) { return; } @@ -2173,6 +2279,9 @@ private static void claim(String code, String source, String rawReferrer, body.put("code", code); body.put("source", source); body.put("rawReferrer", rawReferrer == null ? "" : rawReferrer); + if (clickedMillis > 0) { + body.put("clickedMillis", Long.valueOf(clickedMillis)); + } lookupIssuedAt = System.currentTimeMillis(); post(getLinkBase() + PATH_CLAIM, body, matchType, deferred); } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java index 9798c800862..4137a8c2814 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java @@ -33,6 +33,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; class InviteDeliveryTest extends UITestBase { @@ -271,6 +272,41 @@ void aclipCodeIsWrittenDownBeforeItIsSent() { "the saved code lost its provenance"); } + @FormTest + void theclipsTapTimeSurvivesIntoTheClaim() { + // The clip is the only witness to the tap: iOS resolves a clip + // invocation from the association file, so it never reaches the + // redirect, and the native side clears the handoff as it reads it. + // Dropped here the value is gone, and every App Clip attribution + // reports a click time of zero. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + implementation.clearQueuedRequests(); + + long tappedSeconds = System.currentTimeMillis() / 1000L - 600L; + Invites.checkForInvite(); + InviteTestSupport.pendingHandoff.answer("CLIPTIME", tappedSeconds); + + // Written down, because the claim can fail and be resent from the + // record rather than from the callback. + Map record = InviteStore.read(InviteStore.PENDING); + assertNotNull(record); + assertEquals(tappedSeconds * 1000L, + InviteStore.getLong(record, "codeClicked", 0), + "the tap time was not persisted, so a resent claim loses it"); + + // And it is on the wire, in milliseconds. + List sent = implementation.getQueuedRequests(); + assertFalse(sent.isEmpty(), "no claim was sent at all"); + String body = ((com.codename1.io.ConnectionRequest) + sent.get(sent.size() - 1)).getRequestBody(); + // A bare number, not a quoted one. Asserted because the server binds + // it to a long: a string would still coerce today and would stop + // doing so the moment anything there gets stricter. + assertTrue(body.contains("\"clickedMillis\": " + (tappedSeconds * 1000L)), + "the claim did not carry the tap time as a number: " + body); + } + @FormTest void aclipAnswerThatOutlivedItsLookupIsIgnored() { // The read is asynchronous and everything that supersedes a lookup diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java index aeb105dcb44..1e7ec369a5d 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java @@ -62,10 +62,15 @@ boolean wasAsked() { /** Delivers a code, as a clip that saw the link would. */ void answer(String code) { + answer(code, 0L); + } + + /** Delivers a code with the tap time the clip observed. */ + void answer(String code, long clickedSeconds) { AppClipHandoffCallback cb = callback; callback = null; if (cb != null) { - cb.onHandoff(code, 0L); + cb.onHandoff(code, clickedSeconds); } } From b935910518687853327a7ca7efe9867dff704ea3 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:19:40 +0300 Subject: [PATCH 47/99] Analytics: an erasure that never reached the disk came back a launch later resetClientId() clears the reserved cn1_ dimensions, and Preferences.set discards its own write-failure boolean -- the same trap Continuity documents and the reason Invites uses Storage instead. So on a full or read-only store the entries vanished from the in-memory map and stayed in the file, and the next launch loaded them back and attached the referral identity the user asked to be rid of to their NEW client id. One launch later, with nothing in memory left to notice. Two halves, because each closes a case the other cannot. persistDimensions() reports now, by reading the value back rather than trusting the write, and clearReservedDimensions() retries once on failure. That closes it inside the process, where the cause is usually transient. Across a restart no in-memory retry survives, so the persisted blob is stamped with the client id it was written under. loadDimensions() drops reserved entries whose stamp names an identity that has since been reset -- the erasure finishing late -- and keeps the APPLICATION's own dimensions, because those are not what an erasure asked about and losing a plan or role the app set would be a second bug in the name of fixing the first. An absent stamp reads as current, so a file written before this existed is not discarded. Both directions are pinned: a stamp from an erased identity drops only the cn1_ entries, and a stamp from the CURRENT identity keeps them -- without that second test the drop could be keyed on the prefix alone and throw the referral away on every ordinary launch. The first was revert-probed: with the check disabled the test reports the erased campaign coming back as "spring", which is the bug exactly. --- .../com/codename1/analytics/Analytics.java | 99 ++++++++++++++++--- .../analytics/AnalyticsFacadeTest.java | 44 +++++++++ 2 files changed, 132 insertions(+), 11 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/Analytics.java b/CodenameOne/src/com/codename1/analytics/Analytics.java index ccb45770a32..4e840f64aa3 100644 --- a/CodenameOne/src/com/codename1/analytics/Analytics.java +++ b/CodenameOne/src/com/codename1/analytics/Analytics.java @@ -71,6 +71,17 @@ public final class Analytics { private static final String PREF_CONSENT_AD = "cn1$analyticsConsentAdStorage"; private static final String PREF_DIMENSIONS = "cn1$analyticsDimensions"; + // The client id the persisted dimensions were written under. + // + // Preferences.set discards the write-failure boolean, so an erasure that + // could not reach the disk removed the reserved dimensions from memory and + // left them in the file: the next launch loaded them back and attached the + // erased referral identity to the NEW client id, which is the one thing + // resetClientId() exists to prevent. Verifying the write closes that + // inside the process; this closes it across a restart, where no in-memory + // retry survives to run. + private static final String PREF_DIMENSIONS_OWNER = "cn1$analyticsDimensionsOwner"; + private static final Object LOCK = new Object(); private static final List PROVIDERS = new ArrayList(); // App-scoped segmentation dimensions ("plan", "role", ...) that the cloud @@ -527,13 +538,28 @@ public static String resetClientId() { return clientId; } + // Package private test seam: makes the store look the way it does after an + // erasure whose write never landed -- the reserved dimensions still in the + // file, stamped with the identity that has since been reset -- and drops + // the in-memory copy so the next read comes off the disk, which is what the + // next process would do. There is no other way to produce a failed + // Preferences write from a test. + static void simulateSurvivingDimensionsForTest(String raw, String owner) { + synchronized (LOCK) { + Preferences.set(PREF_DIMENSIONS, raw); + Preferences.set(PREF_DIMENSIONS_OWNER, owner); + DIMENSIONS.clear(); + dimensionsLoaded = false; + } + } + /// The prefix reserved for dimensions the framework writes on your behalf. /// Do not use it for your own dimensions: everything under it is cleared by /// [#resetClientId]. public static final String RESERVED_DIMENSION_PREFIX = "cn1_"; // Must be called while holding LOCK. - private static void clearReservedDimensions() { + private static boolean clearReservedDimensions() { loadDimensions(); boolean changed = false; Iterator> it = DIMENSIONS.entrySet().iterator(); @@ -545,9 +571,20 @@ private static void clearReservedDimensions() { changed = true; } } - if (changed) { - persistDimensions(); + if (!changed) { + return true; + } + if (persistDimensions()) { + return true; } + // One retry, because the common cause is transient. If it still will + // not land, the entries are gone from memory and still in the file -- + // and the stamp written beside them now names the NEW client id's + // predecessor, so loadDimensions() drops them on the next launch + // rather than attaching them to the fresh identity. + Log.p("analytics: the reserved dimensions could not be erased from storage; " + + "they will be dropped on the next launch instead", Log.WARNING); + return persistDimensions(); } // Must be called while holding LOCK. Lazily loads the persisted dimensions @@ -563,6 +600,14 @@ private static void loadDimensions() { if (stored == null || stored.length() == 0) { return; } + // Whose dimensions these are. An erasure that could not reach the disk + // leaves the reserved entries in the file under the PREVIOUS identity; + // loading them would attach the referral identity the user asked to be + // rid of to their new client id, one launch later and with nothing in + // memory left to notice. An absent stamp is treated as current, so a + // file written before this existed is not discarded. + String owner = Preferences.get(PREF_DIMENSIONS_OWNER, null); + boolean foreign = owner != null && clientId != null && !owner.equals(clientId); String[] rows = split(stored, '\n'); for (String row : rows) { if (row.length() == 0) { @@ -574,18 +619,44 @@ private static void loadDimensions() { } String key = row.substring(0, tab); String value = row.substring(tab + 1); - if (key.length() > 0) { - DIMENSIONS.put(key, value); + if (key.length() == 0) { + continue; } + if (foreign && key.startsWith(RESERVED_DIMENSION_PREFIX)) { + // The framework's own dimensions, belonging to an identity + // that has since been reset. Dropped rather than loaded: this + // is the erasure finishing late, and the alternative is + // handing the new client id the referral it was reset to + // forget. + // + // The APPLICATION's dimensions are kept. They are not what an + // erasure asked about, and losing a plan or role the app set + // would be a second bug in the name of fixing the first. + continue; + } + DIMENSIONS.put(key, value); + } + if (foreign) { + // Rewritten under the current identity so the drop happens once. + // If this write fails too the next launch simply repeats it, which + // is the correct outcome either way. + persistDimensions(); } } // Must be called while holding LOCK. - private static void persistDimensions() { - if (DIMENSIONS.isEmpty()) { - Preferences.set(PREF_DIMENSIONS, ""); - return; - } + /// Writes the dimensions and says whether the write really landed. + /// + /// `Preferences.set` returns nothing and swallows its own failure, so a + /// full or read-only store looked exactly like a successful write. The + /// value is read back instead of trusted, because for an erasure the + /// difference is the whole operation: entries removed only from the + /// in-memory map come back on the next launch. + /// + /// #### Returns + /// + /// true when the stored value matches what was written + private static boolean persistDimensions() { StringBuilder b = new StringBuilder(); boolean first = true; for (Map.Entry e : DIMENSIONS.entrySet()) { @@ -595,7 +666,13 @@ private static void persistDimensions() { b.append(sanitize(e.getKey())).append('\t').append(sanitize(e.getValue())); first = false; } - Preferences.set(PREF_DIMENSIONS, b.toString()); + String value = b.toString(); + Preferences.set(PREF_DIMENSIONS, value); + // Stamped with the identity these dimensions belong to, so a restart + // can tell a surviving file from a current one even when the write + // above failed and nothing in memory remembers. + Preferences.set(PREF_DIMENSIONS_OWNER, clientId == null ? "" : clientId); + return value.equals(Preferences.get(PREF_DIMENSIONS, null)); } // Replaces the delimiter characters so the persisted form parses back diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java index e133907ea06..8885d532f78 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java @@ -61,6 +61,50 @@ void clientIdIsStableAndResettable() { assertEquals(reset, Analytics.clientId()); } + @FormTest + void reservedDimensionsThatOutlivedAnErasureAreDroppedOnTheNextLaunch() { + // Preferences.set discards its write-failure boolean, so an erasure + // that could not reach the disk removed the reserved dimensions from + // memory and left them in the file. The next launch loaded them back + // and attached the referral identity the user asked to be rid of to + // their NEW client id -- one launch later, with nothing in memory left + // to notice. + Analytics.clearProviders(); + Analytics.clearDimensions(); + String current = Analytics.clientId(); + + // The file as a failed erasure leaves it: framework dimensions and an + // application dimension, stamped with the identity that has gone. + Analytics.simulateSurvivingDimensionsForTest( + "cn1_campaign\tspring\ncn1_invite\tinstall_confirmed\nplan\tpro", + "an-erased-client-id"); + + Map loaded = Analytics.getDimensions(); + assertNull(loaded.get("cn1_campaign"), + "an erased referral came back and attached itself to the new client id"); + assertNull(loaded.get("cn1_invite")); + // The APPLICATION's own dimension is not what an erasure asked about, + // and losing it would be a second bug in the name of fixing the first. + assertEquals("pro", loaded.get("plan"), + "the application's own dimension was destroyed by someone else's erasure"); + assertEquals(current, Analytics.clientId(), "the fixture changed the identity"); + } + + @FormTest + void dimensionsFromTheCurrentIdentityAreKept() { + // The drop is keyed on the STAMP, not on the prefix, or an ordinary + // launch would throw away the referral dimensions every time. + Analytics.clearProviders(); + Analytics.clearDimensions(); + Analytics.simulateSurvivingDimensionsForTest( + "cn1_campaign\tspring\nplan\tpro", Analytics.clientId()); + + Map loaded = Analytics.getDimensions(); + assertEquals("spring", loaded.get("cn1_campaign"), + "a live referral was discarded on an ordinary launch"); + assertEquals("pro", loaded.get("plan")); + } + @FormTest void setUserIdRequiresPersonalizationConsent() { Analytics.clearProviders(); From 51173b4bc51bc7e7e00c7d1a5c1254d70222b94d Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:22:56 +0300 Subject: [PATCH 48/99] Analytics: the header gate covers a file I modified AnalyticsFacadeTest had no copyright header, and the gate checks every file a change TOUCHES rather than only the ones it adds -- so editing it made the missing header this branch's problem. It carries the Codename One GPLv2 + Classpath header now, the same one every other file in this package has. Mine to have caught before pushing: I ran the header gate earlier in the branch and did not re-run it after the commit that touched this file, which is exactly the case it exists for. --- .../analytics/AnalyticsFacadeTest.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java index 8885d532f78..32c91f13de0 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java @@ -1,3 +1,25 @@ +/* + * 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.analytics; import com.codename1.io.Preferences; From e2d3dc661bbff6a5d57e1ad195de21bd4a79e82c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:37:05 +0300 Subject: [PATCH 49/99] Invites: the write check I added checked nothing, and two iOS build traps The verification from the previous commit was worthless and I should have read Preferences before writing it. Preferences.set updates a static Hashtable and Preferences.get reads that same Hashtable, so comparing a value with what comes back compares memory with memory: it reports success for a write that never reached the disk. It was worse than useless. persistDimensions() stamped the file with the NEW client id in the same breath, so a failed erasure left the OLD reserved dimensions sitting under a stamp that claimed them as current -- and the stamp, which is the mechanism that was supposed to catch exactly this, said they belonged. The fix made the bug harder to see. The check is gone, with a note saying why it cannot work, and the stamp now carries the whole job. An ABSENT stamp counts as foreign rather than current, which is the difference between a mechanism that works and one that works only when the write it depends on succeeded: on a device whose file predates the stamp, or where the same storage failure that broke the erasure also stopped the stamp landing, there is nothing to compare. The trade is explicit -- a reserved dimension dropped wrongly is rewritten by the next attribution; an erased identity coming back is not recoverable -- and clientId() is used rather than the field, because loading can happen before the id is materialised and a null made every file look current. ios.invite.appClip=false disabled the receiving side too. It means "do not GENERATE a clip", which is what a developer sets when they ship one of their own -- and it was suppressing the app group, the native define and the registration of IOSAppClipHandoff along with it, so a custom clip wrote the documented handoff into the documented container and nothing read it. Generation and reception are separate questions now, and the hint's documentation said the wrong thing too. The clip also hard-coded TARGETED_DEVICE_FAMILY=1, on the belief that App Clips do not run on iPad. They do -- and an ios.project_type=ipad build has an iPad-only app target, so an iPhone-only clip inside it shares no family with its container and App Store validation rejects the archive. It uses the same host-family helper every other embedded target here uses. --- .../com/codename1/analytics/Analytics.java | 79 ++++++++++--------- docs/developer-guide/Analytics.asciidoc | 2 +- .../codename1/build/shared/BuildHintsIos.java | 7 +- .../com/codename1/builders/IPhoneBuilder.java | 41 +++++++--- .../util/InviteAppClipBuilderTest.java | 17 ++++ .../analytics/AnalyticsFacadeTest.java | 19 +++++ 6 files changed, 115 insertions(+), 50 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/Analytics.java b/CodenameOne/src/com/codename1/analytics/Analytics.java index 4e840f64aa3..0807b1ab887 100644 --- a/CodenameOne/src/com/codename1/analytics/Analytics.java +++ b/CodenameOne/src/com/codename1/analytics/Analytics.java @@ -559,7 +559,7 @@ static void simulateSurvivingDimensionsForTest(String raw, String owner) { public static final String RESERVED_DIMENSION_PREFIX = "cn1_"; // Must be called while holding LOCK. - private static boolean clearReservedDimensions() { + private static void clearReservedDimensions() { loadDimensions(); boolean changed = false; Iterator> it = DIMENSIONS.entrySet().iterator(); @@ -571,20 +571,9 @@ private static boolean clearReservedDimensions() { changed = true; } } - if (!changed) { - return true; - } - if (persistDimensions()) { - return true; + if (changed) { + persistDimensions(); } - // One retry, because the common cause is transient. If it still will - // not land, the entries are gone from memory and still in the file -- - // and the stamp written beside them now names the NEW client id's - // predecessor, so loadDimensions() drops them on the next launch - // rather than attaching them to the fresh identity. - Log.p("analytics: the reserved dimensions could not be erased from storage; " - + "they will be dropped on the next launch instead", Log.WARNING); - return persistDimensions(); } // Must be called while holding LOCK. Lazily loads the persisted dimensions @@ -602,12 +591,28 @@ private static void loadDimensions() { } // Whose dimensions these are. An erasure that could not reach the disk // leaves the reserved entries in the file under the PREVIOUS identity; - // loading them would attach the referral identity the user asked to be - // rid of to their new client id, one launch later and with nothing in - // memory left to notice. An absent stamp is treated as current, so a - // file written before this existed is not discarded. + // loading them would attach the referral the user asked to be rid of + // to their new client id, one launch later and with nothing in memory + // left to notice. + // + // An ABSENT stamp counts as foreign, not as current. That is the + // difference between a mechanism that works and one that works only + // when the write it depends on succeeded: on a device whose file + // predates the stamp -- or where the storage failure that broke the + // erasure also stopped the stamp being written -- there is nothing to + // compare, and treating that as current is exactly the case being + // defended against. Unknown provenance for a dimension the FRAMEWORK + // owns resolves to dropping it. + // + // The cost of being wrong that way is one re-resolution: a reserved + // dimension dropped here is rewritten by the next attribution. The + // cost of being wrong the other way is an erased identity coming back. + // + // clientId() rather than the field, because loading can happen before + // the id has been materialised and a null would make every file look + // current. It does not read dimensions, so there is no recursion. String owner = Preferences.get(PREF_DIMENSIONS_OWNER, null); - boolean foreign = owner != null && clientId != null && !owner.equals(clientId); + boolean foreign = !clientId().equals(owner); String[] rows = split(stored, '\n'); for (String row : rows) { if (row.length() == 0) { @@ -645,18 +650,19 @@ private static void loadDimensions() { } // Must be called while holding LOCK. - /// Writes the dimensions and says whether the write really landed. - /// - /// `Preferences.set` returns nothing and swallows its own failure, so a - /// full or read-only store looked exactly like a successful write. The - /// value is read back instead of trusted, because for an erasure the - /// difference is the whole operation: entries removed only from the - /// in-memory map come back on the next launch. - /// - /// #### Returns - /// - /// true when the stored value matches what was written - private static boolean persistDimensions() { + /// Writes the dimensions and the identity they belong to. + /// + /// There is deliberately NO read-back check here, and one was tried and + /// removed: `Preferences.set` updates a static table and `Preferences.get` + /// reads that same table, so reading a value back after writing it + /// compares memory with memory and reports success for a write that never + /// reached the disk. It looked like verification and verified nothing. + /// + /// The erasure is made safe by the stamp instead, which needs no write to + /// succeed -- see [#loadDimensions]. Both keys live in the SAME + /// preferences record, so they land together or not at all; there is no + /// state where the dimensions survive under a stamp that disowns them. + private static void persistDimensions() { StringBuilder b = new StringBuilder(); boolean first = true; for (Map.Entry e : DIMENSIONS.entrySet()) { @@ -666,13 +672,12 @@ private static boolean persistDimensions() { b.append(sanitize(e.getKey())).append('\t').append(sanitize(e.getValue())); first = false; } - String value = b.toString(); - Preferences.set(PREF_DIMENSIONS, value); - // Stamped with the identity these dimensions belong to, so a restart - // can tell a surviving file from a current one even when the write - // above failed and nothing in memory remembers. + Preferences.set(PREF_DIMENSIONS, b.toString()); + // Stamped with the identity these dimensions belong to. This is what + // makes a surviving file distinguishable from a current one after a + // restart, when nothing in memory remembers that an erasure was asked + // for. Preferences.set(PREF_DIMENSIONS_OWNER, clientId == null ? "" : clientId); - return value.equals(Preferences.get(PREF_DIMENSIONS, null)); } // Replaces the delimiter characters so the persisted form parses back diff --git a/docs/developer-guide/Analytics.asciidoc b/docs/developer-guide/Analytics.asciidoc index 627cc0e4f73..d29dff94c2a 100644 --- a/docs/developer-guide/Analytics.asciidoc +++ b/docs/developer-guide/Analytics.asciidoc @@ -253,7 +253,7 @@ Three things the generated clip needs from you, and each fails in its own way: * An App Clip enabled on your App ID, alongside Associated Domains. * An Advanced App Clip Experience registered in App Store Connect for your invite link prefix. Codename One authorizes your clip for the domain; what maps a particular link to a particular clip is that experience, and until it exists tapping the invite link shows no clip card at all. The console shows the prefix to register once invites are switched on. -Set `ios.invite.appClip` to `false` only if you ship an App Clip of your own. The app then reports every install as organic unless your clip writes the handoff itself. +Set `ios.invite.appClip` to `false` only if you ship an App Clip of your own. The build then generates no clip, but your app still gets the shared App Group and the reader, so a clip of yours that writes the handoff is still picked up. WARNING: On Android, App Links verification checks the certificate the installed APK is signed with. Under Play App Signing that's Google's key, not your upload key, so add the app-signing SHA-256 from the Play Console to `android.invite.signingFingerprint`. Without it verification fails on every Play install, the link opens the browser instead of your app, and nothing reports an error. diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java index 9c95f57b701..a1419e76c8a 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java @@ -229,9 +229,10 @@ static void register(List h) { .doc("Whether the build generates and embeds the App Clip that makes invite " + "attribution exact on iOS. The App Store carries no referrer of its " + "own, so without the clip an iOS install can't be attributed at all. " - + "Set it to `false` only if you ship an App Clip of your own; the build " - + "then writes no clip, and the app reports every install as organic " - + "unless your clip writes the handoff itself. Ignored when " + + "Set it to `false` only if you ship an App Clip of your own: the " + + "build then generates no clip, but the app still carries the shared " + + "app group and the reader, so a clip of yours that writes the " + + "documented handoff is still picked up. Ignored when " + "`ios.invite.universalLinks` is `false`, because iOS can only offer a " + "clip for a link the app has an associated domain for.")); diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index bed334f6de2..191836d34e1 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -1437,6 +1437,11 @@ private java.util.Set foldInCallAndVpnLibraryUsage( /// generated stub tests before registering a reader. private String inviteAppClipGroup = ""; + /// Whether to GENERATE the clip, which is a narrower question than whether + /// to read a handoff. A developer shipping their own clip turns this off + /// and still needs the app group, the native reader and the registration. + private boolean inviteAppClipTargetWanted; + /// The App Group the Call Directory extension and the app share. private String callDirectoryAppGroup; @@ -4517,9 +4522,12 @@ public void usesClassMethod(String cls, String method) { throw new BuildException( "Failed to enable CN1_INCLUDE_INVITE_APPCLIP", ex); } - debug("Invite attribution: generating the App Clip " - + InviteAppClipBuilder.CLIP_NAME + " for " + inviteHost - + " (app group " + group + ")"); + debug("Invite attribution: " + (inviteAppClipTargetWanted + ? "generating the App Clip " + + InviteAppClipBuilder.CLIP_NAME + : "reading a handoff from an App Clip this build " + + "does not generate") + + " for " + inviteHost + " (app group " + group + ")"); } if (request.getArg("ios.associatedDomains", null) != null) { @@ -7385,7 +7393,7 @@ && conditionCovers(governingKey, appendWidgetExtensionTargets(appExtensionsBuilder, request, new File(tmpFile, "dist")); } - if (inviteAppClipGroup.length() > 0) { + if (inviteAppClipTargetWanted) { // Same ordering note: appended after the global deployment-target // pass, so the clip keeps its own iOS 14 floor -- which is not a // preference. App Clips do not exist below it, and one built against @@ -12276,9 +12284,18 @@ displayName, embeddedExtensionShortVersion(request), /// here; the enablement block adds the group private void resolveInviteAppClipGroup(BuildRequest request) throws BuildException { inviteAppClipGroup = ""; + inviteAppClipTargetWanted = false; + // NOT gated on ios.invite.appClip, and that separation is the point. + // + // That hint says "do not GENERATE a clip", which a developer sets when + // they ship one of their own. It used to suppress the receiving side + // too -- the app group, the native define and the registration of + // IOSAppClipHandoff -- so a custom clip could write the documented + // handoff into the documented container and nothing in the + // application ever read it. Every install settled as no_match, for a + // clip that did its job. if (!usesInvites - || !"true".equals(request.getArg("ios.invite.universalLinks", "true")) - || !"true".equals(request.getArg("ios.invite.appClip", "true"))) { + || !"true".equals(request.getArg("ios.invite.universalLinks", "true"))) { return; } String group = request.getArg("ios.invite.appGroup", @@ -12290,6 +12307,8 @@ private void resolveInviteAppClipGroup(BuildRequest request) throws BuildExcepti + "\"group.\", got \"" + group + "\"."); } inviteAppClipGroup = group; + inviteAppClipTargetWanted = + "true".equals(request.getArg("ios.invite.appClip", "true")); } /// Emits the App Clip target into the schemes ruby. @@ -12333,9 +12352,13 @@ private void appendInviteAppClipTarget(StringBuilder sb, BuildRequest request, buildSettingsMap.put("CODE_SIGN_ENTITLEMENTS", name + "/" + name + ".entitlements"); buildSettingsMap.put("IPHONEOS_DEPLOYMENT_TARGET", InviteAppClipBuilder.DEPLOYMENT_TARGET); - // iPhone only. App Clips do not run on iPad-only or Mac destinations, - // and a clip claiming a family the host does not ship fails validation. - buildSettingsMap.put("TARGETED_DEVICE_FAMILY", "1"); + // The HOST's families, through the same helper every other embedded + // target here uses. Hard-coding iPhone was wrong twice over: App Clips + // do run on iPad, and an ios.project_type=ipad build has an iPad-only + // app target -- so an iPhone-only clip inside it shares no family with + // its container and App Store validation rejects the archive. + buildSettingsMap.put("TARGETED_DEVICE_FAMILY", + embeddedExtensionDeviceFamily(request.getArg("ios.project_type", "ios"))); buildSettingsMap.put("LD_RUNPATH_SEARCH_PATHS", "$(inherited) @executable_path/Frameworks"); buildSettingsMap.put("SKIP_INSTALL", "YES"); diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/InviteAppClipBuilderTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/InviteAppClipBuilderTest.java index b18bb8f1da0..2bb499d2d35 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/InviteAppClipBuilderTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/util/InviteAppClipBuilderTest.java @@ -175,4 +175,21 @@ void aQuotedDisplayNameCannotEscapeItsLiteral() { InviteAppClipBuilder.escapeObjC("Bob\"s \\ App")); assertEquals("one two", InviteAppClipBuilder.escapeObjC("one\ntwo")); } + /// The build hint documentation is part of the contract here, because the + /// two halves it governs are easy to conflate: the value of + /// ios.invite.appClip decides whether a clip is GENERATED, and never + /// whether a handoff is read. A developer shipping their own clip turns + /// generation off and still needs the app group, the native reader and the + /// registration -- without them their clip writes the documented handoff + /// into the documented container and nothing ever looks. + @Test + void theClipNameAndSuffixAreTheOnesTheServerAuthorises() { + // BuildCloud names each clip ..Clip in the association + // document, from its own copy of this suffix. The two repositories + // share no code, so a rename here is not a compile error there -- it + // is a clip iOS is never offered, with nothing reporting why. + assertEquals(".Clip", InviteAppClipBuilder.bundleId("x").substring(1)); + assertEquals("CN1InviteClip", InviteAppClipBuilder.CLIP_NAME); + } + } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java index 32c91f13de0..0201414d798 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java @@ -127,6 +127,25 @@ void dimensionsFromTheCurrentIdentityAreKept() { assertEquals("pro", loaded.get("plan")); } + @FormTest + void anUnstampedFileIsNotTrustedWithFrameworkDimensions() { + // The case a read-back check cannot reach and the stamp only covers if + // it is treated strictly: a file whose stamp was never written -- + // because it predates the stamp, or because the same storage failure + // that broke the erasure also stopped the stamp landing. Treating an + // absent stamp as current is exactly the state being defended against. + Analytics.clearProviders(); + Analytics.clearDimensions(); + Analytics.simulateSurvivingDimensionsForTest( + "cn1_campaign\tspring\nplan\tpro", null); + + Map loaded = Analytics.getDimensions(); + assertNull(loaded.get("cn1_campaign"), + "an unstamped referral was trusted and reloaded"); + assertEquals("pro", loaded.get("plan"), + "the application's own dimension was destroyed with it"); + } + @FormTest void setUserIdRequiresPersonalizationConsent() { Analytics.clearProviders(); From 6d7a38f5927423cf025198e2d85a82acc92a05fb Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:49:25 +0300 Subject: [PATCH 50/99] Invites: a deletion tombstone read as pending, and a false privacy claim InviteStore.delete() overwrites a record it could not remove with an empty one, deliberately: an empty record carries no code, no inviter and no campaign, so a delete that cannot happen at least leaves nothing behind. But the state read defaulted an absent "state" key to STATE_PENDING, so that tombstone came back as a pending lookup -- and under re-attribution a pending state outranks the durable attribution, so the settled claim was resubmitted and invite_install or invite_opened counted one install twice. An empty record reads as absent now. And the class documentation still told applications that this feature writes a coarse device profile -- OS version, hardware model, language, screen size -- to local storage before consent. It has not since App Clips replaced the statistical match: pendingRecord() stores timing and state, and the code it keeps is one the person produced by tapping an invite. That is worse than a stale comment. It is the paragraph a developer copies into their own privacy disclosure, so leaving it there publishes a claim about data collection that does not happen -- and it would reasonably put someone off the feature entirely. It now says what is actually stored. --- .../codename1/analytics/invite/Invites.java | 32 +++++++++++++------ .../invite/InviteResilienceTest.java | 25 +++++++++++++++ 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 20674396ee7..478a70001c8 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -90,15 +90,17 @@ /// Everything reported here is gated on the analytics consent category of /// [Analytics], and nothing is transmitted until consent is granted. /// -/// One thing does happen before consent: on first launch a coarse device -/// profile -- operating system version, hardware model, language, screen size -/// -- is written to local storage so that a deferred match is still possible -/// once consent arrives. It is never transmitted while consent is withheld, -/// and it is deleted outright if consent is refused. There is no alternative -/// that also works, because the window in which a deferred match can be made -/// closes within the hour, long before a typical consent prompt is answered. -/// [#setAttributionWindow] with `0` switches deferred attribution off -/// entirely. +/// Nothing about the device is collected, before consent or after it. An +/// earlier design wrote a coarse profile -- operating system version, +/// hardware model, language, screen size -- to local storage on first launch, +/// because an iOS install could then be matched to a click statistically. +/// App Clips removed the need: the clip is launched by the invite link and is +/// handed the code itself, so there is nothing to match and nothing to keep. +/// +/// What is stored locally is the invite code and the bookkeeping around it -- +/// a state, a deadline, an attempt count -- and the code is only ever one the +/// person produced by tapping an invite. [#setAttributionWindow] with `0` +/// switches deferred attribution off entirely. /// /// ### How exact the answer is /// @@ -775,7 +777,17 @@ private static void loadState() { // the shape PMD reads as an unsynchronized lazy singleton, and the // answer to that is not a lock: this facade runs on the EDT and adding // one would be the real mistake. - int recorded = pending == null ? STATE_NONE + // An EMPTY record reads as absent, not as pending. + // + // InviteStore.delete() overwrites a record it could not remove with an + // empty one, deliberately -- an empty record carries no code, no + // inviter and no campaign, so a delete that cannot happen at least + // leaves nothing behind. But the default below turned that tombstone + // into STATE_PENDING on the next launch, and under re-attribution a + // pending state outranks the durable attribution: the settled claim + // was resubmitted and invite_install or invite_opened emitted a second + // time for one install. + int recorded = pending == null || pending.isEmpty() ? STATE_NONE : InviteStore.getInt(pending, "state", STATE_PENDING); // The pending record is consulted first only under re-attribution. // There a later invite writes a new claim while the earlier attribution diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index b4d8cf95e9c..72126e89133 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -578,6 +578,31 @@ public void requestReferrer(InstallReferrerCallback callback) { "an exact 'no invite' answer was left pending"); } + @FormTest + void thedeleteTombstoneIsNotMistakenForAPendingLookup() { + // InviteStore.delete() overwrites a record it could not remove with an + // empty one, on purpose: an empty record carries no code, no inviter + // and no campaign, so a delete that cannot happen leaves nothing + // behind. But an absent "state" key defaulted to STATE_PENDING, and + // under re-attribution a pending state outranks the durable + // attribution -- so the settled claim was resubmitted and the install + // funnel counted one install twice. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.setReattribution(true); + Invites.handleResolution( + InviteTestSupport.resolvedJson("SETTLED1", "spring", "sms"), + Invites.MATCH_REFERRER, true); + assertNotNull(Invites.getAttribution(), "the fixture did not resolve"); + + // The tombstone a failed delete leaves. + InviteStore.write(InviteStore.PENDING, new java.util.LinkedHashMap()); + Invites.forgetLoadedState(); + + assertEquals(Invites.STATE_RESOLVED, Invites.getState(), + "an empty deletion tombstone reopened a settled attribution"); + } + @Test @EdtTest void theReferrerCodeIsPersistedBeforeTheClaimGoesOut() { From fb39ecfc8e29b5932a030e1282addc1b340b0cd2 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:59:04 +0300 Subject: [PATCH 51/99] Invites: a settled claim whose record refused to go was asked again delete() falls back to overwriting a record it cannot remove with an empty one, and the previous commit stopped that empty record reading as pending. This is the case where BOTH fail: the real pending state survives beside the new attribution, and under re-attribution loadState() prefers it -- deliberately, so a claim interrupted by process death is retried. The already-successful claim was therefore resubmitted on the next launch, and a second invite_install or invite_opened was emitted for one install. The record is overwritten with the terminal state when the delete fails. That says what the deletion would have said, in a record the store has just proved it will not remove, and it carries no code and no inviter -- so if that write fails too, what is left is the record that was already there and nothing new is disclosed. The failure is logged rather than assumed away. Revert-probed: with the check removed the test reports the settled install coming back as pending, which is the resubmission exactly. --- .../codename1/analytics/invite/Invites.java | 23 +++++++++++++++++- .../invite/InviteResilienceTest.java | 24 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 478a70001c8..128359cd274 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -2679,7 +2679,28 @@ private static void resolve(InviteAttribution a, String confidence) { + "pending and will be retried", Log.WARNING); return; } - InviteStore.delete(InviteStore.PENDING); + if (!InviteStore.delete(InviteStore.PENDING)) { + // The claim is settled and its record could not be removed, nor + // overwritten with the empty one delete() falls back to. Under + // re-attribution loadState() prefers a surviving pending record + // over the durable attribution -- deliberately, so a claim + // interrupted by process death is retried -- so leaving this one + // there resubmits a claim that already succeeded, and a second + // invite_install or invite_opened is emitted for one install. + // + // Overwritten with the terminal state instead of deleted. That + // says the same thing the deletion would have, in a record the + // store has just proved it will not remove, and it carries no + // code and no inviter -- so if this write fails too, what is left + // is the record that was already there and nothing new is + // disclosed. + Map settled = new LinkedHashMap(); + settled.put("state", String.valueOf(STATE_RESOLVED)); + if (!writePending(settled)) { + Log.p("invite: the pending record survived a resolved claim and could not be " + + "marked settled; this install may be attributed again", Log.WARNING); + } + } forgetPendingFallback(); resolved = a; attributionLoaded = true; diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 72126e89133..4ffece5873a 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -603,6 +603,30 @@ void thedeleteTombstoneIsNotMistakenForAPendingLookup() { "an empty deletion tombstone reopened a settled attribution"); } + @FormTest + void asettledClaimWhosePendingRecordSurvivesIsNotAskedAgain() { + // The store refuses to delete the record AND refuses the empty + // overwrite delete() falls back to, so the real pending state lives on + // beside the new attribution. Under re-attribution loadState() prefers + // that record -- deliberately, so a claim interrupted by process death + // is retried -- and the already-successful claim was resubmitted, + // emitting a second invite_install for one install. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.setReattribution(true); + Invites.handleUrl("https://cloud.codenameone.com/i/PENDSURV"); + + InviteStore.failNextDeleteForTest(InviteStore.PENDING); + Invites.handleResolution( + InviteTestSupport.resolvedJson("PENDSURV", "spring", "sms"), + Invites.MATCH_DIRECT, false); + assertNotNull(Invites.getAttribution(), "the fixture did not resolve"); + + Invites.forgetLoadedState(); + assertEquals(Invites.STATE_RESOLVED, Invites.getState(), + "a settled claim was left pending and would be submitted again"); + } + @Test @EdtTest void theReferrerCodeIsPersistedBeforeTheClaimGoesOut() { From 7066438f034fd3ceaa710aaa4286365560a094df Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:10:57 +0300 Subject: [PATCH 52/99] Invites: the App Clip embed named the target, not the product ios.invite.buildSettings.PRODUCT_NAME can override what the clip is built as, but the embed reference looked for CN1InviteClip.app in BUILT_PRODUCTS_DIR regardless -- so such a build failed while copying a product that was never produced. It goes through effectiveExtensionProductName, the same helper the VPN tunnel and Matter targets use, which also refuses a value this build cannot evaluate rather than emitting a reference that cannot resolve: an Xcode condition is legal in that setting and nothing here can expand it, so the honest answer is to say which hint is unusable and why. --- .../com/codename1/builders/IPhoneBuilder.java | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 191836d34e1..bcbe3d39183 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -12375,6 +12375,20 @@ private void appendInviteAppClipTarget(StringBuilder sb, BuildRequest request, request.getArg(key, "")); } } + // The name the product will ACTUALLY be built under, which is not + // necessarily the target name: ios.invite.buildSettings.PRODUCT_NAME + // can override it. The embed reference below names a file in + // BUILT_PRODUCTS_DIR, so hard-coding the target name made such a build + // fail while copying a product that was never produced. + String productName = effectiveExtensionProductName( + buildSettingsMap.get("PRODUCT_NAME"), name); + if (productName == null) { + throw new BuildException("ios.invite.buildSettings.PRODUCT_NAME is \"" + + buildSettingsMap.get("PRODUCT_NAME") + "\", which this build" + + " cannot evaluate, so it cannot know what the App Clip's" + + " product will be called or embed it in the app. Use a" + + " literal name, or $(TARGET_NAME)."); + } // Guarded so re-running the script does not create a duplicate target; // the build re-executes fix_xcode_schemes.rb after dependency // integration. @@ -12392,7 +12406,7 @@ private void appendInviteAppClipTarget(StringBuilder sb, BuildRequest request, sb.append("main_app_target = xcproj.targets.find{|e| e.name==main_class_name}\n" + "main_app_target.add_dependency(clip_target)\n" + "fileref = xcproj.groups.find{|e| e.display_name=='Products'}.new_file('" - + name + ".app', \"BUILT_PRODUCTS_DIR\")\n" + + productName + ".app', \"BUILT_PRODUCTS_DIR\")\n" + "embed_phase = main_app_target.copy_files_build_phases.find{|p| " + "p.name=='Embed App Clips'} || " + "main_app_target.new_copy_files_build_phase('Embed App Clips')\n" From 4e39d4f87366fef62d4be0b247dd46a900bebd03 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:24:52 +0300 Subject: [PATCH 53/99] Invites: a burst of mints resent the whole queue, and a hint too much Outbox entries leave the queue only when their OWN response acknowledges them -- which is right, because the campaign, channel, payload and preview cannot be reconstructed from a click -- but that leaves an entry drainable while its request is still outstanding. create() flushes unconditionally, so N invites minted in a burst sent N(N+1)/2 requests: six produced twenty-one, and the 512-entry cap puts a full queue past 131,000. Entries now carry an in-flight mark, and the two flushes are told apart. create()'s own flush skips what is already going out; the PUBLIC flush() does not, because it is documented as the "I have just regained connectivity" call and its whole job is resending a request that went out over a dead network and will never answer. An existing test pins that second behaviour and caught the first attempt, which suppressed both. The mark is released on every outcome, including handleException -- where postResponse() never runs. Without that a transport failure left the entry marked for the life of the process and no later drain retried it, trading an amplification bug for a lost registration, which is the worse of the two. It is not persisted, so a process that dies with requests outstanding retries them on the next launch. And ios.invite.universalLinks=false disabled the receiving side, exactly as ios.invite.appClip=false did before it. It means "do not inject the associated domain, I manage the entitlement myself" -- and it was also suppressing the app group, the native define and the registration of IOSAppClipHandoff, so an app that had configured its own domains correctly had nothing reading the handoff and every iOS install settled as no_match. Each hint is applied where the thing it governs is done, and neither gates the machinery any more. --- .../codename1/analytics/invite/Invites.java | 69 ++++++++++++++++++- .../com/codename1/builders/IPhoneBuilder.java | 26 ++++--- .../analytics/invite/InviteMintTest.java | 22 ++++++ 3 files changed, 105 insertions(+), 12 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 128359cd274..330a4cf0e7a 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -382,7 +382,10 @@ public static Invite create(InviteRequest request) { putIfSet(p, "campaign", request.getCampaign()); putIfSet(p, "channel", request.getChannel()); Analytics.autoEvent("invite_created", CATEGORY, p); - flush(); + // Skipping what is already on the wire. Every create() flushes, so + // without this a burst of N invites sent N(N+1)/2 requests -- each + // mint resending every earlier one, none of which needed it. + flush(true); return invite; } @@ -974,8 +977,15 @@ public static boolean isReattribution() { /// deferred match. Called for you on the paths that matter; exposed for /// an application that knows it has just regained connectivity. public static void flush() { + flush(false); + } + + /// - `skipInFlight`: true for the flush create() issues itself, which must + /// not resend a queue that is already going out; false for the public + /// call, which exists precisely to resend after a network came back. + private static void flush(boolean skipInFlight) { ensureProvider(); - drainOutbox(); + drainOutbox(skipInFlight); // A deferred lookup that failed because the first launch was offline // leaves deferredStarted set, and nothing else clears it inside the // process: the request is fail-silent, so no callback runs. Without @@ -2402,8 +2412,29 @@ protected void readResponse(InputStream input) throws IOException { payload = new String(Util.readInputStream(input), "UTF-8"); } + @Override + protected void handleException(Exception err) { + // The transport failed, so postResponse() never runs. Without this + // the entry stayed marked in flight for the life of the process + // and no later flush would retry it -- trading an amplification + // bug for a lost registration, which is the worse of the two. + releaseInFlight(); + super.handleException(err); + } + + private void releaseInFlight() { + if (registration && outboxEntry != null) { + inFlight.remove(outboxEntry); + } + } + @Override protected void postResponse() { + // Cleared before the failure check, because a failed send has to be + // retryable by the next drain: this mark exists only to stop one + // burst of invites reposting the whole queue, not to retire an + // entry. + releaseInFlight(); // Reading the body of an error response is on by default // (ConnectionRequest.readResponseForErrorsDefault), and the error // path falls through to postResponse() exactly as a 200 does. So @@ -2876,6 +2907,21 @@ private static void notifyUnavailable(String reason) { // the durable store is the thing that just failed. private static final List unacknowledged = new ArrayList(); + // Outbox entries with a request already on the wire, keyed by the entry + // exactly as the queue holds it. + // + // Entries leave the queue only when their OWN response acknowledges them, + // which is right -- the metadata cannot be reconstructed from a click -- + // but it means an entry stays drainable while its request is outstanding. + // create() calls flush() unconditionally, so minting invites in a burst + // reposted the whole queue each time: N invites produced N(N+1)/2 + // requests, and the 512-entry cap puts that over 131,000. Each invite + // needs exactly one. + // + // Not persisted: a process that dies with requests outstanding should + // retry them, and an empty set on the next launch is what makes it. + private static final List inFlight = new ArrayList(); + /// Records that a queued registration was evicted to keep the outbox /// under its cap. /// @@ -2941,7 +2987,19 @@ private static boolean queueRegistration(Invite invite, InviteRequest request) { return InviteStore.writeOutbox(outbox); } + /// The ordinary drain: entries with a request already on the wire are + /// skipped. private static void drainOutbox() { + drainOutbox(true); + } + + /// - `skipInFlight`: false for an explicit [#flush], which is the + /// documented "I have just regained connectivity" call and must resend + /// an entry whose request went out over a dead network and will never + /// answer. true everywhere else, including the flush create() issues + /// itself -- that one is what turned a burst of N invites into N(N+1)/2 + /// requests, and no invite in a burst needs its predecessors resent. + private static void drainOutbox(boolean skipInFlight) { if (!allowed()) { return; } @@ -2968,6 +3026,13 @@ private static void drainOutbox() { // Re-posting an entry that did land is harmless: the server keys on // the code and treats a repeat from the same inviter as idempotent. for (String json : outbox) { + if (skipInFlight && inFlight.contains(json)) { + // Already on the wire. Its response will remove it or leave it + // for the next drain; sending it again buys nothing and is how + // one burst of invites became thousands of requests. + continue; + } + inFlight.add(json); // The body is rewritten, the KEY is not. The outbox still holds the // original string, and that is what has to be removed when the // server accepts it. diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index bcbe3d39183..2c20c76b1d3 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -12285,17 +12285,23 @@ displayName, embeddedExtensionShortVersion(request), private void resolveInviteAppClipGroup(BuildRequest request) throws BuildException { inviteAppClipGroup = ""; inviteAppClipTargetWanted = false; - // NOT gated on ios.invite.appClip, and that separation is the point. + // Gated on usesInvites and NOTHING else, which is the point. // - // That hint says "do not GENERATE a clip", which a developer sets when - // they ship one of their own. It used to suppress the receiving side - // too -- the app group, the native define and the registration of - // IOSAppClipHandoff -- so a custom clip could write the documented - // handoff into the documented container and nothing in the - // application ever read it. Every install settled as no_match, for a - // clip that did its job. - if (!usesInvites - || !"true".equals(request.getArg("ios.invite.universalLinks", "true"))) { + // Both hints here say "do not do this FOR me", and both were reading + // as "turn the feature off". ios.invite.appClip says do not generate a + // clip, which a developer sets when they ship one of their own; + // ios.invite.universalLinks says do not inject the associated domain, + // which they set when they manage the entitlement by hand. Either one + // used to suppress the receiving side as well -- the app group, the + // native define and the registration of IOSAppClipHandoff -- so a + // correctly configured app whose own clip wrote the documented handoff + // into the documented container had nothing reading it, and every iOS + // install settled as no_match. + // + // What each hint governs is applied where that thing is done: the + // domain append is guarded by universalLinks at its own call site, and + // target generation by appClip just below. + if (!usesInvites) { return; } String group = request.getArg("ios.invite.appGroup", diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteMintTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteMintTest.java index b29c0f7e708..55670504925 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteMintTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteMintTest.java @@ -184,4 +184,26 @@ public void execute() { }); assertTrue(e2.getMessage().contains("campaign"), e2.getMessage()); } + @FormTest + void aburstOfInvitesSendsOneRequestEach() { + // Entries leave the outbox only when their OWN response acknowledges + // them, which is right -- the campaign, channel, payload and preview + // cannot be reconstructed from a click -- but it leaves an entry + // drainable while its request is outstanding. create() calls flush() + // unconditionally, so a burst reposted the whole queue each time: N + // invites produced N(N+1)/2 requests, and the 512-entry cap puts that + // past 131,000 for a full queue. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + implementation.clearQueuedRequests(); + + int burst = 6; + for (int i = 0; i < burst; i++) { + Invites.create(InviteRequest.create().campaign("c" + i).build()); + } + + assertEquals(burst, implementation.getQueuedRequests().size(), + "a burst of " + burst + " invites did not send one request each"); + } + } From 23ef7ab60c5beb36eb049f33efc640f56ee6be6b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:41:09 +0300 Subject: [PATCH 54/99] Invites: three erasures that reported success and left something behind All three are the same shape, and it is the shape Preferences forces: `set` updates a static table and swallows the store's answer, so no write to it can be verified. Everything durable that CAN report -- the three InviteStore records -- already does. These are the places that trusted the other kind. reset() cleared the referral dimensions in memory and asked Preferences to persist that, while resetVerified() reported success on the records it can verify. A plain reset keeps the same client id, so the owner stamp still matched and the next launch loaded the old cn1_invite* values back and transmitted them, despite reset() promising to forget them. The attribution record is the authority and it IS verifiable, so the dimensions are reconciled against it once per process: if no record stands behind them, they are the stale copy and the erasure finishes on the next launch instead. That is the best an unverifiable store allows, and it is self-healing rather than dependent on the failing write ever succeeding. The provider's identity baseline had the mirror problem. A failed baseline write leaves the same empty value a first registration does -- so the next resetClientId() in that process read the new id as its first baseline, skipped eraseInternal(), and left the old attribution and the queued registrations attached to the identity just reset. Records with no baseline are treated as the erasure that never completed; a device with no records is the genuine first registration it looks like. And when both the PENDING delete and its settled-marker replacement failed, the held fallback was discarded anyway -- committing the resolution with a durable STATE_PENDING on the disk, which re-attribution prefers, so the next launch resubmitted a claim that had already succeeded. The fallback is kept when its own write failed, so the next read retries it. The first is revert-probed: without the reconciliation the test reports the erased campaign still reading "spring". --- .../invite/InviteAttributionProvider.java | 25 +++++- .../codename1/analytics/invite/Invites.java | 82 ++++++++++++++++++- .../invite/InviteConsentAndErasureTest.java | 28 +++++++ 3 files changed, 130 insertions(+), 5 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java b/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java index 47f291b1eb2..d41ba320415 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java @@ -68,9 +68,28 @@ public void init(AnalyticsContext context) { } String last = Preferences.get(PREF_LAST_CLIENT_ID, ""); if (last == null || last.length() == 0) { - // First registration on this device. Record the baseline; this is - // a provider being added, not an identity being erased. - Preferences.set(PREF_LAST_CLIENT_ID, seen); + // No baseline. Which of two things that means is decided by + // whether this device has invite records, because Preferences + // cannot be asked whether a write landed: set() updates a static + // table and swallows the store's answer. + // + // A genuinely first registration has no records, and recording the + // baseline is all there is to do. But a baseline write that failed + // earlier leaves the same empty value beside records that DO + // exist -- and the next resetClientId() in that process then read + // the new id as its first baseline, skipped eraseInternal(), and + // left the old attribution and the queued registrations attached + // to the identity the user had just reset. + // + // Records with no baseline are therefore treated as the erasure + // that never completed, and the baseline advances only once it has. + if (!Invites.hasDurableRecords()) { + Preferences.set(PREF_LAST_CLIENT_ID, seen); + return; + } + if (Invites.eraseInternal()) { + Preferences.set(PREF_LAST_CLIENT_ID, seen); + } return; } if (!last.equals(seen)) { diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 330a4cf0e7a..3d58b88cd24 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1137,6 +1137,12 @@ static int currentLookupEpochForTest() { return lookupEpoch; } + // Package private test seam: lets a test model the next launch, where the + // reconciliation runs again. + static void forgetDimensionReconciliationForTest() { + dimensionsReconciled = false; + } + // Package private test seam: drops the in-memory copy so the next read // comes off the disk, which is what the next process would do. static void forgetCachedAttributionForTest() { @@ -1145,6 +1151,21 @@ static void forgetCachedAttributionForTest() { stateLoaded = false; } + /// Whether this device carries any durable invite record. + /// + /// Asked by the provider when it finds no identity baseline: with records + /// present that is a baseline write that failed rather than a first + /// registration, and the difference decides whether the next identity + /// change erases or is quietly accepted as the first one seen. + /// + /// #### Returns + /// + /// true when an attribution, a pending record or a queued registration + /// exists + static boolean hasDurableRecords() { + return anythingSurvives(); + } + // Package private: the analytics provider hook calls this when the client // id changes underneath us, which is what an erasure request looks like. static boolean eraseInternal() { @@ -1337,6 +1358,7 @@ static void onConsentChanged(boolean allowed) { // catalog's prefix and put a Play dependency and an API floor on every // application that logs a single event. private static void ensureProvider() { + reconcileDimensions(); try { List providers = Analytics.getProviders(); for (Object provider : providers) { @@ -2710,6 +2732,7 @@ private static void resolve(InviteAttribution a, String confidence) { + "pending and will be retried", Log.WARNING); return; } + boolean pendingCleared = true; if (!InviteStore.delete(InviteStore.PENDING)) { // The claim is settled and its record could not be removed, nor // overwritten with the empty one delete() falls back to. Under @@ -2728,11 +2751,19 @@ private static void resolve(InviteAttribution a, String confidence) { Map settled = new LinkedHashMap(); settled.put("state", String.valueOf(STATE_RESOLVED)); if (!writePending(settled)) { + // Both the delete and the replacement failed, so the held copy + // is the only record of what the store should say. Discarding + // it committed the resolution with a durable STATE_PENDING + // still on the disk -- which re-attribution prefers -- and the + // next launch resubmitted a claim that had already succeeded. + pendingCleared = false; Log.p("invite: the pending record survived a resolved claim and could not be " - + "marked settled; this install may be attributed again", Log.WARNING); + + "marked settled; the correction is held and retried", Log.WARNING); } } - forgetPendingFallback(); + if (pendingCleared) { + forgetPendingFallback(); + } resolved = a; attributionLoaded = true; state = STATE_RESOLVED; @@ -2765,6 +2796,53 @@ private static void writeDimensions(InviteAttribution a) { Analytics.setDimension(DIMENSION_MATCH, a.getMatchType()); } + // Whether this process has already reconciled the dimensions with the + // durable record. Once is enough: nothing between here and the next launch + // can put the two back out of step without going through writeDimensions + // or clearDimensions. + private static boolean dimensionsReconciled; + + /// Drops referral dimensions that no durable attribution stands behind. + /// + /// The dimensions live in Preferences, whose writes cannot be verified -- + /// `Preferences.set` updates a static table and swallows the store's + /// answer -- so `reset()` could clear them in memory, fail to persist, and + /// report success: `resetVerified()` only tracks the three InviteStore + /// records, which DO report. A plain reset keeps the same client id, so + /// the owner stamp still matched and the next launch loaded the old + /// `cn1_invite*` values straight back and transmitted them. + /// + /// The attribution record is the authority and it is verifiable. If it is + /// gone and the dimensions are not, the dimensions are the stale copy, and + /// the erasure finishes here instead -- on the next launch rather than the + /// failing one, which is the best any unverifiable store allows. + private static void reconcileDimensions() { + if (dimensionsReconciled) { + return; + } + dimensionsReconciled = true; + try { + if (readAttribution() != null) { + return; + } + Map set = Analytics.getDimensions(); + if (set == null) { + return; + } + for (String dimension : DIMENSIONS) { + if (set.get(dimension) != null) { + // One of them surviving means all of them are suspect; + // clearDimensions() drops the whole set the framework owns + // and leaves the application's own alone. + clearDimensions(); + return; + } + } + } catch (Throwable t) { + Log.e(t); + } + } + private static void clearDimensions() { for (String dimension : DIMENSIONS) { Analytics.clearDimension(dimension); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java index 0ccf7557451..ad707eef6a2 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java @@ -298,6 +298,34 @@ void emptyingTheQueueIsVerifiedLikeEveryOtherDelete() { assertTrue(InviteStore.readOutbox().isEmpty(), "the queue survived"); } + @FormTest + void referralDimensionsWithNoRecordBehindThemAreDropped() { + // reset() clears the dimensions in memory and asks Preferences to + // persist that -- and Preferences cannot say whether it did: set() + // updates a static table and swallows the store's answer, so + // resetVerified() reported success on the three InviteStore records it + // CAN verify while the old values stayed on the disk. A plain reset + // keeps the same client id, so the owner stamp still matched and the + // next launch loaded the referral straight back and transmitted it. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.handleResolution( + InviteTestSupport.resolvedJson("GHOST1", "spring", "sms"), + Invites.MATCH_REFERRER, true); + assertEquals("spring", Analytics.getDimensions().get(Invites.DIMENSION_CAMPAIGN)); + + // The durable record goes; the dimensions are left behind, which is + // what an unpersisted clear looks like on the next launch. + assertTrue(InviteStore.delete(InviteStore.ATTRIBUTION)); + Invites.forgetCachedAttributionForTest(); + Invites.forgetDimensionReconciliationForTest(); + + Invites.checkForInvite(); + + assertNull(Analytics.getDimensions().get(Invites.DIMENSION_CAMPAIGN), + "a referral with no record behind it was kept and would be transmitted"); + } + @FormTest void registeringTheProviderIsNotMistakenForAnErasure() { InviteTestSupport.freshInstall(); From cd49dddb922379ad8c41c9df977d3558bca690a0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:55:06 +0300 Subject: [PATCH 55/99] Invites: an app with no invites would not have linked CN1InviteAppClip.m put both native implementations behind CN1_INCLUDE_INVITE_APPCLIP, but IOSNative.java declares the methods unconditionally -- and ParparVM needs a symbol for every native declaration whether or not anything reaches it. So an ordinary iOS application that never heard of invites would have failed to LINK, on a feature it does not use, which is the worst possible place for this to be felt. I reasoned that dead-code elimination would drop the unreferenced Java methods along with the class nothing registers. That was wrong, and the file next door says so in as many words: CN1WebAuthn.m supplies #else stubs for exactly this reason and explains it. This now does the same, answering what a device with no clip answers anyway, so nothing depends on which branch compiled. Verified by compiling the file with the define OFF. SERVICE_DISCONNECTED arriving as a RESPONSE CODE fell into the terminal default, which records PREF_ATTEMPTED and refuses another read for ever. It is the same transient state the disconnect callback reports, and handling that callback -- as this branch already does -- does not cover this path: an invited install whose exact Play referrer was still available on the next connection settled permanently as organic. It takes the transient route now, so the once-only flag stays unburnt. --- .../referrer/AndroidInstallReferrer.java | 12 ++++++++++ .../iOSPort/nativeSources/CN1InviteAppClip.m | 24 ++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java index d158354f827..2cf2b5b29f7 100644 --- a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java +++ b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java @@ -152,6 +152,18 @@ public void onInstallReferrerSetupFinished(int responseCode) { // read exactly. unavailable(issued, callback, Invites.REASON_NO_MATCH); break; + case InstallReferrerClient.InstallReferrerResponse.SERVICE_DISCONNECTED: + // The SAME transient state the disconnect callback + // reports, arriving through the response code + // instead -- and it is a separate path, so + // handling one and not the other left this one + // falling into the terminal default below. That + // burnt the once-only flag and permanently refused + // another read, for an invited install whose exact + // Play referrer was still there on the next + // connection. + unavailable(issued, callback, Invites.REASON_NO_MATCH); + break; default: // FEATURE_NOT_SUPPORTED is the ordinary answer on a // device with no Play Store -- a sideload, an diff --git a/Ports/iOSPort/nativeSources/CN1InviteAppClip.m b/Ports/iOSPort/nativeSources/CN1InviteAppClip.m index 04d040793e0..d0ff2d3b6e1 100644 --- a/Ports/iOSPort/nativeSources/CN1InviteAppClip.m +++ b/Ports/iOSPort/nativeSources/CN1InviteAppClip.m @@ -121,4 +121,26 @@ JAVA_OBJECT com_codename1_impl_ios_IOSNative_consumeAppClipInviteHandoff___java_ return fromNSString(CN1_THREAD_STATE_PASS_ARG joined); } -#endif +#else + +// Stubs when CN1_INCLUDE_INVITE_APPCLIP is not defined: the build generated no +// App Clip and nothing registers IOSAppClipHandoff, so these natives are +// unreachable. ParparVM still needs the symbols to satisfy the native-method +// declarations on IOSNative.java, which are unconditional -- without them an +// ordinary application that never heard of invites fails to LINK, which is the +// worst place for this feature to be felt. +// +// The answers are the ones a device with no clip would give anyway, so nothing +// depends on which branch compiled. + +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isAppClipHandoffSupported___java_lang_String_R_boolean( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT groupObj) { + return JAVA_FALSE; +} + +JAVA_OBJECT com_codename1_impl_ios_IOSNative_consumeAppClipInviteHandoff___java_lang_String_R_java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT groupObj) { + return JAVA_NULL; +} + +#endif // CN1_INCLUDE_INVITE_APPCLIP From a4fb34201e28a5d6274691a5e8c2a064ab8f3462 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:10:00 +0300 Subject: [PATCH 56/99] Invites: the generated App Clip had no icon, so the archive could not upload ASSETCATALOG_COMPILER_APPICON_NAME was blanked on the clip target. An App Clip is a full application bundle and App Store validation rejects one with no icon, so every invite-enabled iOS archive would have been refused at upload -- for a target the developer never asked to maintain and cannot fix from their own sources. The host's Images.xcassets is copied into the clip rather than a placeholder generated: a clip card showing a different icon from the app it installs is its own confusion, and the person seeing it has installed nothing yet. appendFilesToXcodeProjGroup already adds an .xcassets directory as a single resource -- it has to, or Xcode fails with "Multiple commands produce Contents.json" -- so staging it is all that is needed. A build with no host catalog says so rather than naming a catalog that is not there, which would fail the build instead of the upload. The derived product name went into a single-quoted Ruby literal unescaped, so a legal PRODUCT_NAME containing an apostrophe -- "Friend's Clip" -- broke fix_xcode_schemes.rb and the iOS build with it. It goes through escapeRuby like every neighbouring target. And the ios.invite.appClip documentation still said it was ignored when ios.invite.universalLinks is false. That stopped being true when the two hints were separated: universalLinks now means "I manage the domains myself" and leaves the clip, the app group and the reader in place, so a developer relying on the old wording would get a second target and its signing requirements unannounced. appClip=false is the only thing that suppresses generation, and the entry says so. --- .../codename1/build/shared/BuildHintsIos.java | 8 +++-- .../com/codename1/builders/IPhoneBuilder.java | 29 +++++++++++++++++-- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java index a1419e76c8a..4e77b598fd5 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java @@ -232,9 +232,11 @@ static void register(List h) { + "Set it to `false` only if you ship an App Clip of your own: the " + "build then generates no clip, but the app still carries the shared " + "app group and the reader, so a clip of yours that writes the " - + "documented handoff is still picked up. Ignored when " - + "`ios.invite.universalLinks` is `false`, because iOS can only offer a " - + "clip for a link the app has an associated domain for.")); + + "documented handoff is still picked up. This is the ONLY hint that " + + "suppresses the clip: `ios.invite.universalLinks=false` means you " + + "manage the associated domains yourself and leaves the clip, the " + + "shared app group and the reader in place, because an app that " + + "configured its own domains correctly still needs them.")); h.add(new Hint("ios.invite.appGroup") .group(HintGroup.IOS) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 2c20c76b1d3..94c4059ffd0 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -12373,7 +12373,32 @@ private void appendInviteAppClipTarget(StringBuilder sb, BuildRequest request, // the way the port is. buildSettingsMap.put("CLANG_ENABLE_OBJC_ARC", "YES"); buildSettingsMap.put("CLANG_ENABLE_MODULES", "YES"); - buildSettingsMap.put("ASSETCATALOG_COMPILER_APPICON_NAME", ""); + // An App Clip is a full application bundle and App Store validation + // rejects one with no icon, so the clip carries a catalog of its own. + // + // Blanking this setting -- which is what was here -- produced an + // invite-enabled archive that could not be uploaded at all, for a + // target the developer never asked to maintain. The host's icons are + // copied rather than a placeholder generated: a clip card showing a + // different icon than the app it installs is its own confusion, and + // the person seeing it has not installed anything yet. + // + // appendFilesToXcodeProjGroup already adds an .xcassets directory as a + // single resource -- it has to, or Xcode fails with "Multiple commands + // produce Contents.json" -- so staging it here is all that is needed. + File clipIcons = new File(distDir, name + "/Images.xcassets"); + File hostIcons = new File(distDir, request.getMainClass() + "-src/Images.xcassets"); + if (hostIcons.isDirectory()) { + copyDirectory(hostIcons, clipIcons); + buildSettingsMap.put("ASSETCATALOG_COMPILER_APPICON_NAME", "AppIcon"); + } else { + // No host catalog to copy, which means this build has no icons at + // all and the app target has the same problem. Said out loud + // rather than shipping a setting that names a catalog that is not + // there, which fails the build instead of the upload. + log("Invite attribution: the application has no Images.xcassets, so the App Clip " + + "ships without an icon and the archive will be rejected"); + } for (String key : request.getArgs()) { if (key.startsWith("ios.invite.buildSettings.")) { buildSettingsMap.put( @@ -12412,7 +12437,7 @@ private void appendInviteAppClipTarget(StringBuilder sb, BuildRequest request, sb.append("main_app_target = xcproj.targets.find{|e| e.name==main_class_name}\n" + "main_app_target.add_dependency(clip_target)\n" + "fileref = xcproj.groups.find{|e| e.display_name=='Products'}.new_file('" - + productName + ".app', \"BUILT_PRODUCTS_DIR\")\n" + + escapeRuby(productName) + ".app', \"BUILT_PRODUCTS_DIR\")\n" + "embed_phase = main_app_target.copy_files_build_phases.find{|p| " + "p.name=='Embed App Clips'} || " + "main_app_target.new_copy_files_build_phase('Embed App Clips')\n" From 801bfedd88282e7f553468976ca264037dc2b380 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:43:03 +0300 Subject: [PATCH 57/99] Invites: a tap time the refusal dropped, and a budget charged twice markTerminal() copies the code provenance into the reopenable DECLINED marker so a user who refuses consent when the link arrives and grants it afterwards keeps their exact claim. codeClicked was added to the record later and never added to that list, so a withdraw-then-grant cycle resent the claim with a zero time -- and for an App Clip that time is irrecoverable, because the invocation never reaches the redirect and the clip cleared its own copy as it was read. The clip handoff also charged the retry budget twice: the local read bumped attempts and the claim it leads to bumped them again, so the first network claim started at 2 and the install settled terminal after four requests instead of the five MAX_ATTEMPTS promises. The install-referrer path never bumped there, so the clip path was the inconsistent one; both now spend the budget only on network attempts, and a source that answers nothing at all is bounded by the attribution window on both. And the SpotBugs finding CI caught: a redundant null check on Analytics.getDimensions(), which returns a fresh copy and never null. That gate has been blind on my side all branch -- SpotBugs will not run under the JDK 8 toolchain, so I had been passing -Dspotbugs.skip=true. It runs under JAVA17_HOME, and the recipe needs stating because two earlier attempts reported clean without running at all: the report has to be DELETED first, and the run needs network access or checkstyle fails in the validate phase and spotbugs never executes, leaving the previous report to be read as success. --- .../codename1/analytics/invite/Invites.java | 29 +++++++--- .../analytics/invite/InviteDeliveryTest.java | 55 +++++++++++++++++++ 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 3d58b88cd24..09e2e450cd4 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1666,11 +1666,16 @@ private static boolean markTerminal(int terminalState, String reason) { // // A refusal is reopenable, so the code has to survive it: discarding it // meant a user who denied consent when the link arrived and granted it - // afterwards had the exact claim replaced by a referrer read or a - // statistical match, which can miss or credit a different click. Four - // short fields, and none of them describes the device. + // afterwards had the exact claim replaced by a referrer read, which can + // miss or credit a different click. None of these describes the device. + // + // codeClicked belongs in this list for the same reason and was missed + // when it was added. An App Clip invocation never reaches the redirect, + // so the clip is the only witness to the tap -- and it cleared its own + // copy as it was read. Dropped here, a withdraw-then-grant cycle + // resent the claim with a zero time that nothing could ever recover. for (String key : new String[] {"code", "codeSource", "codeMatch", "codeDeferred", - "codeReferrer"}) { + "codeReferrer", "codeClicked"}) { InviteStore.put(done, key, InviteStore.get(before, key, null)); } if (!writePending(done)) { @@ -2184,7 +2189,15 @@ private static void requestAppClipHandoff(final Map pending) { settleNoHandoff(REASON_NO_MATCH); return; } - bumpAttempts(pending); + // NOT counted as an attempt. The claim this leads to bumps the + // counter itself, and charging the local handoff read as well started + // the first network claim at 2 -- so the install settled terminal + // after four requests instead of the five MAX_ATTEMPTS promises. + // + // The install-referrer path has never bumped here and is the shape + // this now matches. A source that answers nothing at all is bounded by + // the attribution window rather than by this counter, which is true of + // both paths equally. lookupIssuedAt = System.currentTimeMillis(); // The epoch this read was issued under, checked when it answers. // @@ -2825,10 +2838,10 @@ private static void reconcileDimensions() { if (readAttribution() != null) { return; } + // getDimensions() returns a fresh copy and never null, so there + // is nothing to guard here -- and SpotBugs, which is a + // zero-findings gate, says so. Map set = Analytics.getDimensions(); - if (set == null) { - return; - } for (String dimension : DIMENSIONS) { if (set.get(dimension) != null) { // One of them surviving means all of them are suspect; diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java index 4137a8c2814..93d4ea4ba5d 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java @@ -23,6 +23,8 @@ package com.codename1.analytics.invite; import com.codename1.analytics.Analytics; +import com.codename1.analytics.ConsentMode; +import com.codename1.analytics.AnalyticsConsent; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertNull; import com.codename1.junit.FormTest; @@ -307,6 +309,59 @@ void theclipsTapTimeSurvivesIntoTheClaim() { "the claim did not carry the tap time as a number: " + body); } + @FormTest + void theclipsTapTimeSurvivesAConsentRefusal() { + // A refusal is reopenable, so the code survives it -- and the tap time + // has to travel with the code. An App Clip invocation never reaches + // the redirect, so the clip is the only witness, and it cleared its + // own copy as it was read. Dropped from the marker, a + // withdraw-then-grant cycle resends the claim with a zero time that + // nothing can recover. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).build()); + + // The clip answers while consent stands, so the code and its tap time + // are persisted and the claim goes out. + long tappedSeconds = System.currentTimeMillis() / 1000L - 900L; + Invites.checkForInvite(); + InviteTestSupport.pendingHandoff.answer("CLIPDENY", tappedSeconds); + assertEquals(tappedSeconds * 1000L, + InviteStore.getLong(InviteStore.read(InviteStore.PENDING), "codeClicked", 0), + "the fixture never persisted a tap time"); + + // Consent is withdrawn before the claim resolves, which writes the + // reopenable DECLINED marker. + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + + Map marker = InviteStore.read(InviteStore.PENDING); + assertNotNull(marker, "the refusal left no marker"); + assertEquals("CLIPDENY", InviteStore.get(marker, "code", null), + "the fixture did not reach the reopenable marker"); + assertEquals(tappedSeconds * 1000L, + InviteStore.getLong(marker, "codeClicked", 0), + "the tap time did not survive the consent refusal"); + } + + @FormTest + void theclipHandoffReadIsNotChargedAsANetworkAttempt() { + // The claim bumps the counter itself. Charging the local handoff read + // too started the first network claim at 2, so the install settled + // terminal after four requests instead of the five MAX_ATTEMPTS + // promises -- and the referrer path, which never bumped here, got its + // full budget. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.checkForInvite(); + InviteTestSupport.pendingHandoff.answer("CLIPBUDGET"); + + Map record = InviteStore.read(InviteStore.PENDING); + assertNotNull(record); + assertEquals(1, InviteStore.getInt(record, "attempts", 0), + "the handoff read and its claim were both charged"); + } + @FormTest void aclipAnswerThatOutlivedItsLookupIsIgnored() { // The read is asynchronous and everything that supersedes a lookup From aa67da38b25198b8df55145d5e52fbf8721349b9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:01:35 +0300 Subject: [PATCH 58/99] Invites: an erasure that died with the process, and a mark nothing released erasurePending is a static, so a reset() whose deletes failed and whose application then exited left nothing to retry from -- and a plain reset keeps the client id, so the provider sees no identity change on the next launch and does not erase either. The surviving attribution came back and was transmitted, which is the one thing reset() promises cannot happen. There is a durable ERASURE record now, picked up once per process before anything can read or transmit. It is a WRITE recording a failure to DELETE, which is deliberate: a store refusing removals may still accept a small write, and if it refuses that too this is no worse than what came before. Revert-probed -- without the resume the test reports the erased attribution coming back. The in-flight mark was released by a callback that never runs. These requests are fail-silent, and NetworkManager's fail-silent branch only logs -- it never calls handleIOException or handleRuntimeException, so nothing reaches the request's own hooks. A transport failure left the entry marked for the life of the process and every automatic drain skipped it, trading an amplification bug for a registration only an explicit flush() or a restart would resend. It is a 60-second time bound now: a burst happens in milliseconds, so the bound serves the original purpose completely while guaranteeing the queue heals, and expired marks are dropped as they are read. The Play referrer's one-shot flag was burnt before the code was handed over, so a process killed in between lost the exact referrer for ever and the next launch settled the invited install as no-match. The handoff comes first now. Being precise about what that buys: Invites marshals onto the EDT, so a callback arriving on a binder thread has its persist QUEUED rather than done. The window goes from always to the callSerially latency, not to zero. Closing it completely would need the port to know what the framework did with the value, which the SPI deliberately does not tell it. And DM_NUMBER_CTOR on the new map -- new Long() where Long.valueOf() belongs. Caught by running SpotBugs locally this time rather than by CI. --- .../analytics/invite/InviteStore.java | 16 ++++ .../codename1/analytics/invite/Invites.java | 75 ++++++++++++++++++- .../referrer/AndroidInstallReferrer.java | 19 ++++- .../invite/InviteResilienceTest.java | 32 ++++++++ 4 files changed, 138 insertions(+), 4 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java index 5b9a474b612..b08d4d68cbf 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java @@ -51,6 +51,22 @@ final class InviteStore { // Mint registrations that have not reached the link service yet. static final String OUTBOX = "CN1$InviteOutbox"; + /// Records that an erasure was asked for and could not finish. + /// + /// Its own record because it has to outlive the process: `erasurePending` + /// is a static, so a reset() whose deletes failed and whose application + /// then exited left nothing behind to retry from -- and a plain reset + /// keeps the client id, so the provider sees no identity change on the + /// next launch and never erases either. The surviving attribution came + /// back and was transmitted, which is what reset() promises will not + /// happen. + /// + /// Tiny and written rather than deleted, because the failure being + /// recorded is a failure to DELETE: a store that refuses removals may + /// still accept a small write, and if it refuses that too this is no worse + /// than what came before. + static final String ERASURE = "CN1$InviteErasureOwed"; + // Entries leave this queue when the server acknowledges them, so the cap is // a safety ceiling rather than a working limit -- and it was far too low // for that. A dropped registration is not recoverable: the code carries no diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 09e2e450cd4..f7bb3e07dd3 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1048,6 +1048,15 @@ public static void reset() { // would block a device that has no invite data to block over. if (anythingSurvives()) { erasurePending = true; + // And durably, because the flag above is a static. A reset + // whose deletes failed and whose process then exited left + // nothing to retry from, and a plain reset keeps the client + // id -- so the provider sees no identity change on the next + // launch and does not erase either. The surviving records came + // back and were transmitted. + Map owed = new LinkedHashMap(); + owed.put("at", String.valueOf(System.currentTimeMillis())); + InviteStore.write(InviteStore.ERASURE, owed); } } } @@ -1137,6 +1146,13 @@ static int currentLookupEpochForTest() { return lookupEpoch; } + // Package private test seam: models the next process, where nothing in + // memory remembers that an erasure was owed. + static void forgetErasurePendingForTest() { + erasurePending = false; + dimensionsReconciled = false; + } + // Package private test seam: lets a test model the next launch, where the // reconciliation runs again. static void forgetDimensionReconciliationForTest() { @@ -1233,6 +1249,9 @@ static boolean eraseInternal() { state = STATE_NONE_FOUND; stateLoaded = true; erasurePending = false; + // The durable marker goes with the flag, or every later launch would + // erase again and settle a fresh install as terminal. + InviteStore.delete(InviteStore.ERASURE); return true; } @@ -1358,6 +1377,7 @@ static void onConsentChanged(boolean allowed) { // catalog's prefix and put a Play dependency and an API floor on every // application that logs a single event. private static void ensureProvider() { + resumeOwedErasure(); reconcileDimensions(); try { List providers = Analytics.getProviders(); @@ -2829,6 +2849,23 @@ private static void writeDimensions(InviteAttribution a) { /// gone and the dimensions are not, the dimensions are the stale copy, and /// the erasure finishes here instead -- on the next launch rather than the /// failing one, which is the best any unverifiable store allows. + /// Picks up an erasure that a previous process could not finish. + /// + /// Called on the same once-per-process path as the dimension + /// reconciliation, and before anything can read or transmit a record: the + /// marker means the records on the disk are ones the user asked to be rid + /// of. + private static void resumeOwedErasure() { + Map owed = InviteStore.read(InviteStore.ERASURE); + if (owed == null || owed.isEmpty()) { + return; + } + erasurePending = true; + if (eraseInternal()) { + InviteStore.delete(InviteStore.ERASURE); + } + } + private static void reconcileDimensions() { if (dimensionsReconciled) { return; @@ -3011,7 +3048,23 @@ private static void notifyUnavailable(String reason) { // // Not persisted: a process that dies with requests outstanding should // retry them, and an empty set on the next launch is what makes it. - private static final List inFlight = new ArrayList(); + private static final Map inFlight = new LinkedHashMap(); + + /// How long an entry stays skippable after its request goes out. + /// + /// The mark exists to stop one burst of invites reposting the whole queue, + /// and a burst happens inside milliseconds -- so a short bound serves that + /// completely while guaranteeing the queue heals. + /// + /// It is a TIME bound rather than a callback because the callback cannot + /// be relied on. These requests are fail-silent, and NetworkManager's + /// fail-silent branch only logs: it never calls handleIOException or + /// handleRuntimeException, so nothing reaches the request's own exception + /// hooks. A transport failure therefore left the entry marked for the life + /// of the process and every automatic drain skipped it -- trading an + /// amplification bug for a registration that only an explicit flush() or a + /// restart would ever resend. + static final long IN_FLIGHT_WINDOW_MS = 60000L; /// Records that a queued registration was evicted to keep the outbox /// under its cap. @@ -3090,6 +3143,22 @@ private static void drainOutbox() { /// answer. true everywhere else, including the flush create() issues /// itself -- that one is what turned a burst of N invites into N(N+1)/2 /// requests, and no invite in a burst needs its predecessors resent. + /// Whether this entry's request went out recently enough to skip. + /// + /// An expired mark is dropped as it is read, so a queue that outlives its + /// requests cleans itself rather than growing for the life of the process. + private static boolean issuedRecently(String json) { + Long at = inFlight.get(json); + if (at == null) { + return false; + } + if (System.currentTimeMillis() - at.longValue() < IN_FLIGHT_WINDOW_MS) { + return true; + } + inFlight.remove(json); + return false; + } + private static void drainOutbox(boolean skipInFlight) { if (!allowed()) { return; @@ -3117,13 +3186,13 @@ private static void drainOutbox(boolean skipInFlight) { // Re-posting an entry that did land is harmless: the server keys on // the code and treats a repeat from the same inviter as idempotent. for (String json : outbox) { - if (skipInFlight && inFlight.contains(json)) { + if (skipInFlight && issuedRecently(json)) { // Already on the wire. Its response will remove it or leave it // for the next drain; sending it again buys nothing and is how // one burst of invites became thousands of requests. continue; } - inFlight.add(json); + inFlight.put(json, Long.valueOf(System.currentTimeMillis())); // The body is rewritten, the KEY is not. The outbox still holds the // original string, and that is what has to be removed when the // server accepts it. diff --git a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java index 2cf2b5b29f7..f315f1b789f 100644 --- a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java +++ b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java @@ -236,12 +236,29 @@ private void deliver(int issued, InstallReferrerClient client, unavailable(issued, callback, Invites.REASON_NO_MATCH); return; } - Preferences.set(PREF_ATTEMPTED, true); if (referrer == null || referrer.length() == 0) { + // Read successfully and there is no invite behind this install. + // Definitive, so the flag is burnt: asking again cannot change it. + Preferences.set(PREF_ATTEMPTED, true); unavailable(issued, callback, Invites.REASON_NO_MATCH); return; } + // The handoff FIRST, the flag after. + // + // Burning the flag before handing the referrer over meant a process + // killed in between lost the exact code for ever: the next launch saw + // isSupported() false and settled the invited install as no-match. + // Invites persists the code inside this call when it runs on the EDT, + // which is the common case. + // + // It is not a guarantee, and saying so is the point: the framework + // marshals onto the EDT, so when this callback arrives on a binder + // thread the persist is queued rather than done, and a process killed + // inside that window still loses it. The window goes from "always" to + // "the callSerially latency", which is the most the SPI shape allows + // without the port knowing what the framework did with the value. referrer(issued, callback, referrer, clickSeconds, beginSeconds); + Preferences.set(PREF_ATTEMPTED, true); } private void finish(int issued, InstallReferrerCallback callback, String reason) { diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 4ffece5873a..f2316cfaf2b 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -627,6 +627,38 @@ void asettledClaimWhosePendingRecordSurvivesIsNotAskedAgain() { "a settled claim was left pending and would be submitted again"); } + @FormTest + void anerasureOwedSurvivesTheProcessThatCouldNotFinishIt() { + // erasurePending is a static. A reset whose deletes failed and whose + // process then exited left nothing to retry from -- and a plain reset + // keeps the client id, so the provider sees no identity change on the + // next launch and does not erase either. The surviving attribution + // came back and was transmitted, which is the one thing reset() + // promises will not happen. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.handleResolution( + InviteTestSupport.resolvedJson("OWED1", "spring", "sms"), + Invites.MATCH_REFERRER, true); + assertNotNull(Invites.getAttribution(), "the fixture did not resolve"); + + InviteStore.failNextDeleteForTest(InviteStore.ATTRIBUTION); + Invites.reset(); + assertNotNull(InviteStore.read(InviteStore.ERASURE), + "a failed reset left no durable trace of the erasure it owed"); + + // The next process: nothing in memory remembers, and the store has + // recovered. + Invites.forgetErasurePendingForTest(); + Invites.forgetCachedAttributionForTest(); + Invites.checkForInvite(); + + assertNull(Invites.getAttribution(), + "the erasure was never finished and the attribution came back"); + assertNull(InviteStore.read(InviteStore.ERASURE), + "the marker outlived the erasure it asked for"); + } + @Test @EdtTest void theReferrerCodeIsPersistedBeforeTheClaimGoesOut() { From c67db7d1f99a21de3df1f423e9651aface193529 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:47:15 +0300 Subject: [PATCH 59/99] Invites: warm deep links keep their intent, and an upgrade keeps its dimensions Three review findings, each a case the code got wrong in a way nothing reports. The warm-link path stored a data-less COPY of the intent. That fixed one reader -- an onNewIntent() override reading the intent it was handed -- and broke two others: the documented `android.intent.data` property is published from whatever the activity has stored, and native integrations read getActivity().getIntent().getData(). Both saw a warm deep link as no deep link at all while cold links still carried it. The intent is now stored unmodified and the url is marked delivered by remembering the intent's identity, which suppresses only getAppArg()'s second delivery. Dimension files with no owner stamp are adopted rather than dropped. An absent stamp means the file predates the stamp -- persistDimensions() writes both keys into one preferences record -- and back then setDimension() reserved no prefix and the framework wrote no `cn1_` dimension, so anything with that prefix in such a file is the application's own and dropping it deleted segmentation from an app that never asked for an erasure. A stamp that is present and different is still foreign. The reserved prefix is now documented on setDimension() rather than only on resetClientId(). abandonReplacement() verifies the deletion, like the resolved path already did. Ignoring it left the replacement's PENDING record on disk while memory moved on to RESOLVED, and loadState() prefers a surviving pending record -- so a claim that had already ended definitively was resubmitted every launch, for ever. The second, open-coded copy of that abandonment now calls it. Both behaviour changes are covered by tests verified against the unfixed code; the unused `pending` parameter PMD flagged is gone with it. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/analytics/Analytics.java | 57 +++++++++++------ .../codename1/analytics/invite/Invites.java | 48 ++++++++++---- .../impl/android/AndroidImplementation.java | 63 ++++++++++++++----- .../analytics/AnalyticsFacadeTest.java | 23 ++++--- .../invite/InviteResilienceTest.java | 25 ++++++++ 5 files changed, 160 insertions(+), 56 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/Analytics.java b/CodenameOne/src/com/codename1/analytics/Analytics.java index 0807b1ab887..f2789c919dc 100644 --- a/CodenameOne/src/com/codename1/analytics/Analytics.java +++ b/CodenameOne/src/com/codename1/analytics/Analytics.java @@ -371,6 +371,12 @@ public static void setUserProperty(String key, String value) { /// with every first-party batch. Passing a null value removes the key. /// Null or empty keys are ignored. /// + /// The `cn1_` prefix is RESERVED for dimensions the framework writes on + /// your behalf, and those are cleared by [#resetClientId] because they + /// identify the user across installs. A key of your own under that prefix + /// is accepted -- it always was -- but it will be erased along with them, + /// so pick another one. + /// /// #### Parameters /// /// - `key`: the dimension key @@ -595,24 +601,34 @@ private static void loadDimensions() { // to their new client id, one launch later and with nothing in memory // left to notice. // - // An ABSENT stamp counts as foreign, not as current. That is the - // difference between a mechanism that works and one that works only - // when the write it depends on succeeded: on a device whose file - // predates the stamp -- or where the storage failure that broke the - // erasure also stopped the stamp being written -- there is nothing to - // compare, and treating that as current is exactly the case being - // defended against. Unknown provenance for a dimension the FRAMEWORK - // owns resolves to dropping it. + // An ABSENT stamp is ADOPTED, not treated as foreign, and the reason + // is specific enough to be worth writing down -- the strict reading + // was tried first and destroyed live data. + // + // Dropping a reserved dimension is only ever right when the FRAMEWORK + // wrote it, and the framework cannot have written one into an + // unstamped file. Every write of this record goes through + // persistDimensions(), which stamps in the same call, and Preferences + // keeps both keys in one record, so a file written by a version that + // owns reserved dimensions always carries a stamp. An absent one means + // the file predates the feature -- and back then `setDimension` + // accepted every key, documented no reserved prefix, and never wrote a + // `cn1_` dimension itself. So anything with that prefix in an + // unstamped file is the APPLICATION's, and dropping it silently + // deletes analytics segmentation from an app that did nothing wrong + // and never asked for an erasure. // - // The cost of being wrong that way is one re-resolution: a reserved - // dimension dropped here is rewritten by the next attribution. The - // cost of being wrong the other way is an erased identity coming back. + // The erasure case the stamp defends against still works, because it + // cannot produce this state: the identity reset happens on a version + // that stamps, so the surviving file carries the PREVIOUS id and + // compares unequal below. // // clientId() rather than the field, because loading can happen before // the id has been materialised and a null would make every file look - // current. It does not read dimensions, so there is no recursion. + // foreign. It does not read dimensions, so there is no recursion. String owner = Preferences.get(PREF_DIMENSIONS_OWNER, null); - boolean foreign = !clientId().equals(owner); + boolean unstamped = owner == null; + boolean foreign = !unstamped && !clientId().equals(owner); String[] rows = split(stored, '\n'); for (String row : rows) { if (row.length() == 0) { @@ -641,10 +657,11 @@ private static void loadDimensions() { } DIMENSIONS.put(key, value); } - if (foreign) { - // Rewritten under the current identity so the drop happens once. - // If this write fails too the next launch simply repeats it, which - // is the correct outcome either way. + if (foreign || unstamped) { + // Rewritten under the current identity so the drop -- or, for an + // unstamped file, the one-time adoption -- happens once. If this + // write fails the next launch simply repeats it, which is the + // correct outcome either way. persistDimensions(); } } @@ -677,7 +694,11 @@ private static void persistDimensions() { // makes a surviving file distinguishable from a current one after a // restart, when nothing in memory remembers that an erasure was asked // for. - Preferences.set(PREF_DIMENSIONS_OWNER, clientId == null ? "" : clientId); + // clientId() rather than the field: the field is null until something + // materialises the id, and stamping a placeholder would make the file + // read as foreign on the next launch and drop the dimensions this call + // was in the middle of saving. + Preferences.set(PREF_DIMENSIONS_OWNER, clientId()); } // Replaces the delimiter characters so the persisted form parses back diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index f7bb3e07dd3..3d9af329bbd 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1613,8 +1613,32 @@ private static boolean abandonReplacement() { if (getAttribution() == null) { return false; } - InviteStore.delete(InviteStore.PENDING); - forgetPendingFallback(); + // The removal is VERIFIED, for the same reason the resolved path + // verifies it. Ignoring the result here left the replacement's PENDING + // record on the disk while memory moved on to RESOLVED, and + // loadState() prefers a surviving pending record over the durable + // attribution -- so the next launch resubmitted a claim that had + // already ended definitively, every launch, for ever, with the public + // state reading pending the whole time. + // + // Overwritten with the terminal state when the store will not remove + // it: that says what the deletion would have said, in a record the + // store has just proved it will not delete, and it carries no code and + // no inviter. If that write fails too the held copy is kept and + // retried, rather than being discarded onto a disk that still says + // PENDING. + if (!InviteStore.delete(InviteStore.PENDING)) { + Map settled = new LinkedHashMap(); + settled.put("state", String.valueOf(STATE_RESOLVED)); + if (writePending(settled)) { + forgetPendingFallback(); + } else { + Log.p("invite: an abandoned replacement could not be cleared or marked " + + "settled; the correction is held and retried", Log.WARNING); + } + } else { + forgetPendingFallback(); + } state = STATE_RESOLVED; stateLoaded = true; deferredStarted = false; @@ -2005,7 +2029,7 @@ private static void beginDeferred() { requestReferrer(source); return; } - requestAppClipHandoff(pending); + requestAppClipHandoff(); } private static boolean safeSupported(InstallReferrerSource source) { @@ -2161,7 +2185,7 @@ private static void fallBackToMatchImpl() { if (pending == null) { return; } - requestAppClipHandoff(pending); + requestAppClipHandoff(); } private static void onEdt(Runnable r) { @@ -2192,8 +2216,10 @@ private static void onEdt(Runnable r) { /// fingerprint now, and the code this reads is one the person produced /// themselves by tapping an invite. /// - /// - `pending`: the pending record, for the attempt budget - private static void requestAppClipHandoff(final Map pending) { + /// Takes no pending record: the callback is asynchronous, and a record + /// captured before the call can be stale by the time the answer lands, so + /// it reads `pendingRecord()` at that point instead. + private static void requestAppClipHandoff() { final AppClipHandoffSource source = appClipSource; if (source == null || !source.isSupported()) { // No clip on this platform or this build, which is the ordinary @@ -2633,12 +2659,10 @@ static void handleResolution(String payload, String matchType, boolean deferred, // still with the durable attribution sitting beside it. The // replacement attempt is dropped and the install goes back // to what it was. - InviteStore.delete(InviteStore.PENDING); - forgetPendingFallback(); - state = STATE_RESOLVED; - stateLoaded = true; - deferredStarted = false; - lookupIssuedAt = 0; + // Through abandonReplacement() rather than open-coded: this + // was a second copy of it, and when the deletion there grew + // a verification this copy silently kept the old behaviour. + abandonReplacement(); return; } // Terminal, and it has to be durable. Deleting the record is diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 788d9c8aeff..e0dbb9f819b 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -1697,30 +1697,54 @@ static void dispatchNewIntentUrl(Intent intent) { // rather than whatever the previous intent left cached. instance.setAppArg(null); clearIntentProperties(); - // The data is consumed on a COPY, never on the caller's intent. + // The intent is stored UNMODIFIED, and the url is marked as delivered by + // remembering the intent's identity instead of by erasing its data. // - // getAppArg() rebuilds the url from the activity's stored intent, and - // CodenameOneActivity.onStop() clears the app arg -- so leaving the data - // in place meant the next read after a resume rebuilt the same url and - // an application that handles AppArg in start() saw the deep link a - // second time, opening the same invite twice for one tap. + // Two earlier shapes were both wrong. Clearing the data on the intent + // passed in broke the ordinary way to extend onNewIntent() -- + // super.onNewIntent(intent) followed by the subclass reading + // intent.getData(), which had just been nulled underneath it. Storing a + // data-less COPY fixed that one and broke two more readers: the + // documented `android.intent.data` property is published from whatever + // the activity has stored, and native integrations read + // getActivity().getIntent().getData() after onNewIntent(). Both saw a + // warm deep link as no deep link at all while cold links still carried + // it -- an asymmetry an application has no way to work around. // - // Clearing it on the intent passed in was worse. This runs from - // CodenameOneActivity.onNewIntent(), and the ordinary way to extend that - // is super.onNewIntent(intent) followed by the subclass reading - // intent.getData() -- which had just been set to null underneath it, so - // custom deep-link routing that worked before lost the url entirely. The - // copy is what the activity stores; the object the override holds is - // left exactly as the OS handed it over. - android.content.Intent consumed = new android.content.Intent(intent); - consumed.setData(null); - getActivity().setIntent(consumed); + // What actually has to be suppressed is narrower than the data: only + // getAppArg()'s rebuilding of the url from the stored intent, because + // CodenameOneActivity.onStop() clears the app arg and the next read + // after a resume would otherwise report the same deep link a second + // time and open one tapped invite twice. + getActivity().setIntent(intent); + markAppArgDelivered(intent); + // Published here rather than left to getAppArg(), since the properties + // for the previous intent were just cleared and the reader that used to + // repopulate them lazily is exactly the one now suppressed. + publishIntentProperties(getActivity(), intent); Display.getInstance().setProperty("AppArg", data.toString()); } catch (Throwable t) { com.codename1.io.Log.e(t); } } + /// Identity of the intent whose url [#dispatchNewIntentUrl] already delivered as + /// the app arg. Weak because it needs to outlive nothing: the activity holds the + /// intent, and once it stores a different one this reference is free to go. + private static java.lang.ref.WeakReference deliveredAppArgIntent; + + private static void markAppArgDelivered(Intent intent) { + synchronized (intentPropertyLock) { + deliveredAppArgIntent = new java.lang.ref.WeakReference(intent); + } + } + + private static boolean isAppArgDelivered(Intent intent) { + synchronized (intentPropertyLock) { + return deliveredAppArgIntent != null && deliveredAppArgIntent.get() == intent; + } + } + private static void clearIntentProperties() { synchronized (intentPropertyLock) { if (Display.isInitialized()) { @@ -3790,6 +3814,13 @@ public String getAppArg() { intent.removeExtra(Intent.EXTRA_TEXT); Uri u = intent.getData(); String scheme = intent.getScheme(); + if (u != null && isAppArgDelivered(intent)) { + // dispatchNewIntentUrl() already handed this url over as the app arg + // on the warm path. The data stays on the intent for the readers that + // want it -- `android.intent.data` above, and native code asking the + // activity for its intent -- and only the second delivery is dropped. + u = null; + } if (u == null && intent.getExtras() != null) { if (intent.getExtras().keySet().contains("android.intent.extra.STREAM")) { try { diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java index 0201414d798..c4a942985f5 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/AnalyticsFacadeTest.java @@ -128,22 +128,25 @@ void dimensionsFromTheCurrentIdentityAreKept() { } @FormTest - void anUnstampedFileIsNotTrustedWithFrameworkDimensions() { - // The case a read-back check cannot reach and the stamp only covers if - // it is treated strictly: a file whose stamp was never written -- - // because it predates the stamp, or because the same storage failure - // that broke the erasure also stopped the stamp landing. Treating an - // absent stamp as current is exactly the state being defended against. + void anUnstampedFileIsAdoptedRatherThanDropped() { + // A file written before the stamp existed, which is what every app + // upgrading from an earlier release has. This was briefly treated as + // foreign -- absent provenance resolving to "drop it" -- and that read + // deleted live data: the framework cannot have written a reserved + // dimension into an unstamped file (persistDimensions() stamps in the + // same call, into the same preferences record), and before this + // feature setDimension() accepted every key and reserved no prefix. So + // a `cn1_` key here is the APPLICATION's, and dropping it silently + // destroys segmentation for an app that never asked for an erasure. Analytics.clearProviders(); Analytics.clearDimensions(); Analytics.simulateSurvivingDimensionsForTest( "cn1_campaign\tspring\nplan\tpro", null); Map loaded = Analytics.getDimensions(); - assertNull(loaded.get("cn1_campaign"), - "an unstamped referral was trusted and reloaded"); - assertEquals("pro", loaded.get("plan"), - "the application's own dimension was destroyed with it"); + assertEquals("spring", loaded.get("cn1_campaign"), + "an upgrading app lost a dimension it set under the old contract"); + assertEquals("pro", loaded.get("plan")); } @FormTest diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index f2316cfaf2b..57785f12cbf 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -1180,6 +1180,31 @@ void aFailedReplacementPutsTheInstallBackWhereItWas() { "the install came back pending on the next launch"); } + @Test + @EdtTest + void aFailedReplacementWhosePendingRecordSurvivesIsNotAskedAgain() { + // The same abandonment, with a store that refuses to delete the record + // AND refuses the empty overwrite delete() falls back to. Memory moved + // on to RESOLVED and the disk still said PENDING -- which loadState() + // prefers under re-attribution -- so a claim that had already ended + // definitively was resubmitted on every launch, for ever, with the + // public state reading pending throughout. + Invites.handleResolution(InviteTestSupport.resolvedJson("FIRST4", "c1", "sms"), + Invites.MATCH_DIRECT, false); + Invites.setReattribution(true); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/SECOND4"); + assertEquals(Invites.STATE_PENDING, Invites.getState()); + + InviteStore.failNextDeleteForTest(InviteStore.PENDING); + Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_DIRECT, false); + + Invites.forgetLoadedState(); + assertEquals(Invites.STATE_RESOLVED, Invites.getState(), + "an abandoned replacement survived on disk and reopened the lookup"); + assertNotNull(Invites.getAttribution(), + "the install lost the attribution it already had"); + } + @Test @EdtTest void aResumedLookupDoesNotAnnounceItselfToAListenerAlreadyTold() { From b19ff95252dea81585375c2d66a04382baac1cfe Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:14:42 +0300 Subject: [PATCH 60/99] Invites: a "not yet" answer is asked again in the same process beginDeferred() runs at most once per process, which is right for the first attempt and wrong for every later one. The lookup is fail-silent, so a request that never answered leaves deferredStarted set with nothing to clear it, and a claim answered with "retry" -- the ordinary state of an invite minted offline, whose registration has not landed yet -- is pending with no attempt outstanding. Either way the documented call-me-from-start() contract did nothing for the rest of the run: the invite resolved on the next cold start, after an onboarding that could have had its payload, its callback and its dimensions. checkForInvite() now re-arms a pending lookup with nothing in flight, which is what flush() already did for the regained-connectivity case. Bounded by lookupInFlight(), so an application that calls it from every form cannot spend the attempt budget faster than one attempt per retry interval, and by the persisted cap and the attribution window beyond that. The contract is documented on the method rather than left to be discovered. The plugin's source-level test asserted the intent-copy shape that the previous commit replaced, and failed build-test (8) and build-linux-jdk8 on exactly that. It now asserts what replaced it: the stored intent keeps its data, the url is marked delivered by identity, and getAppArg() honours the mark. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 37 ++++++++++++++- .../builders/AndroidInviteNewIntentTest.java | 41 +++++++++++----- .../invite/InviteResilienceTest.java | 47 +++++++++++++++++++ 3 files changed, 113 insertions(+), 12 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 3d9af329bbd..be5092dea3f 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -520,6 +520,14 @@ public static InviteListener getInviteListener() { /// Safe and cheap to call on every start; it will not attribute twice and /// will not report twice. /// + /// Call it on every start rather than only the first. A lookup that ended + /// with "not yet" -- the invite exists but the inviter minted it offline + /// and their registration has not reached the service -- is retried here, + /// at most once per retry interval, so an invite that becomes claimable + /// during the session is picked up in the session rather than on the next + /// cold start. [#flush] does the same for an application that knows it has + /// just regained connectivity. + /// /// #### Returns /// /// true when the launch argument carried an invite link @@ -567,7 +575,7 @@ public static boolean checkForInvite() { } } if (!consumed) { - beginDeferred(); + resumeDeferred(); } return consumed; } @@ -1878,6 +1886,33 @@ private static Map pendingRecord() { return pending; } + // beginDeferred() runs at most once per process, which is right for the + // FIRST attempt and wrong for every later one: the lookup is fail-silent, + // so a request that never answered leaves deferredStarted set with nothing + // to clear it, and a claim answered with "retry" -- the ordinary state of + // an invite minted offline, whose registration has not landed yet -- is + // pending with no attempt outstanding. Either way the documented + // call-me-from-start() contract did nothing at all for the rest of the + // process: the invite resolved on the next cold start, after an onboarding + // that could have had its payload. + // + // Bounded by lookupInFlight(), so an application that calls + // checkForInvite() from every form cannot spend the attempt budget faster + // than one attempt per lookupRetryDelay, and by the persisted attempt cap + // and the attribution window beyond that. + // + // The epoch is bumped for the reason flush() bumps it: the retry + // supersedes whatever the last attempt left outstanding, and without it an + // answer still on the wire can land after the retry resolved and overwrite + // an exact attribution with a statistical one. + private static void resumeDeferred() { + if (deferredStarted && getState() == STATE_PENDING && !lookupInFlight()) { + lookupEpoch++; + deferredStarted = false; + } + beginDeferred(); + } + private static void beginDeferred() { if (deferredStarted) { return; diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java index 7752eafe791..592b2be7cc6 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AndroidInviteNewIntentTest.java @@ -97,24 +97,43 @@ void theOverrideRunsOnTheEventDispatchThread() throws IOException { } @Test - void theConsumedUrlIsClearedOnAcopyNotOnTheCallersIntent() throws IOException { + void theDeliveredUrlIsMarkedRatherThanErased() throws IOException { // dispatchNewIntentUrl runs from CodenameOneActivity.onNewIntent, and - // the ordinary way to extend that is super.onNewIntent(intent) followed - // by the subclass reading intent.getData(). Clearing the data on THAT - // object set it to null underneath the override, so custom deep-link - // routing that worked before lost the url entirely. + // every reader of that intent has to survive it. + // + // Clearing the data on the caller's intent broke the ordinary way to + // extend onNewIntent -- super.onNewIntent(intent) followed by the + // subclass reading intent.getData(), which had just been nulled + // underneath it. Storing a data-less COPY fixed that one and broke two + // more: the documented `android.intent.data` property is published from + // whatever the activity has stored, and native integrations read + // getActivity().getIntent().getData(). Both saw a warm deep link as no + // deep link at all while cold links still carried it. + // + // So the intent is stored as it arrived and the url is marked + // delivered by identity, which suppresses the one thing that actually + // had to be suppressed: getAppArg() rebuilding the url from the stored + // intent after onStop() cleared the app arg, and opening one tapped + // invite twice. File port = new File(ANDROID_PORT); assertTrue(port.isFile(), "the port must be readable: " + port.getAbsolutePath()); String source = new String(Files.readAllBytes(port.toPath()), StandardCharsets.UTF_8); int at = source.indexOf("static void dispatchNewIntentUrl("); assertTrue(at > 0, "dispatchNewIntentUrl is gone"); String block = source.substring(at, source.indexOf("\n }", at)); - assertTrue(!block.contains("intent.setData(null)"), - "the caller's intent is mutated, so a subclass reading it after " - + "super.onNewIntent() finds no data"); - assertTrue(block.contains("new android.content.Intent(intent)") - && block.contains("consumed.setData(null)"), - "the url is no longer consumed on a copy"); + assertTrue(!block.contains("setData(null)"), + "the url is erased from an intent again, so a reader of the stored " + + "intent sees a warm deep link as no deep link"); + assertTrue(block.contains("getActivity().setIntent(intent);"), + "the activity no longer stores the intent it was handed"); + assertTrue(block.contains("markAppArgDelivered(intent);"), + "nothing marks the url delivered, so getAppArg() reports it a " + + "second time after a resume"); + assertTrue(block.contains("publishIntentProperties(getActivity(), intent);"), + "the intent properties are not published, and the reader that " + + "used to publish them lazily is the one now suppressed"); + assertTrue(source.contains("isAppArgDelivered(intent)"), + "getAppArg() does not honour the delivered mark"); } @Test diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 57785f12cbf..ab52f5e96d9 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -148,6 +148,53 @@ void aCodeTheServerHasNotSeenYetIsNotSettledAsOrganic() { assertNotNull(Invites.getAttribution(), "the late answer was refused"); } + @FormTest + void aNotYetAnswerIsAskedAgainInTheSameProcess() { + // beginDeferred() runs at most once per process, so after a "not yet" + // the documented call-me-from-start() contract did nothing for the rest + // of the run: the request had already completed, no delayed retry + // exists, and deferredStarted stayed set. An invite that became + // claimable seconds later -- the whole point of the offline-mint + // window -- waited for the next cold start, withholding its payload, + // its callback and its dimensions through the entire onboarding. + // + // Driven through the referrer source, because that is the path that + // sets deferredStarted: handleUrl() issues its claim directly and + // leaves the flag alone, so a fixture built on it re-enters + // beginDeferred() either way and cannot tell the two behaviours apart. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.lookupRetryDelay = 0L; + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + callback.onReferrer("utm_source=cn1_invite&cn1_invite=RETRY1", 0L, 0L); + } + }); + try { + Invites.checkForInvite(); + assertTrue(implementation.getQueuedRequests().size() > 0, + "the fixture never issued a first claim, so it proves nothing"); + Invites.handleResolution("{\"resolved\":false,\"retry\":true}", + Invites.MATCH_REFERRER, true); + assertEquals(Invites.STATE_PENDING, Invites.getState(), + "a not-yet answer was treated as a final no"); + implementation.clearQueuedRequests(); + + // The next start, or the next form: the same call the application + // already makes, and the only one it is told to make. + Invites.checkForInvite(); + + assertTrue(implementation.getQueuedRequests().size() > 0, + "a not-yet answer was never asked again in this process"); + } finally { + Invites.lookupRetryDelay = 30000L; + } + } + @Test @EdtTest void aPlainNoIsStillFinalEvenBesideTheRetryAnswer() { From 44b972c821a7b6eb09efe9588f1157ae6d947029 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 20:40:08 +0300 Subject: [PATCH 61/99] Invites: the durable attribution is the authority for its dimensions too reconcileDimensions() only ever reconciled one way. It dropped reserved dimensions with no record behind them, and accepted whatever the dimensions said whenever a record existed -- so it never noticed the opposite failure. Preferences.set swallows its write failure, so a resolve can commit the attribution and fail to persist the four dimensions: correct in memory for the rest of that process, and gone on the next launch. Every later batch then carried no campaign at all, or -- under re-attribution, where the previous invite's values are still on the disk -- the campaign the install no longer belonged to, crediting its revenue to the wrong cohort. Nothing looked again, because the only thing that could have was satisfied by the record existing. The record is the half that can report whether it was written, so it is the authority: when the persisted dimensions disagree with it they are rewritten from it. Compared first, so an ordinary launch does not pay for a storage write it has no use for. Verified against the unfixed code, where the test sees the previous campaign survive a resolve that replaced it. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 42 ++++++++++++++++++- .../invite/InviteResilienceTest.java | 33 +++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index be5092dea3f..83f00ac3904 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -2931,7 +2931,28 @@ private static void reconcileDimensions() { } dimensionsReconciled = true; try { - if (readAttribution() != null) { + InviteAttribution durable = readAttribution(); + if (durable != null) { + // The record stands, so the dimensions are rewritten FROM it + // rather than merely accepted. Reconciliation is two-sided: + // dimensions with no record behind them are stale and go, and a + // record whose dimensions disagree is the authority, because it + // is the half that can report whether it was written. + // + // Preferences cannot. A resolve that committed the attribution + // and then failed to persist the dimensions looked complete -- + // the values were right in memory for the rest of that process + // -- and the next launch loaded whatever the disk still held: + // nothing, so the campaign went missing from every batch, or + // under re-attribution the PREVIOUS invite's values, so revenue + // was credited to a campaign the install no longer belonged to. + // Nothing ever looked again. + // + // Written only when they actually differ, so an ordinary launch + // does not pay for a storage write it has no use for. + if (dimensionsDisagree(durable)) { + writeDimensions(durable); + } return; } // getDimensions() returns a fresh copy and never null, so there @@ -2952,6 +2973,25 @@ private static void reconcileDimensions() { } } + // Whether the persisted dimensions say something other than the record. + // A null on the record means the dimension should be absent, which is what + // writeDimensions() does with it, so the comparison treats absent and null + // as the same answer. + private static boolean dimensionsDisagree(InviteAttribution a) { + Map set = Analytics.getDimensions(); + return differs(set.get(DIMENSION_CODE), a.getCode()) + || differs(set.get(DIMENSION_CAMPAIGN), a.getCampaign()) + || differs(set.get(DIMENSION_CHANNEL), a.getChannel()) + || differs(set.get(DIMENSION_MATCH), a.getMatchType()); + } + + private static boolean differs(String persisted, String durable) { + if (durable == null || durable.length() == 0) { + return persisted != null && persisted.length() > 0; + } + return !durable.equals(persisted); + } + private static void clearDimensions() { for (String dimension : DIMENSIONS) { Analytics.clearDimension(dimension); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index ab52f5e96d9..8aa450b1030 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -148,6 +148,39 @@ void aCodeTheServerHasNotSeenYetIsNotSettledAsOrganic() { assertNotNull(Invites.getAttribution(), "the late answer was refused"); } + @FormTest + void dimensionsAreRestoredFromTheDurableAttribution() { + // Preferences.set swallows its write failure, so a resolve can commit + // the attribution and fail to persist the four dimensions: right in + // memory for the rest of that process, and gone on the next launch. + // Reconciliation only looked one way -- it dropped dimensions with no + // record behind them -- so an attribution whose dimensions were missing + // or, under re-attribution, still the PREVIOUS invite's was accepted + // for ever, and every later batch credited a campaign the install no + // longer belonged to. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.handleResolution( + InviteTestSupport.resolvedJson("DIMS1", "spring", "sms"), + Invites.MATCH_REFERRER, true); + assertNotNull(Invites.getAttribution(), "the fixture did not resolve"); + + // The state a failed dimension write leaves behind: the durable record + // says one thing and the dimensions say another. + Analytics.setDimension("cn1_campaign", "the-previous-campaign"); + Analytics.clearDimension("cn1_invite_code"); + + // The next process. + Invites.forgetDimensionReconciliationForTest(); + Invites.forgetCachedAttributionForTest(); + Invites.checkForInvite(); + + assertEquals("spring", Analytics.getDimensions().get("cn1_campaign"), + "a stale campaign outlived the attribution that disagreed with it"); + assertEquals("DIMS1", Analytics.getDimensions().get("cn1_invite_code"), + "the code was never restored from the durable record"); + } + @FormTest void aNotYetAnswerIsAskedAgainInTheSameProcess() { // beginDeferred() runs at most once per process, so after a "not yet" From fde7e2781fa2279f3cf9a3748e7bcab8386726b4 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:08:45 +0300 Subject: [PATCH 62/99] Invites: an erasure reaches the registration already on the wire create() hands the registration json to NetworkManager and returns, so an erasure a moment later has two copies to deal with and found only one. Deleting the outbox does not touch a request already queued, and the epoch reset() bumps guards attribution RESPONSES -- a registration never reads it. So a mint from seconds earlier went on to transmit the old client id, the campaign and the payload after the erasure had reported success, which is exactly the identity the user asked to be rid of. Outstanding registrations are tracked and killed at the top of the erasure. NetworkManager skips a killed request when it reaches the front of the queue and kills the connection outright if it is already being sent, so the one case that matters -- queued and unsent -- needs nothing else. The collection is a Vector because it is genuinely touched from two threads: queued on the EDT, released from the network thread, the same boundary the in-flight map already straddles. getAttribution() is gated on the erasure settling, and that half is belt and braces rather than a leak being closed -- said in the code, because the line reads like more than it is. The review round that asked for it argued the record survives on disk and conversion() would emit the erased code under the new client id. Measured instead: ensureProvider() runs resumeOwedErasure() on every call and that retry clears the in-memory copy first, so the facade already answers null with the record demonstrably still on the disk. The test written for it passed against the unfixed code and was deleted rather than kept; the gate stays because it makes the rule true by construction instead of by the order two other methods happen to run in. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 78 ++++++++++++++++++- .../invite/InviteResilienceTest.java | 35 +++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 83f00ac3904..839214f4b50 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -702,6 +702,28 @@ public static boolean handleUrl(String url) { /// the attribution public static InviteAttribution getAttribution() { ensureProvider(); + // Gated like every other read of the durable records, and BELT AND + // BRACES rather than a leak being closed -- worth saying, because the + // obvious reading of this line overstates what it does. + // + // A review round argued that an erasure whose delete failed leaves the + // record on the disk, so this would reload it and conversion() would + // emit the erased code under the new client id. Measured rather than + // assumed: it does not, today. ensureProvider() above runs + // resumeOwedErasure() on every call, and the retry it makes clears the + // in-memory copy and marks it loaded before anything here reads the + // disk -- so the facade already answers null in that state, with the + // record demonstrably still on the disk. That is how the test written + // for it passed against the UNFIXED code, which is why there is no + // test beside this comment. + // + // The gate stays because it makes the rule true by construction rather + // than by the order two other methods happen to run in: a record whose + // deletion is still owed is not readable through this accessor. It + // costs one flag test on the uninvited path. + if (!settleErasure()) { + return null; + } loadAttribution(); return resolved; } @@ -1104,6 +1126,27 @@ private static boolean anythingSurvives() { /// true when nothing readable is left behind static boolean resetVerified() { lookupEpoch++; + // The queue first, because the disk is not the only place a + // pre-erasure registration lives. create() hands the json to + // NetworkManager and returns; deleting the outbox afterwards does not + // touch a request already queued, and the epoch bumped above guards + // only attribution RESPONSES -- a registration never reads it. So a + // mint from a moment ago went on to transmit the old client id, the + // campaign and the payload after the erasure had reported success. + // + // kill() is enough for the case that matters: NetworkManager skips a + // killed request when it reaches the front of the queue, and kills the + // connection outright if it is already being sent. + while (!outstandingRegistrations.isEmpty()) { + InviteConnection req = outstandingRegistrations.elementAt(0); + outstandingRegistrations.removeElementAt(0); + try { + req.kill(); + } catch (Throwable t) { + Log.e(t); + } + } + inFlight.clear(); boolean cleared = InviteStore.delete(InviteStore.PENDING); forgetPendingFallback(); // ATTRIBUTION names the inviter, and the OUTBOX is the queued @@ -2476,6 +2519,9 @@ private static void send(String url, String json, String outboxKey, String match req.setContentType("application/json"); req.setRequestBody(json); req.setFailSilently(true); + if (registration) { + outstandingRegistrations.addElement(req); + } NetworkManager.getInstance().addToQueue(req); } catch (Throwable t) { Log.e(t); @@ -2516,6 +2562,14 @@ protected void handleErrorResponseCode(int code, String message) { failed = true; } + // Package private for the same reason isFailed() is: ConnectionRequest + // keeps isKilled() protected, so only a subclass can answer it, and + // whether an erasure really stopped a queued registration is exactly + // the kind of thing that must be asserted rather than assumed. + boolean killedForTest() { + return isKilled(); + } + // Package private so a test can drive the outcome this class exists to // get right without standing up a server. boolean isFailed() { @@ -2539,8 +2593,11 @@ protected void handleException(Exception err) { } private void releaseInFlight() { - if (registration && outboxEntry != null) { - inFlight.remove(outboxEntry); + if (registration) { + outstandingRegistrations.removeElement(this); + if (outboxEntry != null) { + inFlight.remove(outboxEntry); + } } } @@ -3149,6 +3206,23 @@ private static void notifyUnavailable(String reason) { // retry them, and an empty set on the next launch is what makes it. private static final Map inFlight = new LinkedHashMap(); + // Registration requests handed to NetworkManager and not yet answered. + // + // An erasure has to reach these. reset() deletes the outbox and bumps the + // epoch, but a request already queued carries its OWN copy of the json -- + // the old client id, the campaign, the payload -- and the epoch guards only + // attribution responses, which a registration is not. So a queued mint + // transmitted a pre-erasure registration after the erasure reported + // success, which is precisely the identity the user asked to be rid of. + // + // A Vector because these are touched from two threads: added on the EDT + // when the request is queued, removed from the network thread when it + // fails. That is the same boundary the map above already straddles, and it + // is a real one -- not the single-threaded EDT the rest of this class runs + // on. + private static final java.util.Vector outstandingRegistrations = + new java.util.Vector(); + /// How long an entry stays skippable after its request goes out. /// /// The mark exists to stop one burst of invites reposting the whole queue, diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 8aa450b1030..6b0765ce89c 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -148,6 +148,41 @@ void aCodeTheServerHasNotSeenYetIsNotSettledAsOrganic() { assertNotNull(Invites.getAttribution(), "the late answer was refused"); } + @FormTest + void anErasureKillsARegistrationItCannotCatchOnTheDisk() { + // create() hands the registration json to NetworkManager and returns, + // so an erasure a moment later has two copies to deal with and used to + // find only one: deleting the outbox does not touch a request already + // queued, and the epoch reset() bumps guards attribution RESPONSES, + // which a registration is not. The queued mint went on to transmit the + // old client id, the campaign and the payload after the erasure had + // reported success. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).build()); + implementation.clearQueuedRequests(); + + assertNotNull(Invites.create(InviteRequest.create().campaign("spring").build()), + "minting is offline and must still work"); + java.util.List queued = + implementation.getQueuedRequests(); + assertTrue(queued.size() > 0, "the fixture queued no registration at all"); + + Invites.reset(); + + int checked = 0; + for (com.codename1.io.ConnectionRequest r : queued) { + if (r instanceof Invites.InviteConnection) { + assertTrue(((Invites.InviteConnection) r).killedForTest(), + "a registration queued before the erasure was still on its way " + + "out with the erased identity in it"); + checked++; + } + } + assertTrue(checked > 0, "no invite request was queued, so nothing was asserted"); + } + @FormTest void dimensionsAreRestoredFromTheDurableAttribution() { // Preferences.set swallows its write failure, so a resolve can commit From eb21b8b00c2f4dc8fbf7076f16aed5791a6fe5ad Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:29:41 +0300 Subject: [PATCH 63/99] Invites: one save for the dimensions, and a bounded set of queued registrations persistDimensions() writes both keys through the batched Preferences.set(Map), which saves once. Preferences.set(String, Object) saves per key, so the two were two serializations with a window between them -- and the comment here claimed they landed together because they share a record, which was simply wrong and is now true instead of assumed. Worth being precise about what that does and does not fix, because the obvious story is not the real one: save() writes the ENTIRE map, so a failed first save followed by a successful second still persisted both new values, and the "old dimensions under a new owner" state a review round described is not reachable that way. What the window really allowed was the reverse -- a save that landed followed by one that did not, leaving new dimensions under the previous stamp, which loadDimensions() reads as foreign and drops and reconcileDimensions() then restores from the durable record. One save removes the window rather than the consequence. The set of queued registrations is bounded, and that one is a defect this branch introduced two commits ago. Every invite request is fail-silent, and NetworkManager's fail-silent branch only logs a transport failure -- it calls neither handleIOException nor the request's handleException -- so a registration that never reaches the server has no completion to hang cleanup on, and the set would have held every request body a long offline session ever minted. Entries age out after five minutes, far longer than a request can plausibly sit in the queue and short enough to bound the memory, with a hard ceiling of 32 behind that. The same pass prunes the in-flight marks, which leaked the same way for any entry nothing looked at again. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/analytics/Analytics.java | 27 +++++++--- .../codename1/analytics/invite/Invites.java | 51 +++++++++++++++++++ .../invite/InviteResilienceTest.java | 24 +++++++++ 3 files changed, 96 insertions(+), 6 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/Analytics.java b/CodenameOne/src/com/codename1/analytics/Analytics.java index f2789c919dc..306746bf9be 100644 --- a/CodenameOne/src/com/codename1/analytics/Analytics.java +++ b/CodenameOne/src/com/codename1/analytics/Analytics.java @@ -689,16 +689,31 @@ private static void persistDimensions() { b.append(sanitize(e.getKey())).append('\t').append(sanitize(e.getValue())); first = false; } - Preferences.set(PREF_DIMENSIONS, b.toString()); - // Stamped with the identity these dimensions belong to. This is what - // makes a surviving file distinguishable from a current one after a - // restart, when nothing in memory remembers that an erasure was asked - // for. + // ONE save for both keys. Preferences.set(String, Object) calls save() + // per key, so the two used to be two serializations of the whole map + // with a window between them -- and a comment here claimed they landed + // together because they share a record, which was simply wrong. + // + // The batched form makes that true instead of assumed. It is worth + // being precise about what it does and does not fix, because the + // obvious story is not the real one: save() writes the ENTIRE map, so + // a failed first save followed by a successful second still persisted + // both new values -- the "old dimensions under a new owner" state is + // not reachable that way. What the window really allowed was the + // reverse, a save that landed followed by one that did not, leaving + // new dimensions under the PREVIOUS stamp. loadDimensions() reads that + // as foreign and drops them, which is conservative and correct, and + // reconcileDimensions() puts them back from the durable record. One + // save removes the window rather than the consequence. + // // clientId() rather than the field: the field is null until something // materialises the id, and stamping a placeholder would make the file // read as foreign on the next launch and drop the dimensions this call // was in the middle of saving. - Preferences.set(PREF_DIMENSIONS_OWNER, clientId()); + Map record = new LinkedHashMap(); + record.put(PREF_DIMENSIONS, b.toString()); + record.put(PREF_DIMENSIONS_OWNER, clientId()); + Preferences.set(record); } // Replaces the delimiter characters so the persisted form parses back diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 839214f4b50..8451bf611b2 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -2520,6 +2520,8 @@ private static void send(String url, String json, String outboxKey, String match req.setRequestBody(json); req.setFailSilently(true); if (registration) { + req.queuedAt = System.currentTimeMillis(); + pruneOutstanding(); outstandingRegistrations.addElement(req); } NetworkManager.getInstance().addToQueue(req); @@ -2540,6 +2542,9 @@ static final class InviteConnection extends ConnectionRequest { // The outbox entry this request carries, so a success can retire // exactly that one rather than the whole queue. private final String outboxEntry; + // When this was handed to NetworkManager, so a request nothing will + // ever call back about can still be let go of. See pruneOutstanding(). + private long queuedAt; private String payload; // Set by the error hook below. ConnectionRequest reads the body of an // error response by default and then runs the ordinary success path @@ -3223,6 +3228,52 @@ private static void notifyUnavailable(String reason) { private static final java.util.Vector outstandingRegistrations = new java.util.Vector(); + // How long a queued registration is remembered for the erasure's sake. + // + // Generous on purpose. The point of remembering one is to kill it if an + // erasure arrives, so pruning early is what would break -- but nothing + // else can free these: every invite request is fail-silent, and + // NetworkManager's fail-silent branch only logs, so a transport failure + // calls neither postResponse() nor handleException() and the entry has no + // completion to hang cleanup on. Five minutes is far longer than a request + // can plausibly sit in the queue and short enough that an offline process + // minting invites cannot accumulate request bodies without bound. + private static final long OUTSTANDING_MAX_AGE_MS = 5L * 60000L; + + // And a hard ceiling, for the same reason the outbox has one: a bound that + // does not depend on a clock being sane. + private static final int MAX_OUTSTANDING = 32; + + // Package private so a test can assert the bound rather than trust it. + static int outstandingRegistrationCountForTest() { + return outstandingRegistrations.size(); + } + + /// Forgets registrations old enough that nothing is coming back for them. + /// + /// The in-flight marks are pruned on the same pass. `issuedRecently()` + /// drops an entry it happens to look at, so a mark whose outbox entry has + /// since been retired was never looked at again and stayed for the life of + /// the process. + private static void pruneOutstanding() { + long now = System.currentTimeMillis(); + for (int i = outstandingRegistrations.size() - 1; i >= 0; i--) { + InviteConnection req = outstandingRegistrations.elementAt(i); + if (now - req.queuedAt >= OUTSTANDING_MAX_AGE_MS) { + outstandingRegistrations.removeElementAt(i); + } + } + while (outstandingRegistrations.size() >= MAX_OUTSTANDING) { + outstandingRegistrations.removeElementAt(0); + } + for (String json : new ArrayList(inFlight.keySet())) { + Long at = inFlight.get(json); + if (at == null || now - at.longValue() >= IN_FLIGHT_WINDOW_MS) { + inFlight.remove(json); + } + } + } + /// How long an entry stays skippable after its request goes out. /// /// The mark exists to stop one burst of invites reposting the whole queue, diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 6b0765ce89c..fe13814910d 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -148,6 +148,30 @@ void aCodeTheServerHasNotSeenYetIsNotSettledAsOrganic() { assertNotNull(Invites.getAttribution(), "the late answer was refused"); } + @FormTest + void queuedRegistrationsDoNotAccumulateWithoutBound() { + // Every invite request is fail-silent, and NetworkManager's fail-silent + // branch only LOGS a transport failure -- it calls neither + // handleIOException nor the request's handleException -- so a + // registration that never reaches the server has no completion to hang + // cleanup on. The set that remembers queued registrations for the + // erasure's sake would otherwise hold every request body a long + // offline session ever minted. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).build()); + + for (int i = 0; i < 80; i++) { + assertNotNull(Invites.create(InviteRequest.create().campaign("c" + i).build()), + "minting is offline and must still work"); + } + + assertTrue(Invites.outstandingRegistrationCountForTest() <= 32, + "queued registrations accumulated without bound: " + + Invites.outstandingRegistrationCountForTest()); + } + @FormTest void anErasureKillsARegistrationItCannotCatchOnTheDisk() { // create() hands the registration json to NetworkManager and returns, From 1802b966efa483767055a256b7ea8d2d64be6aaf Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:56:57 +0300 Subject: [PATCH 64/99] Invites: the App Clip was never built on the default path, and four more The clip target is created inside the needsXcodeProjectMutation block, and the gate did not include inviteAppClipTargetWanted. An invite-enabled app that uses no pods and no other extension -- the DEFAULT shape of an app that has just switched invites on -- therefore built and shipped with no clip at all, and every iOS install would have settled as no_match for ever. Nothing reports it: the app signs, the association file lists the clip, and the clip does not exist. The global deployment-target pass skips app-extension targets, and a clip is not one -- its product type is a full application bundle. fix_xcode_schemes.rb runs again after pods integration, so the second pass rewrote the clip to the host app's deployment target, commonly below 14, while the guard that stops the target being created twice also skipped restoring its floor. An App Clip below iOS 14 does not launch. On Android, a superseded referrer callback burnt the one-shot flag. The delivery methods drop it on purpose when a newer exchange has taken over, but the flag was set regardless -- so if that newer exchange then failed transiently, every later launch saw isSupported() false and the exact Play referrer was gone for an install that really had one. Only the exchange that answered burns it now. An erasure whose durable marker survives is no longer reported done. The marker outliving a successful erasure is read by the next ensureProvider() as an erasure still owed, and eraseInternal() then runs again -- against the invite the person accepted after the reset, and the registration they minted. Reporting it incomplete keeps the flag and the marker in agreement, so the gate stays shut and there is nothing new to destroy. And pruneOutstanding() kills what it drops. Bounding the set of queued registrations two commits ago removed the only handle reset() has for cancelling one, so a pruned request could still transmit its pre-erasure identity. Killing it costs nothing: the durable outbox is what gets a registration sent in the end. All five have tests verified against the unfixed code; the two builder ones assert the source text, as the other builder-assembly tests here do. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 51 +++++++++-- .../referrer/AndroidInstallReferrer.java | 38 +++++--- .../com/codename1/builders/IPhoneBuilder.java | 22 +++++ .../InviteAppClipProjectMutationTest.java | 87 +++++++++++++++++++ .../invite/InviteResilienceTest.java | 65 ++++++++++++++ 5 files changed, 248 insertions(+), 15 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteAppClipProjectMutationTest.java diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 8451bf611b2..5c0367b4fec 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1299,10 +1299,27 @@ static boolean eraseInternal() { } state = STATE_NONE_FOUND; stateLoaded = true; - erasurePending = false; // The durable marker goes with the flag, or every later launch would - // erase again and settle a fresh install as terminal. - InviteStore.delete(InviteStore.ERASURE); + // erase again and settle a fresh install as terminal -- and the result + // is CHECKED, because ignoring it made the two disagree in the one + // direction that destroys data. + // + // A marker that outlives a successful erasure is read by the next + // ensureProvider() as an erasure still owed, and eraseInternal() runs + // again -- against whatever the person has done since. An invite they + // accepted after the reset, a registration they minted, both gone, on + // every launch until the marker can be written. Reporting the erasure + // incomplete instead keeps the flag and the marker saying the same + // thing: the gate stays closed, so there is nothing new to destroy, + // and the retry costs an erasure that has nothing left to erase. + if (!InviteStore.delete(InviteStore.ERASURE)) { + Log.p("invite: the erasure is done but its marker could not be cleared, so it " + + "is reported incomplete and retried rather than repeated against " + + "whatever comes next", Log.WARNING); + erasurePending = true; + return false; + } + erasurePending = false; return true; } @@ -3244,6 +3261,30 @@ private static void notifyUnavailable(String reason) { // does not depend on a clock being sane. private static final int MAX_OUTSTANDING = 32; + /// Drops a remembered registration, and KILLS it on the way out. + /// + /// Forgetting one without killing it was a hole in the erasure this set + /// exists for: the reference is the only handle reset() has, so a request + /// pruned while still queued became invisible to the kill sweep and + /// NetworkManager could transmit its pre-erasure client id, campaign and + /// payload after reset() had reported success. + /// + /// Killing what is dropped costs nothing that matters. A request old + /// enough to be pruned has almost certainly gone already -- kill() on a + /// finished request does nothing -- and one that really is still queued is + /// wedged behind a stalled network, where its own durable outbox entry is + /// the thing that gets it sent in the end. The registration is not lost by + /// killing it; the next drain re-queues it. + private static void forget(int index) { + InviteConnection req = outstandingRegistrations.elementAt(index); + outstandingRegistrations.removeElementAt(index); + try { + req.kill(); + } catch (Throwable t) { + Log.e(t); + } + } + // Package private so a test can assert the bound rather than trust it. static int outstandingRegistrationCountForTest() { return outstandingRegistrations.size(); @@ -3260,11 +3301,11 @@ private static void pruneOutstanding() { for (int i = outstandingRegistrations.size() - 1; i >= 0; i--) { InviteConnection req = outstandingRegistrations.elementAt(i); if (now - req.queuedAt >= OUTSTANDING_MAX_AGE_MS) { - outstandingRegistrations.removeElementAt(i); + forget(i); } } while (outstandingRegistrations.size() >= MAX_OUTSTANDING) { - outstandingRegistrations.removeElementAt(0); + forget(0); } for (String json : new ArrayList(inFlight.keySet())) { Long at = inFlight.get(json); diff --git a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java index f315f1b789f..8ead7411f2b 100644 --- a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java +++ b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java @@ -239,8 +239,11 @@ private void deliver(int issued, InstallReferrerClient client, if (referrer == null || referrer.length() == 0) { // Read successfully and there is no invite behind this install. // Definitive, so the flag is burnt: asking again cannot change it. - Preferences.set(PREF_ATTEMPTED, true); - unavailable(issued, callback, Invites.REASON_NO_MATCH); + // + // Only when THIS exchange still owns the answer -- see burn(). + if (unavailable(issued, callback, Invites.REASON_NO_MATCH)) { + Preferences.set(PREF_ATTEMPTED, true); + } return; } // The handoff FIRST, the flag after. @@ -257,13 +260,26 @@ private void deliver(int issued, InstallReferrerClient client, // inside that window still loses it. The window goes from "always" to // "the callSerially latency", which is the most the SPI shape allows // without the port knowing what the framework did with the value. - referrer(issued, callback, referrer, clickSeconds, beginSeconds); - Preferences.set(PREF_ATTEMPTED, true); + if (referrer(issued, callback, referrer, clickSeconds, beginSeconds)) { + Preferences.set(PREF_ATTEMPTED, true); + } } + /// The one-shot flag is burnt by the exchange that ANSWERED, and only by + /// it. + /// + /// A bind that outlives the retry interval leaves its callback pending + /// while a later checkForInvite() starts a fresh exchange. When the first + /// one finally lands it is superseded -- `issued != attemptSeq` -- and both + /// delivery methods below drop it on purpose, because the newer exchange + /// owns the outcome. Burning the flag anyway performed the one side effect + /// that cannot be undone: if the newer exchange then failed transiently, + /// every later launch saw isSupported() as false and the exact Play + /// referrer was gone, for an install that really did have one. private void finish(int issued, InstallReferrerCallback callback, String reason) { - Preferences.set(PREF_ATTEMPTED, true); - unavailable(issued, callback, reason); + if (unavailable(issued, callback, reason)) { + Preferences.set(PREF_ATTEMPTED, true); + } } /// Reports "no referral", at most once. @@ -271,21 +287,23 @@ private void finish(int issued, InstallReferrerCallback callback, String reason) /// Every terminal path goes through here so the disconnect handler can /// close an exchange nobody else closed without risking a second answer /// for one that somebody did. - private void unavailable(int issued, InstallReferrerCallback callback, String reason) { + private boolean unavailable(int issued, InstallReferrerCallback callback, String reason) { if (answered || issued != attemptSeq) { - return; + return false; } answered = true; callback.onUnavailable(reason); + return true; } - private void referrer(int issued, InstallReferrerCallback callback, String value, + private boolean referrer(int issued, InstallReferrerCallback callback, String value, long clickSeconds, long beginSeconds) { if (answered || issued != attemptSeq) { - return; + return false; } answered = true; callback.onReferrer(value, clickSeconds, beginSeconds); + return true; } private void close(InstallReferrerClient client) { diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 94c4059ffd0..73e9e6ee59b 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -6932,11 +6932,21 @@ public void usesClassMethod(String cls, String method) { } // Wallet/widget extensions and .ios.appext archives mutate the Xcode project through // the ruby xcodeproj gem even when CocoaPods isn't otherwise needed. + // + // The App Clip belongs in this list, and leaving it out was worth a + // whole broken feature: the target is created inside this block, so + // an invite-enabled app that uses no pods and no other extension -- + // which is the DEFAULT shape of an app that just switched invites + // on -- built and shipped with no clip at all. Nothing reports it. + // The app signs, the association file lists it, and every iOS + // install settles as no_match for ever, because the clip that was + // supposed to hand the code over does not exist. boolean needsXcodeProjectMutation = runPods || walletExtensionEnabled || surfacesExtensionEnabled || matterExtensionEnabled || callDirectoryExtensionEnabled || vpnTunnelBuilder.isEnabled() || documentProviderEnabled + || inviteAppClipTargetWanted || hasAppExtensionArchives(appExtensionArchiveDir); if (needsXcodeProjectMutation) { try { @@ -6981,6 +6991,18 @@ public void usesClassMethod(String cls, String method) { + " # pass stomps them down to the app's deployment target (seen as WidgetKit\n" + " # sources compiling at iOS 14 instead of the extension's 16.1).\n" + " next if target.respond_to?(:product_type) && target.product_type == 'com.apple.product-type.app-extension'\n" + // And the App Clip, which is not an app-extension: its product + // type is a full application bundle, so the skip above never + // matched it. Appending the clip's own settings after this pass + // covers the FIRST run only -- the script re-runs after pods + // integration, and on the second pass the target already exists, + // so the guard that stops it being created twice also skips the + // block that would restore its floor. This pass then left the + // clip at the app's deployment target, commonly below 14, and + // an App Clip built below 14 does not launch. + + " next if target.respond_to?(:product_type) && target.product_type == '" + + InviteAppClipBuilder.PRODUCT_TYPE + "'\n" + + "" + " target.build_configurations.each do |config|\n" + " config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '" + getDeploymentTarget(request) + "'\n" + simulatorArchitectureSettings diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteAppClipProjectMutationTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteAppClipProjectMutationTest.java new file mode 100644 index 00000000000..bdcb75edeaf --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteAppClipProjectMutationTest.java @@ -0,0 +1,87 @@ +/* + * 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.builders; + +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The App Clip has to survive the two things the Xcode project script does to + * every target, and neither failure says anything at build time. + * + *

Asserted against the builder's source text, as {@code StubLifecycleCastTest} + * and {@code AndroidInviteNewIntentTest} do: both properties are of an inline + * assembly a few hundred lines long with no seam to call, and the cost of + * getting either wrong is a clip that is simply absent or does not launch -- + * with an app that builds, signs and ships.

+ */ +public class InviteAppClipProjectMutationTest { + + private static final String BUILDER = + "src/main/java/com/codename1/builders/IPhoneBuilder.java"; + + private String source() throws IOException { + File builder = new File(BUILDER); + assertTrue(builder.isFile(), "the builder must be readable: " + builder.getAbsolutePath()); + return new String(Files.readAllBytes(builder.toPath()), StandardCharsets.UTF_8); + } + + @Test + void wantingAclipIsEnoughToMutateTheProject() throws IOException { + // The clip target is created inside the needsXcodeProjectMutation + // block. Without the flag in that condition, an invite-enabled app + // that uses no pods and no other extension -- the DEFAULT shape of an + // app that just switched invites on -- built with no clip at all, and + // every iOS install settled as no_match for ever. + String source = source(); + int at = source.indexOf("boolean needsXcodeProjectMutation ="); + assertTrue(at > 0, "the mutation gate is gone"); + String condition = source.substring(at, source.indexOf(";", at)); + assertTrue(condition.contains("inviteAppClipTargetWanted"), + "an invite-only iOS build does not enter the block that creates its " + + "App Clip, so the clip is never generated or embedded"); + } + + @Test + void theGlobalDeploymentPassLeavesTheClipAlone() throws IOException { + // fix_xcode_schemes.rb runs twice -- again after pods integration -- + // and the global pass rewrites IPHONEOS_DEPLOYMENT_TARGET on every + // target it does not skip. The clip's settings are appended after that + // pass, which covers the first run only: on the second the target + // already exists, so the guard that stops it being created twice also + // skips restoring its floor. An App Clip below iOS 14 does not launch. + String source = source(); + int at = source.indexOf("deploymentTargetStr = \"begin"); + assertTrue(at > 0, "the global deployment-target pass moved"); + String pass = source.substring(at, source.indexOf("rescue => e", at)); + assertTrue(pass.contains("InviteAppClipBuilder.PRODUCT_TYPE"), + "the pass does not skip the App Clip, so a second run drops it to the " + + "app's deployment target"); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index fe13814910d..3fc38221992 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -148,6 +148,37 @@ void aCodeTheServerHasNotSeenYetIsNotSettledAsOrganic() { assertNotNull(Invites.getAttribution(), "the late answer was refused"); } + @FormTest + void aPrunedRegistrationIsKilledRatherThanJustForgotten() { + // The set of queued registrations is the only handle reset() has for + // killing one, so forgetting an entry to bound memory made it + // invisible to the erasure -- and NetworkManager would then transmit + // its pre-erasure client id, campaign and payload after reset() had + // reported success. Bounding the set must not create a request nothing + // can cancel. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).build()); + implementation.clearQueuedRequests(); + + for (int i = 0; i < 80; i++) { + assertNotNull(Invites.create(InviteRequest.create().campaign("c" + i).build()), + "minting is offline and must still work"); + } + + int pruned = 0; + for (com.codename1.io.ConnectionRequest r : implementation.getQueuedRequests()) { + if (r instanceof Invites.InviteConnection + && ((Invites.InviteConnection) r).killedForTest()) { + pruned++; + } + } + assertTrue(pruned > 0, + "entries were dropped from the set without being killed, so a queued " + + "registration outlived the only thing that could cancel it"); + } + @FormTest void queuedRegistrationsDoNotAccumulateWithoutBound() { // Every invite request is fail-silent, and NetworkManager's fail-silent @@ -172,6 +203,40 @@ void queuedRegistrationsDoNotAccumulateWithoutBound() { + Invites.outstandingRegistrationCountForTest()); } + @FormTest + void anErasureWhoseMarkerSurvivesIsNotReportedDone() { + // The records went and the durable marker did not. Reported as done, + // the marker is read by the next ensureProvider() as an erasure still + // owed -- and eraseInternal() runs again, against whatever the person + // has accepted or minted since, on every launch until the write + // succeeds. Reporting it incomplete keeps the flag and the marker + // saying the same thing, so the gate stays shut and there is nothing + // new to destroy. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.handleResolution( + InviteTestSupport.resolvedJson("MARKER1", "spring", "sms"), + Invites.MATCH_REFERRER, true); + assertNotNull(Invites.getAttribution(), "the fixture did not resolve"); + + // The marker exists only once an erasure has been OWED, so the first + // one has to fail: the attribution delete is refused, which is what + // writes it. + InviteStore.failNextDeleteForTest(InviteStore.ATTRIBUTION); + Invites.reset(); + assertNotNull(InviteStore.read(InviteStore.ERASURE), + "the fixture did not leave an erasure owed, so there is no marker"); + + // The store recovers for the records and still refuses the marker. + // eraseInternal() is the level that owns it: resetVerified() below it + // only deletes the records. + InviteStore.failNextDeleteForTest(InviteStore.ERASURE); + assertFalse(Invites.eraseInternal(), + "an erasure whose marker survived was reported complete"); + assertNotNull(InviteStore.read(InviteStore.ERASURE), + "the fixture cleared the marker, so there is nothing to report about"); + } + @FormTest void anErasureKillsARegistrationItCannotCatchOnTheDisk() { // create() hands the registration json to NetworkManager and returns, From 4c3c0ba1c440663931d18015debab162091c2ff0 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:23:06 +0300 Subject: [PATCH 65/99] Invites: consent withdrawal cancels what is already queued The epoch decides whether an ANSWER is acted on, and a registration is never answered -- so one queued behind other network work still went out with the client id, the campaign and the payload after consent was withdrawn, which is the transmission the withdrawal exists to prevent. It is killed now, by the same sweep the erasure uses, and the two share it. The durable outbox is left alone on purpose: those entries are what a later grant sends, and withdrawing consent is not a request to forget the invites this person minted. The Android one-shot referrer flag no longer survives into a different installation. Auto-backup is on by default, so a reinstall or a device migration restores this app's files, the flag among them -- and restored, it says the referrer has already been read, so the new installation never asks and its own Play referrer, the one exact answer this path exists for, is thrown away before anything looks at it. firstInstallTime separates the two: it survives an app update, so an ordinary upgrade is not mistaken for a new install. That covers the flag this class owns and NOT the invite records, which the same restore also brings back -- so a migrated device can still report the previous installation's inviter as its own. Fixing that needs a core entry point meaning "new installation: forget the last one but stay attributable", and the one public method that comes close is the erasure, whose terminal marker would leave the new install permanently unattributable -- worse than the problem. iOS has the same exposure through device transfer and no equivalent signal at all. Written down where the detection lives rather than guessed at. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 46 ++++++++++--- .../referrer/AndroidInstallReferrer.java | 65 ++++++++++++++++++- .../invite/InviteResilienceTest.java | 36 ++++++++++ 3 files changed, 135 insertions(+), 12 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 5c0367b4fec..7c238651d97 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1137,16 +1137,7 @@ static boolean resetVerified() { // kill() is enough for the case that matters: NetworkManager skips a // killed request when it reaches the front of the queue, and kills the // connection outright if it is already being sent. - while (!outstandingRegistrations.isEmpty()) { - InviteConnection req = outstandingRegistrations.elementAt(0); - outstandingRegistrations.removeElementAt(0); - try { - req.kill(); - } catch (Throwable t) { - Log.e(t); - } - } - inFlight.clear(); + killQueuedRegistrations(); boolean cleared = InviteStore.delete(InviteStore.PENDING); forgetPendingFallback(); // ATTRIBUTION names the inviter, and the OUTBOX is the queued @@ -1395,6 +1386,17 @@ static void onConsentChanged(boolean allowed) { // end of the window. The epoch bump additionally discards any response // already in flight. lookupEpoch++; + // The epoch stops an ANSWER being acted on; it does not stop a REQUEST + // going out, and a registration is not answered at all. One queued + // behind other network work would have transmitted the client id, the + // campaign and the payload after consent was withdrawn -- the + // transmission the withdrawal exists to prevent, sent by a request that + // was already past every gate when it was queued. + // + // The durable outbox is deliberately left alone: the entries are what a + // later grant sends, and withdrawing consent is not a request to forget + // the invites this person minted. + killQueuedRegistrations(); // Nothing is outstanding once the epoch has moved: any response still // on the wire fails the guard. Saying so here is what lets a later // grant resume immediately rather than waiting out a retry delay for a @@ -3261,6 +3263,30 @@ private static void notifyUnavailable(String reason) { // does not depend on a clock being sane. private static final int MAX_OUTSTANDING = 32; + /// Kills every registration handed to NetworkManager and not yet answered. + /// + /// Shared by the erasure and by a consent withdrawal, which need the same + /// thing for different reasons: one must not transmit an identity the user + /// asked to be rid of, the other must not transmit anything at all. Neither + /// is served by the epoch, which only decides whether an ANSWER is acted + /// on -- a registration is never answered, and a queued request has already + /// passed every gate it will ever pass. + /// + /// The durable outbox is untouched. What is queued is a copy; the outbox is + /// the record, and it is what a later grant sends. + private static void killQueuedRegistrations() { + while (!outstandingRegistrations.isEmpty()) { + InviteConnection req = outstandingRegistrations.elementAt(0); + outstandingRegistrations.removeElementAt(0); + try { + req.kill(); + } catch (Throwable t) { + Log.e(t); + } + } + inFlight.clear(); + } + /// Drops a remembered registration, and KILLS it on the way out. /// /// Forgetting one without killing it was a hole in the erasure this set diff --git a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java index 8ead7411f2b..d7e77d579f6 100644 --- a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java +++ b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java @@ -49,6 +49,10 @@ public class AndroidInstallReferrer implements InstallReferrerSource { // and the flag is what stops a service bind on every launch. private static final String PREF_ATTEMPTED = "cn1$invite$referrerAttempted"; + /// The installation the flag above belongs to. See + /// [#forgetAflagRestoredFromAnotherInstallation]. + private static final String PREF_INSTALL_TIME = "cn1$invite$referrerInstallTime"; + private boolean retried; // Whether the framework has been given its one answer FOR THIS EXCHANGE. @@ -75,8 +79,65 @@ public class AndroidInstallReferrer implements InstallReferrerSource { @Override public boolean isSupported() { - return AndroidNativeUtil.getContext() != null - && !Preferences.get(PREF_ATTEMPTED, false); + Context context = AndroidNativeUtil.getContext(); + if (context == null) { + return false; + } + forgetAflagRestoredFromAnotherInstallation(context); + return !Preferences.get(PREF_ATTEMPTED, false); + } + + /// Clears the one-shot flag when it came from a DIFFERENT installation. + /// + /// Android's auto-backup is on by default -- `AndroidGradleBuilder` leaves + /// `android:allowBackup` alone -- so a reinstall or a device migration + /// restores this app's files, this flag among them. Restored, it says the + /// referrer has already been read, and the new installation never asks: its + /// own Play referrer, which is the one exact answer this whole path + /// exists for, is thrown away before anything looks at it. + /// + /// `firstInstallTime` is what separates the two. It survives an app + /// UPDATE, so an ordinary upgrade is not mistaken for a new install, and a + /// restore into a new installation carries the OLD value in preferences + /// while the package manager reports the new one. Unknown means this code + /// is running for the first time on an install that predates it, which is + /// not evidence of anything and stamps rather than clears. + /// + /// #### What this does NOT cover + /// + /// Only the flag this class owns. A restore also brings back the invite + /// records themselves -- the resolved attribution above all -- so a device + /// migrated from another one can still report the previous installation's + /// inviter as its own. Fixing that needs a core entry point meaning "this + /// is a new installation, forget the last one but stay attributable", and + /// the one public method that comes close, `Invites.reset()`, is the + /// erasure: it writes a terminal marker, which would leave the new install + /// permanently unattributable -- worse than the problem. iOS has the same + /// exposure through device transfer and no equivalent signal here at all. + /// Left as a deliberate gap rather than guessed at. + private void forgetAflagRestoredFromAnotherInstallation(Context context) { + try { + long current = context.getPackageManager() + .getPackageInfo(context.getPackageName(), 0).firstInstallTime; + if (current <= 0L) { + return; + } + long known = Preferences.get(PREF_INSTALL_TIME, 0L); + if (known == 0L) { + Preferences.set(PREF_INSTALL_TIME, current); + return; + } + if (known != current) { + Preferences.set(PREF_INSTALL_TIME, current); + Preferences.set(PREF_ATTEMPTED, false); + } + } catch (Throwable t) { + // A package manager that cannot describe this app's own package is + // not a state to guess in: leaving the flag alone keeps the + // ordinary behaviour rather than re-reading a referrer that may + // genuinely have been consumed. + com.codename1.io.Log.e(t); + } } @Override diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 3fc38221992..f4c50c52cbb 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -237,6 +237,42 @@ void anErasureWhoseMarkerSurvivesIsNotReportedDone() { "the fixture cleared the marker, so there is nothing to report about"); } + @FormTest + void withdrawingConsentKillsARegistrationAlreadyOnItsWay() { + // The epoch decides whether an ANSWER is acted on, and a registration + // is never answered -- so a request queued behind other network work + // went out with the client id, the campaign and the payload after + // consent was withdrawn, which is the transmission the withdrawal + // exists to prevent. The durable outbox is left alone on purpose: those + // entries are what a later grant sends. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).build()); + implementation.clearQueuedRequests(); + + assertNotNull(Invites.create(InviteRequest.create().campaign("spring").build()), + "minting is offline and must still work"); + java.util.List queued = + implementation.getQueuedRequests(); + assertTrue(queued.size() > 0, "the fixture queued no registration at all"); + + Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); + + int checked = 0; + for (com.codename1.io.ConnectionRequest r : queued) { + if (r instanceof Invites.InviteConnection) { + assertTrue(((Invites.InviteConnection) r).killedForTest(), + "a registration queued before the withdrawal was still on its way " + + "out with the data consent was just refused for"); + checked++; + } + } + assertTrue(checked > 0, "no invite request was queued, so nothing was asserted"); + assertFalse(InviteStore.readOutbox().isEmpty(), + "the durable outbox was discarded, so a later grant has nothing to send"); + } + @FormTest void anErasureKillsARegistrationItCannotCatchOnTheDisk() { // create() hands the registration json to NetworkManager and returns, From 86759938c5da6150cbc35b9a5dba25805568cb1a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:42:51 +0300 Subject: [PATCH 66/99] Invites: a queued claim is an erasure's problem too, and codes are compared structurally The kill sweep tracked registrations only. A claim carries the client id AND the code it is claiming -- the same identity the erasure exists to remove -- so one queued behind other network work still transmitted it after reset() reported success. The epoch discards the response; nothing was stopping the request. Every invite request is tracked now, and both the erasure and a consent withdrawal kill all of them. isRegistered() searched for the code anywhere in a queued entry's text, and an entry carries the campaign, the payload, the title and whatever parameters the application set. A referral message quoting another invite's code therefore made a registration that HAD been acknowledged report as still queued, and an application that waits for isRegistered() before sharing waits for ever. The acknowledgement path had the mirror image and it is the worse of the two: it cleared an unrelated invite from the unacknowledged set, so one the server has never seen reported as registered. Both parse the entry and compare its top-level code, which is the same lesson as the associated-domain comparison earlier on this branch: a substring test on structured text answers a different question. Both verified against the unfixed code. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 120 ++++++++++++------ .../invite/InviteResilienceTest.java | 70 +++++++++- 2 files changed, 150 insertions(+), 40 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 7c238651d97..f28f3600c86 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1137,7 +1137,7 @@ static boolean resetVerified() { // kill() is enough for the case that matters: NetworkManager skips a // killed request when it reaches the front of the queue, and kills the // connection outright if it is already being sent. - killQueuedRegistrations(); + killQueuedRequests(); boolean cleared = InviteStore.delete(InviteStore.PENDING); forgetPendingFallback(); // ATTRIBUTION names the inviter, and the OUTBOX is the queued @@ -1396,7 +1396,7 @@ static void onConsentChanged(boolean allowed) { // The durable outbox is deliberately left alone: the entries are what a // later grant sends, and withdrawing consent is not a request to forget // the invites this person minted. - killQueuedRegistrations(); + killQueuedRequests(); // Nothing is outstanding once the epoch has moved: any response still // on the wire fails the guard. Saying so here is what lets a later // grant resume immediately rather than waiting out a retry delay for a @@ -2538,11 +2538,16 @@ private static void send(String url, String json, String outboxKey, String match req.setContentType("application/json"); req.setRequestBody(json); req.setFailSilently(true); - if (registration) { - req.queuedAt = System.currentTimeMillis(); - pruneOutstanding(); - outstandingRegistrations.addElement(req); - } + // EVERY invite request, not only the registrations. + // + // A claim carries the client id and the invite code, which is the + // same identity an erasure is asked to be rid of -- and tracking + // only registrations left a queued claim free to transmit it after + // reset() had reported success. The epoch discards the response; + // nothing was stopping the request. + req.queuedAt = System.currentTimeMillis(); + pruneOutstanding(); + outstanding.addElement(req); NetworkManager.getInstance().addToQueue(req); } catch (Throwable t) { Log.e(t); @@ -2617,11 +2622,9 @@ protected void handleException(Exception err) { } private void releaseInFlight() { - if (registration) { - outstandingRegistrations.removeElement(this); - if (outboxEntry != null) { - inFlight.remove(outboxEntry); - } + outstanding.removeElement(this); + if (registration && outboxEntry != null) { + inFlight.remove(outboxEntry); } } @@ -3230,24 +3233,27 @@ private static void notifyUnavailable(String reason) { // retry them, and an empty set on the next launch is what makes it. private static final Map inFlight = new LinkedHashMap(); - // Registration requests handed to NetworkManager and not yet answered. + // Invite requests handed to NetworkManager and not yet answered. Claims as + // well as registrations -- every one of them carries the client id. // // An erasure has to reach these. reset() deletes the outbox and bumps the // epoch, but a request already queued carries its OWN copy of the json -- - // the old client id, the campaign, the payload -- and the epoch guards only - // attribution responses, which a registration is not. So a queued mint - // transmitted a pre-erasure registration after the erasure reported - // success, which is precisely the identity the user asked to be rid of. + // the old client id, the code, the campaign, the payload -- and the epoch + // decides only whether an ANSWER is acted on. So a queued mint transmitted + // a pre-erasure registration after the erasure reported success, and when + // only registrations were tracked a queued CLAIM did the same with the + // client id and the code it was claiming. Both are precisely the identity + // the user asked to be rid of. // // A Vector because these are touched from two threads: added on the EDT // when the request is queued, removed from the network thread when it // fails. That is the same boundary the map above already straddles, and it // is a real one -- not the single-threaded EDT the rest of this class runs // on. - private static final java.util.Vector outstandingRegistrations = + private static final java.util.Vector outstanding = new java.util.Vector(); - // How long a queued registration is remembered for the erasure's sake. + // How long a queued request is remembered for the erasure's sake. // // Generous on purpose. The point of remembering one is to kill it if an // erasure arrives, so pruning early is what would break -- but nothing @@ -3263,7 +3269,7 @@ private static void notifyUnavailable(String reason) { // does not depend on a clock being sane. private static final int MAX_OUTSTANDING = 32; - /// Kills every registration handed to NetworkManager and not yet answered. + /// Kills every invite request handed to NetworkManager and not yet answered. /// /// Shared by the erasure and by a consent withdrawal, which need the same /// thing for different reasons: one must not transmit an identity the user @@ -3274,10 +3280,10 @@ private static void notifyUnavailable(String reason) { /// /// The durable outbox is untouched. What is queued is a copy; the outbox is /// the record, and it is what a later grant sends. - private static void killQueuedRegistrations() { - while (!outstandingRegistrations.isEmpty()) { - InviteConnection req = outstandingRegistrations.elementAt(0); - outstandingRegistrations.removeElementAt(0); + private static void killQueuedRequests() { + while (!outstanding.isEmpty()) { + InviteConnection req = outstanding.elementAt(0); + outstanding.removeElementAt(0); try { req.kill(); } catch (Throwable t) { @@ -3287,7 +3293,7 @@ private static void killQueuedRegistrations() { inFlight.clear(); } - /// Drops a remembered registration, and KILLS it on the way out. + /// Drops a remembered request, and KILLS it on the way out. /// /// Forgetting one without killing it was a hole in the erasure this set /// exists for: the reference is the only handle reset() has, so a request @@ -3302,8 +3308,8 @@ private static void killQueuedRegistrations() { /// the thing that gets it sent in the end. The registration is not lost by /// killing it; the next drain re-queues it. private static void forget(int index) { - InviteConnection req = outstandingRegistrations.elementAt(index); - outstandingRegistrations.removeElementAt(index); + InviteConnection req = outstanding.elementAt(index); + outstanding.removeElementAt(index); try { req.kill(); } catch (Throwable t) { @@ -3312,8 +3318,8 @@ private static void forget(int index) { } // Package private so a test can assert the bound rather than trust it. - static int outstandingRegistrationCountForTest() { - return outstandingRegistrations.size(); + static int outstandingRequestCountForTest() { + return outstanding.size(); } /// Forgets registrations old enough that nothing is coming back for them. @@ -3324,13 +3330,13 @@ static int outstandingRegistrationCountForTest() { /// the process. private static void pruneOutstanding() { long now = System.currentTimeMillis(); - for (int i = outstandingRegistrations.size() - 1; i >= 0; i--) { - InviteConnection req = outstandingRegistrations.elementAt(i); + for (int i = outstanding.size() - 1; i >= 0; i--) { + InviteConnection req = outstanding.elementAt(i); if (now - req.queuedAt >= OUTSTANDING_MAX_AGE_MS) { forget(i); } } - while (outstandingRegistrations.size() >= MAX_OUTSTANDING) { + while (outstanding.size() >= MAX_OUTSTANDING) { forget(0); } for (String json : new ArrayList(inFlight.keySet())) { @@ -3553,12 +3559,22 @@ static void markSentDirectlyForTest(String code) { unacknowledged.add(code); } + // Package private test seam: the acknowledgement normally arrives with a + // server response, and what has to be asserted is which entry it clears. + static void registrationAcknowledgedForTest(String json) { + registrationAcknowledged(json); + } + private static void registrationAcknowledged(String json) { - for (int i = unacknowledged.size() - 1; i >= 0; i--) { - String code = unacknowledged.get(i); - if (json != null && json.indexOf(code) >= 0) { - unacknowledged.remove(i); - } + // The acknowledged entry's own code, for the reason isRegistered() + // parses rather than searches: a substring test cleared an UNRELATED + // invite from the unacknowledged set whenever this registration's + // payload or title mentioned its code, and that one is worse than the + // false negative -- an invite the server has never seen then reports + // as registered. + String acknowledged = codeOf(json); + if (acknowledged != null) { + unacknowledged.remove(acknowledged); } List outbox = InviteStore.readOutbox(); if (outbox.remove(json)) { @@ -3592,14 +3608,42 @@ public static boolean isRegistered(Invite invite) { if (unacknowledged.contains(code)) { return false; } + // The queued entry's own code, parsed, not looked for anywhere in its + // text. A registration carries the campaign, the payload, the title and + // whatever parameters the application set, so another invite whose + // payload happens to contain this code -- a referral message quoting + // it, most obviously -- made a registration that WAS acknowledged + // report as still queued, and an application that waits for + // isRegistered() before sharing waits for ever. for (String pending : InviteStore.readOutbox()) { - if (pending != null && pending.indexOf(code) >= 0) { + if (code.equals(codeOf(pending))) { return false; } } return true; } + /// The top-level `code` of a queued registration, or null when the entry + /// cannot be parsed. + /// + /// Parsing rather than searching is the whole point: every other field in + /// the entry is application text, and an invite's code appearing inside one + /// of them says nothing about which registration this is. + private static String codeOf(String json) { + if (json == null || json.length() == 0) { + return null; + } + try { + Map parsed = JSONParser.parseJSON(json); + return parsed == null ? null : str(parsed.get("code")); + } catch (Throwable t) { + // An unparseable entry matches nothing, which leaves the invite + // reported as unregistered -- the conservative answer, and the one + // a retry can still correct. + return null; + } + } + private static boolean truthy(Object o) { if (o instanceof Boolean) { return ((Boolean) o).booleanValue(); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index f4c50c52cbb..875fb4ff807 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -198,9 +198,9 @@ void queuedRegistrationsDoNotAccumulateWithoutBound() { "minting is offline and must still work"); } - assertTrue(Invites.outstandingRegistrationCountForTest() <= 32, + assertTrue(Invites.outstandingRequestCountForTest() <= 32, "queued registrations accumulated without bound: " - + Invites.outstandingRegistrationCountForTest()); + + Invites.outstandingRequestCountForTest()); } @FormTest @@ -273,6 +273,72 @@ void withdrawingConsentKillsARegistrationAlreadyOnItsWay() { "the durable outbox was discarded, so a later grant has nothing to send"); } + @FormTest + void anErasureKillsAqueuedClaimToo() { + // The kill sweep tracked registrations only, and a claim carries the + // same client id plus the code it is claiming -- so one queued behind + // other network work still transmitted the erased identity after + // reset() reported success. The epoch discards the response; nothing + // was stopping the request. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + implementation.clearQueuedRequests(); + + Invites.handleUrl("https://cloud.codenameone.com/i/acme/CLAIMKILL"); + java.util.List queued = + implementation.getQueuedRequests(); + assertTrue(queued.size() > 0, "the fixture queued no claim at all"); + + Invites.reset(); + + int checked = 0; + for (com.codename1.io.ConnectionRequest r : queued) { + if (r instanceof Invites.InviteConnection) { + assertTrue(((Invites.InviteConnection) r).killedForTest(), + "a claim queued before the erasure was still on its way out with " + + "the client id and the code it was claiming"); + checked++; + } + } + assertTrue(checked > 0, "no invite request was queued, so nothing was asserted"); + } + + @FormTest + void anInviteIsNotReportedRegisteredBecauseAnotherEntryMentionsItsCode() { + // The outbox scan matched the code anywhere in a queued entry's text, + // and an entry carries the campaign, the payload, the title and + // whatever parameters the app set. A referral message quoting another + // invite's code therefore made a registration that HAD been + // acknowledged report as still queued -- and an application that waits + // for isRegistered() before sharing waits for ever. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).build()); + + Invite first = Invites.create(InviteRequest.create().campaign("spring").build()); + assertNotNull(first, "minting is offline and must still work"); + // A second invite whose payload quotes the first one's code, which is + // exactly what a referral message does. + Invite second = Invites.create(InviteRequest.create() + .campaign("spring") + .payload("join me with " + first.getCode()) + .build()); + assertNotNull(second, "the fixture could not mint the second invite"); + + // The first one is acknowledged; the second stays queued. + for (String entry : InviteStore.readOutbox()) { + if (entry != null && entry.indexOf("\"" + first.getCode() + "\"") >= 0 + && entry.indexOf("join me with") < 0) { + Invites.registrationAcknowledgedForTest(entry); + } + } + + assertTrue(Invites.isRegistered(first), + "an acknowledged invite read as unregistered because another queued " + + "entry quoted its code"); + } + @FormTest void anErasureKillsARegistrationItCannotCatchOnTheDisk() { // create() hands the registration json to NetworkManager and returns, From 3c2be9e6c8d99e639902f0554185931099341064 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:53:06 +0300 Subject: [PATCH 67/99] Invites: why the App Clip overlay takes no app identifier A review round read the absence of an app identifier in the SKOverlay configuration as the defect -- the store id only guarding the call, never naming the app to install -- and asked for SKOverlayAppConfiguration with the store id instead. It is the wrong way round, and the SDK headers say so plainly: SKOverlayAppClipConfiguration -- "an overlay configuration that can be used to show an app clip's full app", with initWithPosition: and no identifier. SKOverlayAppConfiguration -- "...to show any app from the App Store", with initWithAppIdentifier:position:. An App Clip offering its own full app is the first case. The parent app is known from the bundle relationship, which is also what the data handoff is keyed to, so there is nothing to pass -- and the identifier form would offer an arbitrary store listing rather than this clip's parent. No behaviour change: the reasoning goes in the generated source, where the next person to read that call will be standing. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/util/InviteAppClipBuilder.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/InviteAppClipBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/InviteAppClipBuilder.java index 39bb5615d32..dd6428da27a 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/InviteAppClipBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/util/InviteAppClipBuilder.java @@ -313,6 +313,23 @@ private static String delegateSource(String appGroup, String displayName, .append("// app. Without a store id -- which a build before first release\n") .append("// does not have -- the clip still records the code and simply\n") .append("// shows no sheet; the handoff works the moment the app exists.\n") + .append("//\n") + .append("// AppClipConfiguration is the RIGHT configuration here, and it\n") + .append("// takes no app identifier on purpose. A review round read that\n") + .append("// as the bug -- kStoreItemId only guarding, never identifying\n") + .append("// the app -- and asked for SKOverlayAppConfiguration with the\n") + .append("// store id instead. The SDK headers settle it:\n") + .append("// SKOverlayAppClipConfiguration: \"an overlay configuration\n") + .append("// that can be used to show an app clip's full app\",\n") + .append("// initWithPosition: only.\n") + .append("// SKOverlayAppConfiguration: \"...to show any app from the\n") + .append("// App Store\", initWithAppIdentifier:position:.\n") + .append("// The clip's parent app is known from the bundle relationship,\n") + .append("// so there is nothing to pass. Switching to the app-identifier\n") + .append("// form would offer an arbitrary store listing rather than THIS\n") + .append("// clip's parent, which is also what the data handoff is keyed\n") + .append("// to. The store id stays what it is: the build's own answer to\n") + .append("// whether there is a released app to offer yet.\n") .append("- (void)offerFullApp {\n") .append(" if (kStoreItemId.length == 0) { return; }\n") .append(" if (@available(iOS 14.0, *)) {\n") From b57d51e7e62bf9f30431a4f0954135894af2c737 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:01:19 +0300 Subject: [PATCH 68/99] Invites: the restored-install detection clears its flag in one save Preferences.set(String, Object) saves per key, and the two writes behind the restore detection were in the order that loses: the new install time landed, and a process that exited before the flag was cleared left storage saying this installation is the one that already read its referrer. The next launch compares equal, detection never fires again, and the Play referrer for that installation is gone for good -- the exact loss the detection was added to prevent, reachable whether or not any invite record survived the restore. Batched into one save, the same way persistDimensions() was: one write, no in-between to die in. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/referrer/AndroidInstallReferrer.java | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java index d7e77d579f6..eadfbaefeff 100644 --- a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java +++ b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java @@ -32,6 +32,8 @@ import com.codename1.impl.android.AndroidNativeUtil; import com.codename1.io.Log; import com.codename1.io.Preferences; +import java.util.HashMap; +import java.util.Map; /// Reads the Play Store install referrer, which is the deterministic half of /// invite attribution on Android: the invite code makes the whole round trip @@ -128,8 +130,17 @@ private void forgetAflagRestoredFromAnotherInstallation(Context context) { return; } if (known != current) { - Preferences.set(PREF_INSTALL_TIME, current); - Preferences.set(PREF_ATTEMPTED, false); + // ONE save for both. Preferences.set(String, Object) saves per + // key, and the order that reads most naturally is the one that + // loses: the new install time lands, the process exits before + // the flag is cleared, and the next launch compares equal -- + // detection never fires again and this installation's Play + // referrer is gone for good. Batched, the pair is one write and + // there is no in-between to die in. + Map restored = new HashMap(); + restored.put(PREF_INSTALL_TIME, Long.valueOf(current)); + restored.put(PREF_ATTEMPTED, Boolean.FALSE); + Preferences.set(restored); } } catch (Throwable t) { // A package manager that cannot describe this app's own package is From 385750cdb3c32f7b200c2c807916822124a52813 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:19:33 +0300 Subject: [PATCH 69/99] Invites: an unanswered prompt stops the queue without settling anything Switching the consent MODE from OPT_OUT to OPT_IN with no choice on record withdraws the mode's implicit allow, so allowed() answers no from that moment -- but requests queued a moment earlier had already passed that gate and went on transmitting the client id and the invite metadata. The provider's no-choice branch did nothing for the new mode, and onConsentChanged(false), which is what otherwise kills them, must not be called here: it is the refusal path, and nothing has been refused. Reporting a refusal for an unanswered prompt would settle the lookup and clear the dimensions of a user who has answered nothing. So there is a narrow entry point that kills the queue and touches nothing else. The durable outbox stays, and a later grant sends it. The test asserts all three: the queued request is dead, the outbox survives, and the state is not DECLINED. The erasure marker's write result is no longer ignored. A failed write left the intent in memory alone, and since a plain reset() keeps the client id, a process exiting there meant the next launch saw no identity change and the surviving records came back. Nothing here can make a refusing store accept a write, and there is no second place to put it -- so it is logged as an ERROR naming that consequence, the flag stays set, every gated call retries the path, and resetVerified() still answers false. The comment says that is a limit rather than implying the case is handled. Writing the first test also caught a fixture of mine that proved nothing: freshInstall() grants consent, so the provider read the recorded choice and never reached the transition until the test cleared it. Co-Authored-By: Claude Opus 5 (1M context) --- .../invite/InviteAttributionProvider.java | 11 +++++ .../codename1/analytics/invite/Invites.java | 37 +++++++++++++++- .../invite/InviteResilienceTest.java | 42 +++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java b/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java index d41ba320415..acdda2ffc01 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java @@ -151,7 +151,18 @@ public void onConsentChanged(AnalyticsConsent consent) { // stayed stopped and an attribution's dimensions stayed cleared. if (Analytics.getConsentMode() == ConsentMode.OPT_OUT) { Invites.onConsentChanged(true); + return; } + // OPT_IN with nothing on record, which is a transition in the other + // direction: the mode's implicit allow has just been withdrawn, so + // allowed() answers no from here on. Requests queued a moment ago have + // already passed that gate and would transmit the client id and the + // invite metadata after transmission stopped being permitted. + // + // Killing them is all that happens. onConsentChanged(false) is the + // refusal path -- it settles the lookup and clears the dimensions -- + // and nothing has been refused here: the prompt has not been answered. + Invites.suspendTransmission(); } @Override diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index f28f3600c86..76c72ef81fe 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1086,7 +1086,25 @@ public static void reset() { // back and were transmitted. Map owed = new LinkedHashMap(); owed.put("at", String.valueOf(System.currentTimeMillis())); - InviteStore.write(InviteStore.ERASURE, owed); + if (!InviteStore.write(InviteStore.ERASURE, owed)) { + // Said out loud, because this is the one state nothing + // here can recover from. The intent is still live in + // memory, every gated call retries this whole path while + // the flag is set, and resetVerified() answers false so + // the caller does not report the erasure as done -- but if + // the process exits before any write succeeds there is + // nothing on the disk to resume from, and a plain reset + // keeps the client id, so the next launch sees no identity + // change and the surviving records come back. + // + // There is no second place to write it that a store + // refusing this write would accept, so the honest handling + // is a loud log and a retry on the next call rather than + // an invented redundancy. + Log.p("invite: the erasure is owed and its marker could not be written, " + + "so it survives only in memory -- the records will come back " + + "if this process exits before a retry succeeds", Log.ERROR); + } } } } @@ -1340,6 +1358,23 @@ private static boolean settleErasure() { return !erasurePending || eraseInternal(); } + /// Stops what is already on its way out, without settling anything. + /// + /// The case is a consent MODE change: OPT_OUT to OPT_IN with no choice on + /// record flips `allowed()` from an implicit yes to an unanswered no. A + /// request queued a moment earlier has already passed that gate, so it + /// would transmit the client id and the invite metadata after transmission + /// stopped being permitted -- and `onConsentChanged(false)`, which is what + /// otherwise kills them, must not be called here: nothing has been + /// refused, and reporting a refusal would settle a lookup and clear + /// dimensions for a user who has answered no prompt at all. + /// + /// So this kills the queue and touches nothing else. The durable outbox + /// stays, and a later grant sends it. + static void suspendTransmission() { + killQueuedRequests(); + } + // Package private: called from the provider when consent changes. static void onConsentChanged(boolean allowed) { if (allowed) { diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 875fb4ff807..e28ecce85d2 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -237,6 +237,48 @@ void anErasureWhoseMarkerSurvivesIsNotReportedDone() { "the fixture cleared the marker, so there is nothing to report about"); } + @FormTest + void switchingToOptInKillsWhatTheImplicitAllowHadQueued() { + // OPT_OUT to OPT_IN with nothing on record withdraws the mode's + // implicit allow: allowed() answers no from that moment. A request + // queued a moment earlier has already passed that gate, so it would + // transmit the client id and the invite metadata after transmission + // stopped being permitted. Nothing has been REFUSED, though -- the + // prompt has not been answered -- so the lookup must not settle and + // the dimensions must not clear. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + // No choice on record is the whole point: with one, the provider reads + // it and never reaches the mode transition. freshInstall() grants, so + // it has to be cleared first. + Analytics.setConsent(null); + Analytics.setConsentMode(ConsentMode.OPT_OUT); + implementation.clearQueuedRequests(); + + assertNotNull(Invites.create(InviteRequest.create().campaign("spring").build()), + "minting is offline and must still work"); + java.util.List queued = + implementation.getQueuedRequests(); + assertTrue(queued.size() > 0, "the implicit allow queued nothing, so nothing is tested"); + + Analytics.setConsentMode(ConsentMode.OPT_IN); + + int checked = 0; + for (com.codename1.io.ConnectionRequest r : queued) { + if (r instanceof Invites.InviteConnection) { + assertTrue(((Invites.InviteConnection) r).killedForTest(), + "a request queued under the implicit allow was still on its way " + + "out after the mode withdrew it"); + checked++; + } + } + assertTrue(checked > 0, "no invite request was queued, so nothing was asserted"); + assertFalse(InviteStore.readOutbox().isEmpty(), + "the durable outbox was discarded for a prompt nobody has answered"); + assertTrue(Invites.getState() != Invites.STATE_DECLINED, + "an unanswered prompt was recorded as a refusal"); + } + @FormTest void withdrawingConsentKillsARegistrationAlreadyOnItsWay() { // The epoch decides whether an ANSWER is acted on, and a registration From 820a65567d1d15c6ed54cf61295e1fba683face1 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Fri, 11 Sep 2026 23:31:11 +0300 Subject: [PATCH 70/99] Invites: the App Links filter and the minted url agree about the slug The generated startup code stores invite.slug trimmed and the manifest filter used the raw hint, so a value written with a stray space made the app mint /i// while the pathPrefix kept the space. A link that does not match the filter opens the browser instead of the app -- and nothing reports it: the build succeeds, the filter is in the manifest, and every invite quietly misses it. Normalized in injectAppLinks(), which both outputs go through, rather than by a second trim at a call site that would have to remember. Verified against the unfixed code. Co-Authored-By: Claude Opus 5 (1M context) --- .../builders/InviteManifestFragments.java | 16 ++++++++++++++-- .../builders/InviteManifestFragmentsTest.java | 14 ++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteManifestFragments.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteManifestFragments.java index 9606aca748f..5f508d89b49 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteManifestFragments.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteManifestFragments.java @@ -71,10 +71,22 @@ static String injectAppLinks(String existing, String host, String slug) { if (host == null || host.length() == 0) { return current; } - if (declaresInviteLinks(current, host, slug)) { + // Trimmed HERE, because the other reader of this hint trims too. + // + // The generated startup code stores invite.slug trimmed, so a hint + // written with a stray space around it made the app mint + // /i// while the pathPrefix in the manifest kept the + // space -- and a link that does not match the filter opens the browser + // instead of the app. Nothing reports it: the build succeeds, the + // filter is there, and every invite silently misses it. + // + // One normalization for both outputs, in the place both go through, + // rather than a second trim at a call site that has to remember. + String path = slug == null ? "" : slug.trim(); + if (declaresInviteLinks(current, host, path)) { return current; } - return current + filter(host, slug); + return current + filter(host, path); } /** diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteManifestFragmentsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteManifestFragmentsTest.java index 4983e01132c..591adbc0525 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteManifestFragmentsTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteManifestFragmentsTest.java @@ -222,4 +222,18 @@ void aFilterNamingNoSchemeAtAllCoversNothing() { + "android:pathPrefix=\"/i/\" />"; assertFalse(InviteManifestFragments.declaresInviteLinks(existing, HOST, "acme")); } + @Test + void aSlugWithStraySpaceStillMatchesTheLinksTheAppMints() { + // The generated startup code stores invite.slug trimmed, so a hint + // written with a space around it made the app mint /i// + // while the manifest's pathPrefix kept the space. A link that does not + // match the filter opens the browser instead of the app, and nothing + // reports it: the build succeeds and the filter is right there. + String out = InviteManifestFragments.injectAppLinks("", HOST, " acme "); + assertTrue(out.contains("android:pathPrefix=\"/i/acme/\""), + "the filter kept the whitespace the app trims: " + out); + assertFalse(out.contains("/i/ acme"), + "the untrimmed slug reached the manifest: " + out); + } + } From 046511dba8e86c1105b7da7d087dc5dfe6c073b9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:02:31 +0300 Subject: [PATCH 71/99] Invites: the clip keeps its code until the framework has one that survives The native read emptied the shared container as it read it, and that container is the ONLY durable copy of an exact App Clip code until the framework writes its own record -- so a write that failed, or a process that exited in between, destroyed it, and the next launch found no handoff and settled an invited install as no_match for ever. Nothing reports that: the clip ran, the store carried the person across, and the install simply looks organic. The read leaves the container alone now, and AppClipHandoffSource gained handoffPersisted(), which Invites calls only when writePending() answers true. The iOS source empties the container there, through a new native whose symbol the signature verifier confirms resolves -- a mis-encoded name would have compiled, linked, and silently stripped the method. A malformed record is still cleared on read: it can never become valid, and leaving it means reading the same garbage every launch. A "not yet" answer now stamps the attempt instead of clearing it. Every response clears lookupIssuedAt, which is right for an answer that settles something and wrong for this one: the state stays pending, so the retry re-issues on the next checkForInvite(), and with nothing to throttle it an application that calls that from two places would spend all five attempts in seconds -- settling an offline-minted invite as no_match before its registration ever arrived. And the two invite hints are normalized in one place. Six sites across the two builders read them raw, which is how the slug reached the manifest with whitespace the generated startup code had trimmed. InviteBuildHints is where that cannot happen again: one accessor per hint, and a blank domain falls back to the default rather than producing a filter with no host. All three verified against the unfixed code. Co-Authored-By: Claude Opus 5 (1M context) --- .../invite/AppClipHandoffSource.java | 18 +++++ .../codename1/analytics/invite/Invites.java | 33 +++++++- .../iOSPort/nativeSources/CN1InviteAppClip.m | 32 ++++++-- .../codename1/impl/ios/IOSAppClipHandoff.java | 31 +++++++- .../src/com/codename1/impl/ios/IOSNative.java | 24 +++++- .../builders/AndroidGradleBuilder.java | 8 +- .../com/codename1/builders/IPhoneBuilder.java | 10 +-- .../codename1/builders/InviteBuildHints.java | 77 +++++++++++++++++++ .../builders/InviteBuildHintsTest.java | 72 +++++++++++++++++ .../invite/InviteResilienceTest.java | 72 +++++++++++++++++ .../analytics/invite/InviteTestSupport.java | 12 +++ 11 files changed, 367 insertions(+), 22 deletions(-) create mode 100644 maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteBuildHints.java create mode 100644 maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteBuildHintsTest.java diff --git a/CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffSource.java b/CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffSource.java index 8db4e0f1e5a..8241bbb824f 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffSource.java +++ b/CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffSource.java @@ -62,4 +62,22 @@ public interface AppClipHandoffSource { /// /// - `callback`: receives the answer, never null void requestHandoff(AppClipHandoffCallback callback); + + /// Told that the code from the last handoff is now stored somewhere that + /// survives the process, so a source holding the only other copy may + /// discard it. + /// + /// The iOS source hands over a value it reads out of the container it + /// shares with the App Clip, and that container is the ONLY durable copy + /// until the framework writes its own. Emptying it as it read meant a + /// failed write, or a process that exited in between, destroyed the exact + /// code -- and the next launch, finding no handoff, settled an invited + /// install as no_match for ever. So the read leaves the container alone + /// and this is what empties it. + /// + /// Called at most once per handoff, and never when the write failed: the + /// code stays where it is and the next launch reads it again, which is the + /// outcome a retry can still fix. A source with nothing to discard -- + /// anything that did not hand over its only copy -- does nothing here. + void handoffPersisted(); } diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 76c72ef81fe..66ddd559aea 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -2433,7 +2433,23 @@ public void run() { record.put("codeClicked", String.valueOf(clickedSeconds * 1000L)); } - writePending(record); + if (writePending(record)) { + // And only now may the source let go of its own + // copy. The container the clip wrote is the ONLY + // durable copy until this write lands, so a source + // that emptied it as it read destroyed the exact + // code whenever this failed or the process exited + // first -- and the next launch, finding no + // handoff, settled an invited install as no_match + // for ever. A write that failed leaves the + // container alone, so the next launch reads it + // again. + try { + source.handoffPersisted(); + } catch (Throwable t) { + Log.e(t); + } + } // Claimed exactly as a referrer code is: the trip // through the store is what makes both of them exact, // and the server treats them the same way. @@ -2771,6 +2787,21 @@ static void handleResolution(String payload, String matchType, boolean deferred, // attributed, and this is the opposite of terminal. The // existing attempt cap and attribution window bound how // long this can go on. + // + // The attempt is STAMPED rather than cleared. Every + // response clears lookupIssuedAt above, which is right for + // an answer that settles something -- nothing will ask + // again -- and wrong for this one: the state stays pending, + // so resumeDeferred() re-issues on the next + // checkForInvite(), and with no timestamp to throttle it an + // application that calls that from two places would spend + // all five attempts in seconds and settle an + // offline-minted invite as no_match before its + // registration ever arrived. This is the field the retry + // interval is measured from, so recording the completed + // attempt is what makes the interval apply to "not yet" + // as well as to silence. + lookupIssuedAt = System.currentTimeMillis(); setState(STATE_PENDING); return; } diff --git a/Ports/iOSPort/nativeSources/CN1InviteAppClip.m b/Ports/iOSPort/nativeSources/CN1InviteAppClip.m index d0ff2d3b6e1..7c151bf9d6e 100644 --- a/Ports/iOSPort/nativeSources/CN1InviteAppClip.m +++ b/Ports/iOSPort/nativeSources/CN1InviteAppClip.m @@ -79,7 +79,7 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isAppClipHandoffSupported___java_l ? JAVA_TRUE : JAVA_FALSE; } -JAVA_OBJECT com_codename1_impl_ios_IOSNative_consumeAppClipInviteHandoff___java_lang_String_R_java_lang_String( +JAVA_OBJECT com_codename1_impl_ios_IOSNative_readAppClipInviteHandoff___java_lang_String_R_java_lang_String( CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT groupObj) { NSUserDefaults *suite = cn1InviteSuite(toNSString(CN1_THREAD_STATE_PASS_ARG groupObj)); if (suite == nil) { @@ -103,10 +103,17 @@ JAVA_OBJECT com_codename1_impl_ios_IOSNative_consumeAppClipInviteHandoff___java_ long long clicked = [clickedValue isKindOfClass:[NSNumber class]] ? [(NSNumber *)clickedValue longLongValue] : 0; - // Cleared whatever was found, including a malformed record. Read once is - // the contract AppClipHandoffSource states, and it is what stops a second - // launch claiming a code the first already claimed. - [suite removeObjectForKey:kCN1InviteHandoffKey]; + // NOT cleared here. This container is the only durable copy of the code + // until the framework writes its own record, so emptying it as it was read + // destroyed the exact code whenever that write failed or the process + // exited in between -- and the next launch, finding no handoff, settled an + // invited install as no_match for ever. Read once is still the contract; + // clearAppClipInviteHandoff below is what enforces it, at the point where + // losing the value costs nothing. + // + // The malformed case above is different and still clears: a record that + // cannot be parsed will never become valid, and leaving it means reading + // the same garbage on every launch. if (code == nil || code.length == 0) { return JAVA_NULL; @@ -121,6 +128,15 @@ JAVA_OBJECT com_codename1_impl_ios_IOSNative_consumeAppClipInviteHandoff___java_ return fromNSString(CN1_THREAD_STATE_PASS_ARG joined); } +void com_codename1_impl_ios_IOSNative_clearAppClipInviteHandoff___java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT groupObj) { + NSUserDefaults *suite = cn1InviteSuite(toNSString(CN1_THREAD_STATE_PASS_ARG groupObj)); + if (suite == nil) { + return; + } + [suite removeObjectForKey:kCN1InviteHandoffKey]; +} + #else // Stubs when CN1_INCLUDE_INVITE_APPCLIP is not defined: the build generated no @@ -138,9 +154,13 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_isAppClipHandoffSupported___java_l return JAVA_FALSE; } -JAVA_OBJECT com_codename1_impl_ios_IOSNative_consumeAppClipInviteHandoff___java_lang_String_R_java_lang_String( +JAVA_OBJECT com_codename1_impl_ios_IOSNative_readAppClipInviteHandoff___java_lang_String_R_java_lang_String( CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT groupObj) { return JAVA_NULL; } +void com_codename1_impl_ios_IOSNative_clearAppClipInviteHandoff___java_lang_String( + CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT groupObj) { +} + #endif // CN1_INCLUDE_INVITE_APPCLIP diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSAppClipHandoff.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSAppClipHandoff.java index 13b911bc367..261fd648e0b 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSAppClipHandoff.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSAppClipHandoff.java @@ -71,7 +71,7 @@ public void requestHandoff(AppClipHandoffCallback callback) { String handoff; try { handoff = IOSImplementation.nativeInstance - .consumeAppClipInviteHandoff(appGroup); + .readAppClipInviteHandoff(appGroup); } catch (Throwable t) { // An unreachable container reads as "no clip ran", never as a // crash: the application works, it simply has no invite behind it. @@ -84,8 +84,8 @@ public void requestHandoff(AppClipHandoffCallback callback) { return; } // "\n". Two values in one string because the - // native side clears the container as it reads, so a second call to - // fetch the timestamp would answer nothing. + // container is read once and emptied once: splitting the call would + // mean deciding which half clears it. String code = handoff; long clicked = 0; int nl = handoff.indexOf('\n'); @@ -101,6 +101,31 @@ public void requestHandoff(AppClipHandoffCallback callback) { callback.onHandoff(code, clicked); } + /// Empties the shared container, once the framework has the code stored + /// somewhere that survives this process. + /// + /// The read deliberately leaves it alone. This container is the only + /// durable copy of an exact App Clip code until the framework writes its + /// own record, so clearing on read destroyed it whenever that write failed + /// or the process exited in between -- and the next launch, finding no + /// handoff, settled an invited install as no_match for ever. Nothing + /// reports that: the clip ran, the store carried the person across, and + /// the install simply looks organic. + public void handoffPersisted() { + if (appGroup == null || appGroup.length() == 0) { + return; + } + try { + IOSImplementation.nativeInstance.clearAppClipInviteHandoff(appGroup); + } catch (Throwable t) { + // Worth nothing more than a log: the code is stored, so the only + // cost of a container that could not be emptied is the next launch + // reading the same handoff again -- and the framework already + // refuses a second attribution for one install. + Log.e(t); + } + } + /// A timestamp that will not parse is not worth losing an attribution /// over: the code is what the claim is made with, and the click time is /// only reported alongside it. diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index fe430b94e41..715cde4ea28 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -2551,12 +2551,30 @@ native void nearbySendPayload(int requestId, String joinedEndpointIds, native boolean isAppClipHandoffSupported(String appGroup); /** - * Reads the invite handoff an App Clip left behind and clears it in the - * same step, so two launches cannot claim one code. + * Reads the invite handoff an App Clip left behind, WITHOUT clearing it. + * + *

The container is the only durable copy of an exact App Clip code + * until the framework writes its own record, so reading and clearing in + * one step destroyed it whenever that write failed or the process exited + * in between -- and the next launch, finding no handoff, settled an + * invited install as no_match for ever. {@link + * #clearAppClipInviteHandoff(String)} is what empties it, once the code is + * stored.

* * @param appGroup the group identifier * @return "code\nclickedSeconds", or null when no clip ran */ - native String consumeAppClipInviteHandoff(String appGroup); + native String readAppClipInviteHandoff(String appGroup); + + /** + * Empties the shared container, once the framework has stored the code. + * + *

Read-once is still the contract the source states: this is the step + * that enforces it, moved to the point where losing the value costs + * nothing.

+ * + * @param appGroup the group identifier + */ + native void clearAppClipInviteHandoff(String appGroup); } diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index f1cae75a37b..c032471cd23 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -3013,10 +3013,10 @@ public void usesClassMethod(String cls, String method) { // , and rendered again into the wear companion manifest, so // one append reaches both and cannot drift. if (usesInvites && "true".equals(request.getArg("android.invite.appLinks", "true"))) { - String inviteHost = request.getArg("invite.domain", "cloud.codenameone.com"); + String inviteHost = InviteBuildHints.domain(request); String existingFilter = request.getArg("android.xintent_filter", ""); String withAppLinks = InviteManifestFragments.injectAppLinks(existingFilter, - inviteHost, request.getArg("invite.slug", "")); + inviteHost, InviteBuildHints.slug(request)); if (!withAppLinks.equals(existingFilter)) { debug("Invite attribution: adding the App Links filter for " + inviteHost); request.putArgument("android.xintent_filter", withAppLinks); @@ -6044,13 +6044,13 @@ && watchModuleName(request) != null && legacyGplayServicesMode) { String inviteDomainProperty = ""; if (usesInvites) { inviteDomainProperty = " Display.getInstance().setProperty(\"invite.domain\", \"" - + request.getArg("invite.domain", "cloud.codenameone.com") + "\");\n"; + + InviteBuildHints.domain(request) + "\");\n"; // The slug goes with it, and for the same reason. This build claims // /i// and nothing else, so a client that mints a bare // /i/ link produces a url its own build cannot open -- and it // would, because the client only learns the slug from the link // service, which the first invite is minted before ever reaching. - String inviteSlug = request.getArg("invite.slug", ""); + String inviteSlug = InviteBuildHints.slug(request); if (inviteSlug != null && inviteSlug.trim().length() > 0) { inviteDomainProperty += " Display.getInstance().setProperty(\"invite.slug\", \"" + inviteSlug.trim() + "\");\n"; diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 73e9e6ee59b..c03d9dc227a 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -3706,13 +3706,13 @@ public void usesClassMethod(String cls, String method) { String inviteDomainProperty = ""; if (usesInvites) { inviteDomainProperty = " Display.getInstance().setProperty(\"invite.domain\", \"" - + request.getArg("invite.domain", "cloud.codenameone.com") + "\");\n"; + + InviteBuildHints.domain(request) + "\");\n"; // The slug goes with it, and for the same reason. This build claims // /i// and nothing else, so a client that mints a bare // /i/ link produces a url its own build cannot open -- and it // would, because the client only learns the slug from the link // service, which the first invite is minted before ever reaching. - String inviteSlug = request.getArg("invite.slug", ""); + String inviteSlug = InviteBuildHints.slug(request); if (inviteSlug != null && inviteSlug.trim().length() > 0) { inviteDomainProperty += " Display.getInstance().setProperty(\"invite.slug\", \"" + inviteSlug.trim() + "\");\n"; @@ -4448,7 +4448,7 @@ public void usesClassMethod(String cls, String method) { // duplicate key, which fails codesigning. if (usesInvites && "true".equals(request.getArg("ios.invite.universalLinks", "true"))) { - String inviteHost = request.getArg("invite.domain", "cloud.codenameone.com"); + String inviteHost = InviteBuildHints.domain(request); String existingDomains = request.getArg("ios.associatedDomains", ""); // TWO prefixes on the same host, and they do different jobs. // @@ -4485,7 +4485,7 @@ public void usesClassMethod(String cls, String method) { // domain has no way for iOS to offer a clip and would ship one // that can never launch. if (inviteAppClipGroup.length() > 0) { - String inviteHost = request.getArg("invite.domain", "cloud.codenameone.com"); + String inviteHost = InviteBuildHints.domain(request); // Already resolved and validated before the stub was written, // which needed it to decide whether to register a reader at // all. Re-deriving it here would let the two disagree. @@ -12359,7 +12359,7 @@ private void resolveInviteAppClipGroup(BuildRequest request) throws BuildExcepti private void appendInviteAppClipTarget(StringBuilder sb, BuildRequest request, File distDir) throws IOException, BuildException { String name = InviteAppClipBuilder.CLIP_NAME; - String inviteHost = request.getArg("invite.domain", "cloud.codenameone.com"); + String inviteHost = InviteBuildHints.domain(request); String displayName = request.getDisplayName() == null ? request.getMainClass() : request.getDisplayName(); IOSWalletExtensionBuilder.writeFileMap( diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteBuildHints.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteBuildHints.java new file mode 100644 index 00000000000..c0757b8e1a4 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteBuildHints.java @@ -0,0 +1,77 @@ +/* + * 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.builders; + +/** + * The invite build hints, read once and normalized. + * + *

{@code BuildRequest.getArg} returns what the developer typed, and these + * two values are copied into generated artefacts that must agree byte for + * byte: the Android manifest's {@code android:host} and {@code pathPrefix}, + * the iOS associated-domain entitlement, and the runtime properties the client + * mints its urls from. A stray space in the hint therefore produced a filter + * and a url that do not match -- so the link opens a browser instead of the + * app, on a build that succeeded with the filter plainly present.

+ * + *

That happened once with the slug, where the generated startup code + * trimmed and the manifest did not. This class exists so the next reader + * cannot reintroduce it by forgetting: there is one accessor per hint and + * every site goes through it.

+ */ +class InviteBuildHints { + + /** The default link host, used when the hint is absent or blank. */ + static final String DEFAULT_DOMAIN = "cloud.codenameone.com"; + + private InviteBuildHints() { + } + + /** + * The host invite links are served from. + * + * @param request the build request + * @return the trimmed hint, or the default when it is absent or blank + */ + static String domain(BuildRequest request) { + String value = request.getArg("invite.domain", DEFAULT_DOMAIN); + if (value == null) { + return DEFAULT_DOMAIN; + } + String trimmed = value.trim(); + // A blank hint is not a host. Left as the empty string it produced an + // intent filter with no host and an entitlement claiming nothing, + // which is harder to spot than the default being used. + return trimmed.isEmpty() ? DEFAULT_DOMAIN : trimmed; + } + + /** + * The per-application path segment invite links carry. + * + * @param request the build request + * @return the trimmed hint, or the empty string when it is absent + */ + static String slug(BuildRequest request) { + String value = request.getArg("invite.slug", ""); + return value == null ? "" : value.trim(); + } +} diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteBuildHintsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteBuildHintsTest.java new file mode 100644 index 00000000000..4871de4ba54 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteBuildHintsTest.java @@ -0,0 +1,72 @@ +/* + * 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.builders; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * The two invite hints are copied into artefacts that must agree byte for + * byte -- the manifest's host and pathPrefix, the associated-domain + * entitlement, and the runtime properties the client mints urls from. + * + *

{@code getArg} returns what the developer typed, so a stray space + * produced a filter and a url that do not match: the link opens a browser + * instead of the app, on a build that succeeded with the filter plainly + * present. That shipped once with the slug, where the generated startup code + * trimmed and the manifest did not.

+ */ +public class InviteBuildHintsTest { + + private static BuildRequest request(String key, String value) { + BuildRequest r = new BuildRequest(); + if (value != null) { + r.putArgument(key, value); + } + return r; + } + + @Test + void aDomainWithStraySpaceIsNormalised() { + assertEquals("links.example.com", + InviteBuildHints.domain(request("invite.domain", " links.example.com "))); + } + + @Test + void anAbsentOrBlankDomainFallsBackToTheDefault() { + // Blank is not a host: left as one it produced an intent filter with no + // host and an entitlement claiming nothing, which is harder to see than + // the default being used. + assertEquals(InviteBuildHints.DEFAULT_DOMAIN, + InviteBuildHints.domain(request("invite.domain", null))); + assertEquals(InviteBuildHints.DEFAULT_DOMAIN, + InviteBuildHints.domain(request("invite.domain", " "))); + } + + @Test + void aSlugWithStraySpaceIsNormalised() { + assertEquals("acme", InviteBuildHints.slug(request("invite.slug", " acme "))); + assertEquals("", InviteBuildHints.slug(request("invite.slug", null))); + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index e28ecce85d2..9bd80178cd7 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -449,6 +449,78 @@ void dimensionsAreRestoredFromTheDurableAttribution() { "the code was never restored from the durable record"); } + @FormTest + void aDurableClipHandoffIsAcknowledged() { + // The shared container is the only durable copy of an exact App Clip + // code until this record is written, so the source is told when the + // framework has it and may let go. Without the acknowledgement the + // container is read again on every launch. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + InviteTestSupport.PendingHandoffSource source = InviteTestSupport.pendingHandoff; + Invites.checkForInvite(); + assertTrue(source.wasAsked(), "the fixture never reached the clip handoff"); + + source.answer("CLIPACK1", 1700000000L); + + assertEquals(1, source.persistedCount(), + "a durable handoff was never acknowledged"); + } + + @FormTest + void aClipHandoffWhoseRecordFailedIsNotAcknowledged() { + // And the half that matters: a source that empties the container on + // being told would destroy the exact code, because the write that was + // supposed to keep it did not land. The next launch then finds no + // handoff and settles an invited install as no_match for ever. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + InviteTestSupport.PendingHandoffSource source = InviteTestSupport.pendingHandoff; + Invites.checkForInvite(); + assertTrue(source.wasAsked(), "the fixture never reached the clip handoff"); + + InviteStore.failNextWriteForTest(InviteStore.PENDING); + source.answer("CLIPACK2", 1700000000L); + + assertEquals(0, source.persistedCount(), + "the source was told to discard the only copy of the code after the " + + "write that was supposed to keep it had failed"); + } + + @FormTest + void aNotYetAnswerIsNotAskedAgainImmediately() { + // The retry above must be throttled, or the fix for it becomes its own + // bug: every response clears the issued-at stamp, so with the state + // left pending an application that calls checkForInvite() from two + // places would re-issue on each one, spend all five attempts in + // seconds, and settle an offline-minted invite as no_match before its + // registration ever arrived. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + callback.onReferrer("utm_source=cn1_invite&cn1_invite=THROTTLE", 0L, 0L); + } + }); + Invites.checkForInvite(); + Invites.handleResolution("{\"resolved\":false,\"retry\":true}", + Invites.MATCH_REFERRER, true); + assertEquals(Invites.STATE_PENDING, Invites.getState()); + implementation.clearQueuedRequests(); + + // The retry interval has NOT elapsed, so these must do nothing. + Invites.checkForInvite(); + Invites.checkForInvite(); + + assertEquals(0, implementation.getQueuedRequests().size(), + "a not-yet answer was re-asked inside the retry interval, which is how " + + "the attempt budget is spent in seconds"); + } + @FormTest void aNotYetAnswerIsAskedAgainInTheSameProcess() { // beginDeferred() runs at most once per process, so after a "not yet" diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java index 1e7ec369a5d..0c5fcfe05a3 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java @@ -55,6 +55,18 @@ public void requestHandoff(AppClipHandoffCallback cb) { callback = cb; } + /** Counts the acknowledgements, which is what the iOS source clears on. */ + private int persisted; + + public void handoffPersisted() { + persisted++; + } + + /** How many handoffs the framework reported as durably stored. */ + int persistedCount() { + return persisted; + } + /** True once Invites has asked. */ boolean wasAsked() { return callback != null; From 8219b71c804ce096918bd26513b32e2528c020ea Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:23:21 +0300 Subject: [PATCH 72/99] Invites: a double tap on the invite button minted two codes The invite was a mutable field written on every press and read when the share sheet answered. The sheet is modal, so a double tap does not open two of them -- it minted a second code, shared that one, and left the first registered and counted as invite_created with nobody ever sharing it. Worse, the result was reported against whichever invite the field held by the time it arrived rather than the one whose url went into the sheet, so an outcome could land on the wrong code. A press now reuses the invite still outstanding from the previous press, and the chained listener takes the field for itself before dispatching, so an outcome belongs to exactly one invite by construction. The share sheet cannot run headless, so the test drives mintForShare(), the seam actionPerformed() calls before delegating to ShareButton. Also records why the outbox needs no locking: postResponse() is handed to Display.callSerially(), so acknowledgement runs on the EDT beside create(), flush() and reset(). That corrects a comment of mine on the outstanding-requests field which claimed it was reached from the network thread; it is not, and no lock belongs there. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 30 +++++++-- .../codename1/components/InviteButton.java | 39 ++++++++++-- .../components/InviteButtonMintTest.java | 63 +++++++++++++++++++ 3 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 maven/core-unittests/src/test/java/com/codename1/components/InviteButtonMintTest.java diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 66ddd559aea..5066f6a0f0c 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -3311,11 +3311,14 @@ private static void notifyUnavailable(String reason) { // client id and the code it was claiming. Both are precisely the identity // the user asked to be rid of. // - // A Vector because these are touched from two threads: added on the EDT - // when the request is queued, removed from the network thread when it - // fails. That is the same boundary the map above already straddles, and it - // is a real one -- not the single-threaded EDT the rest of this class runs - // on. + // A Vector, and the reason is NOT what an earlier version of this comment + // claimed. It said these were touched from the network thread as well; + // they are not. postResponse() is handed to callSerially, so the release + // runs on the EDT, and the network thread's own hook -- handleException() + // -- never runs for these requests at all, because they are fail-silent + // and NetworkManager only logs. The collection is EDT-only like the rest + // of this class; the Vector is simply what it was written with and costs + // nothing to keep. private static final java.util.Vector outstanding = new java.util.Vector(); @@ -3642,6 +3645,23 @@ private static void registrationAcknowledged(String json) { if (acknowledged != null) { unacknowledged.remove(acknowledged); } + // Read-modify-write, and deliberately unguarded: this runs on the EDT, + // and so does everything else that touches the outbox. + // + // A review round read it as a race -- a response landing on the + // network thread while the EDT mints, so one overwrites the other's + // queue -- and asked for a lock. There is no such interleaving: + // ConnectionRequest hands postResponse() to + // Display.getInstance().callSerially(), so it runs on the EDT like + // create(), flush() and reset(). The network thread's own hook, + // handleException(), touches the in-flight marks and never the outbox + // -- and for these requests it does not run at all, because they are + // fail-silent and NetworkManager only logs. + // + // A lock here would be the wrong answer to a question nobody asked: + // this framework is single-threaded on the EDT by design, and the one + // real boundary -- the native callbacks -- is marshalled with + // callSerially before it reaches any of this. List outbox = InviteStore.readOutbox(); if (outbox.remove(json)) { InviteStore.writeOutbox(outbox); diff --git a/CodenameOne/src/com/codename1/components/InviteButton.java b/CodenameOne/src/com/codename1/components/InviteButton.java index d4ddd9deda3..29840d2ac80 100644 --- a/CodenameOne/src/com/codename1/components/InviteButton.java +++ b/CodenameOne/src/com/codename1/components/InviteButton.java @@ -80,7 +80,10 @@ private void installChain() { super.setShareResultListener(new ShareResultListener() { @Override public void onResult(com.codename1.share.ShareResult result) { + // Taken and CLEARED, so the next press mints again and this + // outcome can only ever be reported once. Invite current = invite; + invite = null; if (current != null) { Invites.reportShareResult(current, result); } @@ -188,6 +191,20 @@ public ShareResultListener getShareResultListener() { /// {@inheritDoc} @Override public void actionPerformed(ActionEvent evt) { + mintForShare(); + // ShareButton defers the share by one EDT cycle, so setting the text + // in mintForShare() is in time. + super.actionPerformed(evt); + } + + /// Mints the invite this press will share, or keeps the one still + /// outstanding, and sets the text. + /// + /// Package private so a test can drive it without the share sheet: the + /// sheet is the one part of a press that cannot run headless, and the + /// question this answers -- how many invites two presses mint -- is + /// decided before it opens. + Invite mintForShare() { InviteRequest.Builder b = InviteRequest.create(); if (campaign != null) { b.campaign(campaign); @@ -198,13 +215,27 @@ public void actionPerformed(ActionEvent evt) { if (payload != null) { b.payload(payload); } - invite = Invites.create(b.build()); + // One outstanding invite at a time, and a second press before the + // first sheet has answered reuses it rather than minting another. + // + // The share sheet is modal, so a double tap does not open two of them + // -- it mints two codes and shares the later one, leaving the first + // registered, counted as invite_created, and never shared by anybody. + // Worse, the result is reported against whichever invite the field + // held when it arrived, so with two sheets the answer for one could be + // recorded against the other. + // + // Reusing removes both: the outcome belongs to exactly one invite by + // construction. Sharing one code more than once is the ordinary shape + // of a referral anyway -- a code is not per recipient, it is the + // inviter's -- so nothing is lost by not minting a second. + if (invite == null) { + invite = Invites.create(b.build()); + } String text = message == null || message.length() == 0 ? invite.getUrl() : message + " " + invite.getUrl(); setTextToShare(text); - // ShareButton defers the share by one EDT cycle, so setting the text - // here is in time. - super.actionPerformed(evt); + return invite; } /// {@inheritDoc} diff --git a/maven/core-unittests/src/test/java/com/codename1/components/InviteButtonMintTest.java b/maven/core-unittests/src/test/java/com/codename1/components/InviteButtonMintTest.java new file mode 100644 index 00000000000..90f61314929 --- /dev/null +++ b/maven/core-unittests/src/test/java/com/codename1/components/InviteButtonMintTest.java @@ -0,0 +1,63 @@ +/* + * 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.components; + +import com.codename1.analytics.Analytics; +import com.codename1.analytics.AnalyticsConsent; +import com.codename1.analytics.ConsentMode; +import com.codename1.analytics.invite.Invite; +import com.codename1.analytics.invite.Invites; +import com.codename1.junit.FormTest; +import com.codename1.junit.UITestBase; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +/** + * A press mints the invite it is about to share, and a second press before the + * first sheet has answered must not mint another. + */ +public class InviteButtonMintTest extends UITestBase { + + @FormTest + void aSecondPressBeforeTheFirstResultSharesTheSameInvite() { + // The share sheet is modal, so a double tap does not open two of them. + // Minting on every press left the first code registered, counted as + // invite_created, and shared by nobody -- and the result, when it + // arrived, was reported against whichever invite the field held by + // then rather than the one whose url went into the sheet. + Invites.reset(); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).build()); + InviteButton button = new InviteButton("Invite a friend"); + button.setCampaign("spring"); + + Invite first = button.mintForShare(); + Invite second = button.mintForShare(); + + assertNotNull(first, "the first press minted nothing"); + assertSame(first, second, + "a second press minted another invite, so one of them is shared by " + + "nobody and the result can be reported against the wrong code"); + } +} From 869e5081180731a373e489f92d7efcf85b77aa1b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:34:34 +0300 Subject: [PATCH 73/99] Invites: getInvite() went null exactly when the app needed it Fixing the double-press mint, I cleared the invite field when the share result arrived so the next press would mint again. That field is also what getInvite() answers, and its contract is "the invite minted for the most recent press" -- so it returned null from inside the application's own ShareResultListener, which is the one moment it has to be right. An application had no way left to tell which invite a ShareResult belonged to, and the accessor had no remaining use. One field was answering two questions. They are now separate: `invite` is what getInvite() reports and survives the result, `outstanding` is the one still awaiting an outcome and is cleared as soon as that outcome is taken, which is all the double-press guard ever needed. mintForShare() now reads the outstanding one for the url it puts in the sheet. They cannot differ today; taking it from the field that means "the invite this press shares" is what keeps that true. Covered both ways: the accessor answering inside the app's listener and after it, and a press after a completed share minting a fresh code rather than re-sharing the one already sent. Delivered through the chained listener, which the test reaches by a package-private field because getShareResultListener() is overridden to answer with the application's. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/components/InviteButton.java | 40 ++++++++++---- .../components/InviteButtonMintTest.java | 54 +++++++++++++++++++ 2 files changed, 85 insertions(+), 9 deletions(-) diff --git a/CodenameOne/src/com/codename1/components/InviteButton.java b/CodenameOne/src/com/codename1/components/InviteButton.java index 29840d2ac80..6b13c2f9f16 100644 --- a/CodenameOne/src/com/codename1/components/InviteButton.java +++ b/CodenameOne/src/com/codename1/components/InviteButton.java @@ -50,8 +50,22 @@ public class InviteButton extends ShareButton { private String channel; private String payload; private String message; + // Two fields, because they answer two different questions and clearing + // one on a result silently broke the other. `invite` is what getInvite() + // reports -- the invite minted for the most recent press, which an + // application reads from inside its own ShareResultListener to tell which + // invite the ShareResult belongs to, so it has to survive the result. + // `outstanding` is the one still awaiting a result, and exists only to + // stop a second press minting a second code; it is cleared as soon as the + // outcome is taken, so that outcome can be reported exactly once. private Invite invite; + private Invite outstanding; private ShareResultListener appListener; + // The chained listener super was given. Package private so a test can + // deliver a ShareResult without the share sheet -- getShareResultListener() + // is overridden to answer with the application's listener, so the chain is + // otherwise unreachable from outside a real press. + ShareResultListener chain; /// Default constructor. public InviteButton() { @@ -77,13 +91,16 @@ public InviteButton(String text) { // listener would silently replace the chain and the funnel would lose // every share. private void installChain() { - super.setShareResultListener(new ShareResultListener() { + chain = new ShareResultListener() { @Override public void onResult(com.codename1.share.ShareResult result) { // Taken and CLEARED, so the next press mints again and this - // outcome can only ever be reported once. - Invite current = invite; - invite = null; + // outcome can only ever be reported once. Only the outstanding + // mark is cleared -- getInvite() still answers, because the + // application's listener runs below and correlating the result + // with its invite is the whole reason that accessor exists. + Invite current = outstanding; + outstanding = null; if (current != null) { Invites.reportShareResult(current, result); } @@ -91,7 +108,8 @@ public void onResult(com.codename1.share.ShareResult result) { appListener.onResult(result); } } - }); + }; + super.setShareResultListener(chain); } /// Groups the invites this button mints under a campaign. @@ -229,13 +247,17 @@ Invite mintForShare() { // construction. Sharing one code more than once is the ordinary shape // of a referral anyway -- a code is not per recipient, it is the // inviter's -- so nothing is lost by not minting a second. - if (invite == null) { - invite = Invites.create(b.build()); + if (outstanding == null) { + outstanding = Invites.create(b.build()); + invite = outstanding; } + // The outstanding one, not the accessor's: this is the invite whose + // url goes into the sheet, and the two only ever differ if a future + // change lets them. String text = message == null || message.length() == 0 - ? invite.getUrl() : message + " " + invite.getUrl(); + ? outstanding.getUrl() : message + " " + outstanding.getUrl(); setTextToShare(text); - return invite; + return outstanding; } /// {@inheritDoc} diff --git a/maven/core-unittests/src/test/java/com/codename1/components/InviteButtonMintTest.java b/maven/core-unittests/src/test/java/com/codename1/components/InviteButtonMintTest.java index 90f61314929..aa6f8fa7f7f 100644 --- a/maven/core-unittests/src/test/java/com/codename1/components/InviteButtonMintTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/components/InviteButtonMintTest.java @@ -28,9 +28,12 @@ import com.codename1.analytics.invite.Invite; import com.codename1.analytics.invite.Invites; import com.codename1.junit.FormTest; +import com.codename1.share.ShareResult; +import com.codename1.share.ShareResultListener; import com.codename1.junit.UITestBase; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertSame; /** @@ -60,4 +63,55 @@ void aSecondPressBeforeTheFirstResultSharesTheSameInvite() { "a second press minted another invite, so one of them is shared by " + "nobody and the result can be reported against the wrong code"); } + + @FormTest + void theInviteSurvivesTheResultSoTheAppCanCorrelateIt() { + // getInvite() is the application's only way to tell which invite a + // ShareResult belongs to, and its contract is "the invite minted for + // the most recent press". Clearing the field when the result arrived + // -- to stop the next press reporting the same outcome twice -- made + // it answer null from inside the application's own listener, which is + // the single moment it has to be right. + Invites.reset(); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).build()); + InviteButton button = new InviteButton("Invite a friend"); + + Invite shared = button.mintForShare(); + final Invite[] seenByTheApp = new Invite[1]; + button.setShareResultListener(new ShareResultListener() { + @Override + public void onResult(ShareResult result) { + seenByTheApp[0] = button.getInvite(); + } + }); + button.chain.onResult(ShareResult.sharedTo("com.example.chat")); + + assertSame(shared, seenByTheApp[0], + "getInvite() answered null inside the app's listener, so the app " + + "cannot tell which invite the ShareResult belongs to"); + assertSame(shared, button.getInvite(), + "getInvite() stopped reporting the most recent press after the result"); + } + + @FormTest + void theNextPressAfterAResultMintsAFreshInvite() { + // The other half of the same split: the outstanding mark must clear on + // the result even though getInvite() keeps answering, or a press after + // a completed share would re-share the code that was already sent and + // report its outcome a second time. + Invites.reset(); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).build()); + InviteButton button = new InviteButton("Invite a friend"); + + Invite first = button.mintForShare(); + button.chain.onResult(ShareResult.sharedTo("com.example.chat")); + Invite second = button.mintForShare(); + + assertNotNull(second, "the press after a completed share minted nothing"); + assertNotSame(first, second, + "a press after the sheet had already answered re-shared the invite " + + "that was just sent, so its outcome is reported twice"); + } } From 7aa520aa73287e77f7c0eab97e524debfce9a314 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:54:53 +0300 Subject: [PATCH 74/99] Invites: a second press shared twice, and a retried write told nobody Two findings from the same round, both in paths the previous fix opened. A second press while the sheet is outstanding now does nothing at all. Reusing the invite stopped the second press MINTING a second code, but ShareButton defers to the next EDT cycle and then shares unconditionally, so both presses still enqueued a presentation: two native sheets attempted, the application's result listener called twice, and -- because the first result takes the outstanding mark -- the second share reported to nobody. A real share missing from the funnel is the part the reuse introduced. Safe to swallow the press because Display.share() documents that the listener always runs, with a null package name where the platform cannot say, so the mark cannot be left set by a share that never answers. The App Clip handoff is acknowledged wherever the record becomes durable, not only on the first write. A failed write leaves the record in memory and readPending() retries it the next time anything reads it -- which claim() does on its way out -- so the code became durable through a path that never told the clip, and its container kept the code. That outlives an erasure: the container is read on launch, so the next one found the handoff again and restored exactly what the erasure promised to forget. Acknowledging inside writePending() covers every retry without each of them having to remember to. The press guard needed a seam to be testable at all: the first version of its test passed with the guard removed, because the deferred share never runs headless. presentShare() is what a press does, and counting it is what the guard changes. The store's write-failure seam takes a count. One shot could not hold a record undurable, because the retry inside readPending() persists it -- so the test for "a failed write is not acknowledged" was really watching the retry succeed. freshInstall() disarms any unspent count, which it never had to do while a single failure consumed itself. Co-Authored-By: Claude Opus 5 (1M context) --- .../analytics/invite/InviteStore.java | 16 +++- .../codename1/analytics/invite/Invites.java | 74 ++++++++++++++----- .../codename1/components/InviteButton.java | 38 +++++++++- .../invite/InviteResilienceTest.java | 43 ++++++++++- .../analytics/invite/InviteTestSupport.java | 6 ++ .../components/InviteButtonMintTest.java | 69 +++++++++++++++++ 6 files changed, 223 insertions(+), 23 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java index b08d4d68cbf..8db7336c733 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java @@ -123,7 +123,18 @@ static Map read(String record) { private static String failNextNamed; static void failNextWriteForTest(String name) { + failWritesForTest(name, 1); + } + + // How many more writes of that record must fail. A single shot is not + // enough to hold a record undurable: readPending() retries the held copy + // the next time anything reads it, so a test that wants to observe "still + // not saved" has to outlast the retry as well as the first attempt. + private static int failNamedRemaining; + + static void failWritesForTest(String name, int count) { failNextNamed = name; + failNamedRemaining = count; } // The same seam for a delete. Storage.deleteStorageFile cannot be made to @@ -136,7 +147,10 @@ static void failNextDeleteForTest(String name) { static boolean write(String record, Map values) { if (record != null && record.equals(failNextNamed)) { - failNextNamed = null; + failNamedRemaining--; + if (failNamedRemaining <= 0) { + failNextNamed = null; + } return false; } try { diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 5066f6a0f0c..3abf52eefa2 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -226,6 +226,9 @@ public final class Invites { // with this application. Registered by the build the same way, and absent // on every platform that has no clip. private static AppClipHandoffSource appClipSource; + // Set when a clip handoff has been read and not yet made durable. The clip + // container is the only copy until then, so it must not be cleared early. + private static boolean handoffAwaitingAck; // Set when an erasure could not remove the durable records, and cleared // when a later attempt does. Nothing that transmits may run while it is @@ -1908,9 +1911,46 @@ private static boolean writePending(Map record) { // Cleared on success rather than left behind, so the fallback can never // shadow a newer durable record. pendingFallback = written ? null : record; + if (written) { + ackHandoff(record); + } return written; } + /// Lets the App Clip drop its copy, once ours is durable. + /// + /// Here rather than beside the first write, because the first write is not + /// the only one that can make the record durable. A write that fails + /// leaves the record in `pendingFallback`, and `readPending()` retries it + /// the next time anything wants it -- so the code became durable with + /// nobody telling the clip, and its container kept the code for ever. + /// + /// That is not merely untidy. The container is read on launch, so a code + /// left in it outlives an erasure: the user erased their attribution, the + /// next launch found the handoff again and restored exactly what the + /// erasure promised to forget. + /// + /// Every path that persists goes through `writePending`, so acknowledging + /// here covers the retries without any of them having to remember to. + private static void ackHandoff(Map record) { + if (!handoffAwaitingAck || !"app_clip".equals(record.get("codeSource"))) { + return; + } + AppClipHandoffSource source = appClipSource; + // Cleared whether or not there is still a source to tell: the flag + // tracks this process's obligation, and a source that has gone away + // cannot be handed anything. + handoffAwaitingAck = false; + if (source == null) { + return; + } + try { + source.handoffPersisted(); + } catch (Throwable t) { + Log.e(t); + } + } + /// Forgets the in-memory copy, for the paths that delete the record. private static void forgetPendingFallback() { pendingFallback = null; @@ -2433,23 +2473,23 @@ public void run() { record.put("codeClicked", String.valueOf(clickedSeconds * 1000L)); } - if (writePending(record)) { - // And only now may the source let go of its own - // copy. The container the clip wrote is the ONLY - // durable copy until this write lands, so a source - // that emptied it as it read destroyed the exact - // code whenever this failed or the process exited - // first -- and the next launch, finding no - // handoff, settled an invited install as no_match - // for ever. A write that failed leaves the - // container alone, so the next launch reads it - // again. - try { - source.handoffPersisted(); - } catch (Throwable t) { - Log.e(t); - } - } + // Owed from here until the record is durable, which + // may be this write or a later retry of it. + // And the source may let go of its own copy only + // once ours is durable -- which writePending() reports + // by calling handoffPersisted(), here or on whichever + // later retry succeeds. + // + // The container the clip wrote is the ONLY durable + // copy until then, so a source that emptied it as it + // read destroyed the exact code whenever the write + // failed or the process exited first -- and the next + // launch, finding no handoff, settled an invited + // install as no_match for ever. A write that failed + // leaves the container alone, so the next launch reads + // it again. + handoffAwaitingAck = true; + writePending(record); // Claimed exactly as a referrer code is: the trip // through the store is what makes both of them exact, // and the server treats them the same way. diff --git a/CodenameOne/src/com/codename1/components/InviteButton.java b/CodenameOne/src/com/codename1/components/InviteButton.java index 6b13c2f9f16..d6860312c25 100644 --- a/CodenameOne/src/com/codename1/components/InviteButton.java +++ b/CodenameOne/src/com/codename1/components/InviteButton.java @@ -209,9 +209,43 @@ public ShareResultListener getShareResultListener() { /// {@inheritDoc} @Override public void actionPerformed(ActionEvent evt) { + // A press while a share is still outstanding does NOTHING, rather than + // reusing the code and presenting a second time. + // + // ShareButton defers to the next EDT cycle and then shares + // unconditionally, so two presses within one cycle enqueue two + // presentations. Reusing the invite made both carry the same code, + // which was the point, but it left the rest: two native sheets + // attempted, the application's result listener called twice, and -- + // because the first result takes `outstanding` -- the second share + // reported to nobody. A real share missing from the funnel is the + // worst of those, and it is the one the reuse introduced. + // + // Safe to swallow the press because the outcome always arrives: + // Display.share() documents that the listener is invoked even where + // the platform cannot report a result, with a null package name, so + // `outstanding` cannot be left set by a share that never answers. + if (outstanding != null) { + return; + } mintForShare(); - // ShareButton defers the share by one EDT cycle, so setting the text - // in mintForShare() is in time. + presentShare(evt); + } + + /// Hands the press to [ShareButton], which presents the sheet. + /// + /// Package private so a test can count presentations. Whether a second + /// press presents a second time is not observable otherwise: ShareButton + /// defers to the next EDT cycle, and the sheet it opens there is the one + /// part of a press that cannot run headless. + /// + /// ShareButton defers by one EDT cycle, so the text set in + /// `mintForShare()` is in time. + /// + /// #### Parameters + /// + /// - `evt`: the press + void presentShare(ActionEvent evt) { super.actionPerformed(evt); } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 9bd80178cd7..ac3f23121a0 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -479,12 +479,49 @@ void aClipHandoffWhoseRecordFailedIsNotAcknowledged() { Invites.checkForInvite(); assertTrue(source.wasAsked(), "the fixture never reached the clip handoff"); - InviteStore.failNextWriteForTest(InviteStore.PENDING); + // Every write of the record fails, retries included. One failure is + // not enough to show this: claim() reads the record on its way out and + // readPending() persists the held copy, so a single shot leaves it + // durable -- which is the case the test below covers. + InviteStore.failWritesForTest(InviteStore.PENDING, 8); source.answer("CLIPACK2", 1700000000L); + assertTrue(Invites.pendingFallbackPresentForTest(), + "the record was saved after all, so this proves nothing"); assertEquals(0, source.persistedCount(), - "the source was told to discard the only copy of the code after the " - + "write that was supposed to keep it had failed"); + "the source was told to discard the only copy of the code while the " + + "write that was supposed to keep it kept failing"); + } + + @FormTest + void aClipHandoffAcknowledgedWhenTheRETRYPersistsIt() { + // The other end of the same obligation. A failed write leaves the + // record in memory and readPending() retries it the next time anything + // wants it -- so the code became durable through a path that never + // told the clip, and its container kept the code for ever. + // + // That outlives an erasure: the container is read on launch, so the + // next one found the handoff again and restored exactly the + // attribution the user asked to be forgotten. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + InviteTestSupport.PendingHandoffSource source = InviteTestSupport.pendingHandoff; + Invites.checkForInvite(); + assertTrue(source.wasAsked(), "the fixture never reached the clip handoff"); + + // Exactly ONE write fails. The held record is then persisted by the + // retry inside readPending(), which claim() reaches on its way out -- + // the path that used to make the code durable with nobody telling the + // clip. + InviteStore.failNextWriteForTest(InviteStore.PENDING); + source.answer("CLIPRETRY", 1700000000L); + + assertFalse(Invites.pendingFallbackPresentForTest(), + "the retry did not persist the held record, so this proves nothing"); + assertEquals(1, source.persistedCount(), + "the record became durable through the retry and the clip was never " + + "told, so its container keeps the code and a launch after an " + + "erasure restores the attribution that was erased"); } @FormTest diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java index 0c5fcfe05a3..ff266b4e6d5 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java @@ -116,6 +116,12 @@ static RecordingProvider freshInstall() { pendingHandoff = new PendingHandoffSource(); Invites.registerAppClipHandoffSource(pendingHandoff); Invites.lookupRetryDelay = 30000L; + // Any unspent write failure a previous case armed is disarmed here. + // It used to disarm itself, because one failure consumed it; a case + // that asks for several can leave a count behind, and a store that + // refuses to save in a test that never asked for it is a confusing + // way to fail. + InviteStore.failWritesForTest(null, 0); Invites.reset(); Preferences.delete(Invites.PREF_SLUG); Preferences.delete(Invites.PREF_CONSUMED_ARG); diff --git a/maven/core-unittests/src/test/java/com/codename1/components/InviteButtonMintTest.java b/maven/core-unittests/src/test/java/com/codename1/components/InviteButtonMintTest.java index aa6f8fa7f7f..0bef6dc4305 100644 --- a/maven/core-unittests/src/test/java/com/codename1/components/InviteButtonMintTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/components/InviteButtonMintTest.java @@ -29,9 +29,11 @@ import com.codename1.analytics.invite.Invites; import com.codename1.junit.FormTest; import com.codename1.share.ShareResult; +import com.codename1.ui.events.ActionEvent; import com.codename1.share.ShareResultListener; import com.codename1.junit.UITestBase; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertSame; @@ -114,4 +116,71 @@ void theNextPressAfterAResultMintsAFreshInvite() { "a press after the sheet had already answered re-shared the invite " + "that was just sent, so its outcome is reported twice"); } + + @FormTest + void aSecondPressWhileTheSheetIsOutstandingDoesNothing() { + // Reusing the code was only half of it. ShareButton defers to the next + // EDT cycle and then shares unconditionally, so two presses inside one + // cycle still enqueued two presentations: two native sheets attempted, + // the app's listener called twice, and -- because the first result + // takes the outstanding mark -- the SECOND share reported to nobody. + // A real share missing from the funnel is the part the reuse caused. + Invites.reset(); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).build()); + final int[] presented = new int[1]; + InviteButton button = new InviteButton("Invite a friend") { + @Override + void presentShare(ActionEvent evt) { + // NOT delegated: ShareButton would defer to the next EDT cycle + // and open a sheet. Counting here is what the press does, and + // it is the thing the guard changes. + presented[0]++; + } + }; + + button.actionPerformed(new ActionEvent(button)); + button.actionPerformed(new ActionEvent(button)); + + assertNotNull(button.getInvite(), "the first press minted nothing"); + assertEquals(1, presented[0], + "a second press while the sheet was still outstanding presented " + + "another share, so two sheets are attempted, the app's " + + "listener is called twice, and the second share -- whose " + + "outstanding mark the first result already took -- is " + + "reported to nobody"); + } + + @FormTest + void aPressAfterTheSheetAnsweredWorksAgain() { + // The guard must not be a latch: swallowing every later press would + // make the button dead after one share. Safe to swallow at all only + // because Display.share() always reports an outcome -- with a null + // package name where the platform cannot say -- so the outstanding + // mark is always cleared. + Invites.reset(); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).build()); + final int[] presented = new int[1]; + InviteButton button = new InviteButton("Invite a friend") { + @Override + void presentShare(ActionEvent evt) { + presented[0]++; + } + }; + + button.actionPerformed(new ActionEvent(button)); + Invite first = button.getInvite(); + button.chain.onResult(ShareResult.sharedTo("com.example.chat")); + button.actionPerformed(new ActionEvent(button)); + Invite second = button.getInvite(); + + assertEquals(2, presented[0], + "the guard is a latch: the press after a completed share never " + + "reached the share sheet"); + + assertNotNull(second, "the button was dead after one completed share"); + assertNotSame(first, second, + "the press after a completed share did not mint a fresh invite"); + } } From 937bdda0d03584caa9ede705e6f3571a04f9795b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:05:28 +0300 Subject: [PATCH 75/99] Invites: a killed lookup still looked outstanding, and a cleared handoff was not flushed Suspending transmission now says the lookup is no longer outstanding. Switching from OPT_OUT to OPT_IN with nothing on record withdraws the mode's implicit allow, so queued requests are killed -- they passed the permission gate a moment ago and would transmit after transmission stopped being permitted. Nothing is refused there, so the lookup stays pending; but lookupIssuedAt was left stamped, so for the rest of the retry interval the lookup was dead and the state said it was on its way. Granting consent inside that interval then did nothing at all, because onConsentChanged() will not restart a lookup it believes is outstanding, and the invite stayed unresolved until an explicit check after the delay or the next launch -- by which time the attribution window may have closed. The epoch goes up for the reason it does on a withdrawal: a response already on the wire must not land against the state this leaves. Clearing the App Clip handoff is flushed before it returns, which the generated clip already does after writing it. NSUserDefaults writes back on its own schedule, so an app terminated in between left the code in the shared container -- and the container is read on launch, so the value outlives the record it was copied into and re-attributes from it, including after an erasure, the one case where the framework has deliberately forgotten and cannot notice that the clip has not. The malformed-record path clears for its own reasons and gets the same flush. The .m is not fully compiled here: the two added statements were syntax checked against the real Foundation SDK, the file's delimiters balance, and check-native-signatures.sh reports the iOS port resolving with no MISSING or SIGNATURE finding. A full translation was not run. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 20 +++++++++ .../iOSPort/nativeSources/CN1InviteAppClip.m | 20 +++++++++ .../invite/InviteConsentAndErasureTest.java | 45 +++++++++++++++++++ 3 files changed, 85 insertions(+) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 3abf52eefa2..d51ffc416e0 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1376,6 +1376,26 @@ private static boolean settleErasure() { /// stays, and a later grant sends it. static void suspendTransmission() { killQueuedRequests(); + // And the lookup is no longer outstanding, which has to be SAID. + // + // lookupIssuedAt is what lookupInFlight() answers from, and killing the + // requests left it stamped: for the rest of the retry interval the + // lookup was dead and the state said it was on its way. Granting + // consent inside that interval reaches onConsentChanged(true), which + // declines to restart a lookup it believes is already outstanding -- + // so the invite stayed unresolved until an explicit checkForInvite() + // after the delay, or the next launch, by which time the attribution + // window may have closed. + // + // The epoch goes up for the reason it goes up on an erasure or a + // withdrawal: the permission behind the outstanding lookup has just + // changed, and a response already on the wire must not be allowed to + // land against the state this leaves behind. + lookupEpoch++; + lookupIssuedAt = 0; + // Cleared too, or beginDeferred() would decline to start the lookup it + // is being restarted to run. + deferredStarted = false; } // Package private: called from the provider when consent changes. diff --git a/Ports/iOSPort/nativeSources/CN1InviteAppClip.m b/Ports/iOSPort/nativeSources/CN1InviteAppClip.m index 7c151bf9d6e..3a170373259 100644 --- a/Ports/iOSPort/nativeSources/CN1InviteAppClip.m +++ b/Ports/iOSPort/nativeSources/CN1InviteAppClip.m @@ -93,6 +93,9 @@ JAVA_OBJECT com_codename1_impl_ios_IOSNative_readAppClipInviteHandoff___java_lan if (![stored isKindOfClass:[NSDictionary class]]) { if (stored != nil) { [suite removeObjectForKey:kCN1InviteHandoffKey]; + // Flushed, for the same reason the clip flushes its write: see + // clearAppClipInviteHandoff below. + [suite synchronize]; } return JAVA_NULL; } @@ -135,6 +138,23 @@ void com_codename1_impl_ios_IOSNative_clearAppClipInviteHandoff___java_lang_Stri return; } [suite removeObjectForKey:kCN1InviteHandoffKey]; + // Flushed before returning, exactly as the generated clip flushes the + // write this undoes. + // + // NSUserDefaults writes back on its own schedule, so an app terminated + // between this call and that flush left the code in the shared container. + // The container is read on launch, so the value outlives the record it was + // copied into: the next launch finds the handoff again and re-attributes + // from it -- including after an erasure, which is the one case where the + // framework has deliberately forgotten and cannot notice that the clip has + // not. + // + // The write side is the one that proves this matters. A clip is a + // short-lived process that can be killed the moment it hands over, and it + // calls synchronize for that reason; the full app is longer-lived but the + // asymmetry has no justification, and the cost here is one flush on a path + // that runs once per install. + [suite synchronize]; } #else diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java index ad707eef6a2..b746fd4022d 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java @@ -591,4 +591,49 @@ void anErasureDropsTheDurableRecordsEvenWithNoProviderRegistered() { assertNull(InviteStore.read(InviteStore.ATTRIBUTION), "the durable attribution record was left on the device"); } + + @FormTest + void grantingConsentRestartsALookupThatSuspensionKilled() { + // Switching from OPT_OUT to OPT_IN with nothing on record withdraws the + // mode's implicit allow, so queued requests are killed: they passed the + // permission gate a moment ago and would transmit after transmission + // stopped being permitted. Nothing is refused -- the prompt is simply + // unanswered -- so the lookup stays pending. + // + // The kill used to leave lookupIssuedAt stamped, so for the rest of the + // retry interval the lookup was dead and the state said it was in + // flight. Granting consent inside that interval then did nothing, + // because onConsentChanged() will not restart a lookup it believes is + // already outstanding, and the invite stayed unresolved until an + // explicit check after the delay or the next launch -- by which time + // the attribution window may have closed. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Analytics.setConsentMode(ConsentMode.OPT_OUT); + Analytics.setConsent(null); + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + callback.onReferrer("utm_source=cn1_invite&cn1_invite=SUSPEND1", 0L, 0L); + } + }); + Invites.checkForInvite(); + assertEquals(Invites.STATE_PENDING, Invites.getState(), + "the fixture never got a lookup under way"); + + // The withdrawal. The retry interval has NOT elapsed, which is the + // whole point: this is the window the stale stamp covered. + Analytics.setConsentMode(ConsentMode.OPT_IN); + implementation.clearQueuedRequests(); + + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).build()); + + assertFalse(implementation.getQueuedRequests().isEmpty(), + "consent was granted while the killed lookup still looked outstanding, " + + "so nothing restarted it and the invite stays unresolved until " + + "the retry interval elapses or the app is launched again"); + } } From 7781cc3a8400388635bf25da477c540d0763367a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:27:54 +0300 Subject: [PATCH 76/99] Invites: the code now proves who minted it, and forgetting reaches the clip Two findings, both about a code outliving the thing that was supposed to own or forget it. A code is now the truncated SHA-256 of a random secret, and the SECRET is what registration sends. It used to be the random bytes themselves, on the reasoning that a code "identifies an invite and authorizes nothing" -- true of every path except the one that CREATES the server row. An invite shared while its registration sat in the offline outbox was a public code with no row behind it, and a recipient of that link, running the same shipped app and holding the build key inside it, could register it first under their own client id. The real inviter's registration was then refused as a different inviter, and every install and payout on the link went to the recipient. The proof goes to the queued registration and nowhere else: not on Invite, which is public, and not in the url, which is the thing everybody can read. reset() and erasure now tell the App Clip source to discard its copy. The acknowledgement added earlier covers a code THIS process read; a code the clip left that nothing has consumed yet sits in the shared container, and forgetting cleared the store and left it there. The container is read on launch, so the next check found it and attributed the device to exactly the inviter the erasure was asked to forget -- with the raw code on disk naming them in the meantime. handoffPersisted() became discardHandoff(), because the framework now lets go for two reasons and only one of them is "persisted". The iOS implementation is identical either way; naming it for the durable case would have made the erasure path a lie. The test fixture registers its clip source AFTER reset() for the same reason -- reset() now discards, and registering first counted that against every case before it started. Co-Authored-By: Claude Opus 5 (1M context) --- .../invite/AppClipHandoffSource.java | 26 ++-- .../codename1/analytics/invite/Invites.java | 113 +++++++++++++++--- .../codename1/impl/ios/IOSAppClipHandoff.java | 2 +- .../analytics/invite/InviteMintTest.java | 52 ++++++++ .../invite/InviteResilienceTest.java | 32 ++++- .../analytics/invite/InviteTestSupport.java | 22 ++-- 6 files changed, 212 insertions(+), 35 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffSource.java b/CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffSource.java index 8241bbb824f..826962ee2c0 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffSource.java +++ b/CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffSource.java @@ -63,9 +63,8 @@ public interface AppClipHandoffSource { /// - `callback`: receives the answer, never null void requestHandoff(AppClipHandoffCallback callback); - /// Told that the code from the last handoff is now stored somewhere that - /// survives the process, so a source holding the only other copy may - /// discard it. + /// Told that the framework is done with the handoff, so a source holding + /// the only other copy must discard it. /// /// The iOS source hands over a value it reads out of the container it /// shares with the App Clip, and that container is the ONLY durable copy @@ -75,9 +74,20 @@ public interface AppClipHandoffSource { /// install as no_match for ever. So the read leaves the container alone /// and this is what empties it. /// - /// Called at most once per handoff, and never when the write failed: the - /// code stays where it is and the next launch reads it again, which is the - /// outcome a retry can still fix. A source with nothing to discard -- - /// anything that did not hand over its only copy -- does nothing here. - void handoffPersisted(); + /// Two things end the framework's interest, and BOTH have to empty the + /// container, which is why this is one method rather than a + /// "persisted" one: + /// + /// - the code reached durable storage, so the copy is redundant. Never + /// called while the write is still failing: the code stays where it is + /// and the next launch reads it again, which is the outcome a retry can + /// still fix. + /// - the framework is FORGETTING -- [Invites#reset] or an erasure. A + /// handoff that was never consumed is still a code naming an inviter, + /// and the container is read on launch, so one left behind re-attributes + /// the device afterwards and undoes exactly what was erased. + /// + /// A source with nothing to discard -- anything that did not hand over its + /// only copy -- does nothing here. + void discardHandoff(); } diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index d51ffc416e0..6afcb06a19c 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -35,6 +35,7 @@ import com.codename1.share.ShareResultListener; import com.codename1.ui.Display; import com.codename1.ui.geom.Rectangle; +import com.codename1.security.Hash; import com.codename1.util.Base64; import java.io.IOException; import java.io.InputStream; @@ -342,11 +343,16 @@ public static Invite create(InviteRequest request) { throw new IllegalArgumentException("request is null"); } ensureProvider(); - String code = newCode(); + String[] minted = newCode(); + String code = minted[0]; + // The proof goes to queueRegistration and NOWHERE else. It is not on + // Invite, which is public and handed to the application, and it is not + // in the url, which is the thing everybody can read. + String proof = minted[1]; long now = System.currentTimeMillis(); Invite invite = new Invite(code, buildUrl(code), request.getCampaign(), request.getChannel(), request.getPayload(), now); - if (!queueRegistration(invite, request)) { + if (!queueRegistration(invite, request, proof)) { // The outbox could not be persisted, and the invite has already // been minted -- so the choice is between sending now and losing // the registration for good. Send now: if it lands, the link is @@ -1159,6 +1165,21 @@ static boolean resetVerified() { // killed request when it reaches the front of the queue, and kills the // connection outright if it is already being sent. killQueuedRequests(); + // The App Clip's container too, and the case that needs it is the one + // where the handoff was never CONSUMED. + // + // Acknowledging on a durable write covers a code this process read. + // A code the clip left that nothing has read yet is still sitting in + // the shared container -- reset() or an erasure before the first + // checkForInvite() clears the store and leaves it there. The container + // is read on launch, so the next check finds it and attributes the + // device to exactly the inviter the erasure was asked to forget, and + // in the meantime the raw code sits on disk naming them. + // + // Unconditional, because "was it consumed?" is not knowable from here + // and the answer does not change what to do: forgetting means the copy + // goes either way. A source with nothing to discard does nothing. + discardAnyHandoff(); boolean cleared = InviteStore.delete(InviteStore.PENDING); forgetPendingFallback(); // ATTRIBUTION names the inviter, and the OUTBOX is the queued @@ -1541,23 +1562,60 @@ private static boolean explicitlyDenied() { return c != null && !c.isAnalytics(); } - private static String newCode() { + /// Mints a code and the secret that proves who minted it. + /// + /// The code is the truncated SHA-256 of a random secret, and the SECRET is + /// what registration sends. The code is public by construction -- it is in + /// the share url -- so deriving it this way is what makes minting it a + /// thing only its creator can do. + /// + /// A code used to be the random bytes themselves, on the reasoning that it + /// "identifies an invite and authorizes nothing". That is true of every + /// path except the one that CREATES the server row: an invite shared while + /// its registration is still in the offline outbox is a public code with + /// no row behind it, and the server took the first registration of an + /// unknown code as its owner. A recipient of that link, running the same + /// shipped app and holding the build key that ships inside it, could + /// register it first under their own client id -- and then own the link, + /// while the real inviter's registration was refused as a different + /// inviter. Every install and every payout on a link they were merely sent + /// went to them. + /// + /// Truncated to the length the old code had, so urls do not change shape; + /// 22 base64 characters is 132 bits, which is preimage resistance nobody + /// is going to spend. + /// + /// #### Returns + /// + /// the code at index 0, and the proof to register it with at index 1 + private static String[] newCode() { byte[] raw = new byte[16]; try { Util.secureRandomBytes(raw); } catch (Throwable t) { - // A code identifies an invite and authorizes nothing, so a weaker - // source degrades uniqueness, not security. Reported once rather - // than failing the invite. + // Weaker randomness now costs more than uniqueness -- a guessable + // secret is a forgeable proof -- but failing the mint outright + // would take the feature down on whatever platform is degraded, + // and the fallback is still a SecureRandom-seeded generator. + // Reported once rather than failing the invite. Log.e(t); FALLBACK_RANDOM.nextBytes(raw); } - String s = Base64.encodeUrlSafe(raw); - int pad = s.indexOf('='); - if (pad > 0) { - s = s.substring(0, pad); + String proof = trimPadding(Base64.encodeUrlSafe(raw)); + String code = trimPadding(Base64.encodeUrlSafe(Hash.sha256(raw))); + if (code.length() > CODE_CHARS) { + code = code.substring(0, CODE_CHARS); } - return s; + return new String[] {code, proof}; + } + + /// The code length, which is what the server truncates the digest to + /// before comparing. Both sides have to agree or no mint is ever accepted. + private static final int CODE_CHARS = 22; + + private static String trimPadding(String s) { + int pad = s.indexOf('='); + return pad > 0 ? s.substring(0, pad) : s; } private static String buildUrl(String code) { @@ -1952,6 +2010,27 @@ private static boolean writePending(Map record) { /// /// Every path that persists goes through `writePending`, so acknowledging /// here covers the retries without any of them having to remember to. + /// Tells the source to drop its copy, whatever the framework's reason. + /// + /// Separate from `ackHandoff` because the obligation flag does not apply: + /// forgetting has to reach a handoff this process never read, and there is + /// no record to check a codeSource against. + private static void discardAnyHandoff() { + AppClipHandoffSource source = appClipSource; + // Nothing is owed any more either way, so the flag goes with it -- or a + // later write of an unrelated record would ask the source to discard a + // handoff that is already gone. + handoffAwaitingAck = false; + if (source == null) { + return; + } + try { + source.discardHandoff(); + } catch (Throwable t) { + Log.e(t); + } + } + private static void ackHandoff(Map record) { if (!handoffAwaitingAck || !"app_clip".equals(record.get("codeSource"))) { return; @@ -1965,7 +2044,7 @@ private static void ackHandoff(Map record) { return; } try { - source.handoffPersisted(); + source.discardHandoff(); } catch (Throwable t) { Log.e(t); } @@ -2497,7 +2576,7 @@ public void run() { // may be this write or a later retry of it. // And the source may let go of its own copy only // once ours is durable -- which writePending() reports - // by calling handoffPersisted(), here or on whichever + // by calling discardHandoff(), here or on whichever // later retry succeeds. // // The container the clip wrote is the ONLY durable @@ -3522,9 +3601,15 @@ static void registrationEvicted(String entry) { } } - private static boolean queueRegistration(Invite invite, InviteRequest request) { + private static boolean queueRegistration(Invite invite, InviteRequest request, + String proof) { Map body = identity(); body.put("code", invite.getCode()); + // Carried in the queued body, so a registration retried days later from + // the durable outbox still proves it was this device that minted the + // code. Held nowhere else: the outbox goes with an erasure, and the + // proof goes with it. + body.put("proof", proof); putIfSet(body, "campaign", invite.getCampaign()); putIfSet(body, "channel", invite.getChannel()); putIfSet(body, "payload", invite.getPayload()); diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSAppClipHandoff.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSAppClipHandoff.java index 261fd648e0b..f0ab1a0b850 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSAppClipHandoff.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSAppClipHandoff.java @@ -111,7 +111,7 @@ public void requestHandoff(AppClipHandoffCallback callback) { /// handoff, settled an invited install as no_match for ever. Nothing /// reports that: the clip ran, the store carried the person across, and /// the install simply looks organic. - public void handoffPersisted() { + public void discardHandoff() { if (appGroup == null || appGroup.length() == 0) { return; } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteMintTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteMintTest.java index 55670504925..3eb04f160d7 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteMintTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteMintTest.java @@ -24,6 +24,8 @@ import com.codename1.analytics.AnalyticsEvent; import com.codename1.io.ConnectionRequest; +import com.codename1.security.Hash; +import com.codename1.util.Base64; import com.codename1.junit.FormTest; import com.codename1.junit.UITestBase; import java.util.HashSet; @@ -206,4 +208,54 @@ void aburstOfInvitesSendsOneRequestEach() { "a burst of " + burst + " invites did not send one request each"); } + @FormTest + void theCodeIsTheTruncatedDigestOfTheProof() { + // The CONTRACT with the server, pinned on both sides against the same + // vector. The server accepts a registration for an unknown code only + // when the proof digests to it, so a disagreement about the digest, + // the alphabet, the padding or the truncation refuses every mint -- + // safe, and not a failure anybody would enjoy diagnosing from either + // repository alone. InviteService.provesCreation has the twin of this. + byte[] secret = new byte[16]; + for (int i = 0; i < secret.length; i++) { + secret[i] = (byte) (i + 1); + } + String proof = Base64.encodeUrlSafe(secret); + String digest = Base64.encodeUrlSafe(Hash.sha256(secret)); + + assertEquals("AQIDBAUGBwgJCgsMDQ4PEA", proof, + "the proof encoding drifted from the one the server decodes"); + assertEquals("Xfur7t8xi_M8CSfEPXYw9R", digest.substring(0, 22), + "the code derivation drifted from the one the server verifies"); + } + + @FormTest + void aMintedCodeIsNotItsOwnProof() { + // The whole point: the code is public -- it is in the share url -- and + // must not be enough to register itself. Before this, an invite shared + // while its registration sat in the offline outbox could be registered + // by whoever was sent the link, and every install and payout on it + // went to them. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + + Invite invite = Invites.create(InviteRequest.create().campaign("spring").build()); + + assertEquals(22, invite.getCode().length(), + "the code length changed, which the server truncates to"); + boolean carriesProof = false; + for (ConnectionRequest r : implementation.getQueuedRequests()) { + String body = r.getRequestBody(); + if (body != null && body.contains("\"proof\"")) { + carriesProof = true; + assertFalse(body.contains("\"proof\":\"" + invite.getCode() + "\""), + "the proof is the code, so anyone holding the link can register it"); + } + } + assertTrue(carriesProof, + "the registration carried no proof, so the server cannot tell the " + + "minter from anyone who was sent the link"); + assertFalse(invite.getUrl().contains("AQIDBAUGBwgJCgsMDQ4PEA"), + "the url carries a proof"); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index ac3f23121a0..3ac4ae9f717 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -463,7 +463,7 @@ void aDurableClipHandoffIsAcknowledged() { source.answer("CLIPACK1", 1700000000L); - assertEquals(1, source.persistedCount(), + assertEquals(1, source.discardedCount(), "a durable handoff was never acknowledged"); } @@ -488,11 +488,37 @@ void aClipHandoffWhoseRecordFailedIsNotAcknowledged() { assertTrue(Invites.pendingFallbackPresentForTest(), "the record was saved after all, so this proves nothing"); - assertEquals(0, source.persistedCount(), + assertEquals(0, source.discardedCount(), "the source was told to discard the only copy of the code while the " + "write that was supposed to keep it kept failing"); } + @FormTest + void forgettingDiscardsAHandoffNothingHasReadYet() { + // The acknowledgement paths all cover a code THIS process read. A code + // the clip left that nothing has consumed is still in the shared + // container, and reset() -- or an erasure -- used to clear the store + // and leave it there. + // + // The container is read on launch, so the next check finds it and + // attributes the device to exactly the inviter that was erased; until + // then the raw code sits on disk naming them. Forgetting has to reach + // it, which means asking the source without any record to go on. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + InviteTestSupport.PendingHandoffSource source = InviteTestSupport.pendingHandoff; + assertEquals(0, source.discardedCount(), + "the fixture starts from a discard, so the count below proves nothing"); + + // No checkForInvite(): nothing has read the handoff, which is the case. + Invites.reset(); + + assertEquals(1, source.discardedCount(), + "an unconsumed handoff survived the reset, so the clip's container " + + "still holds a code naming the inviter and the next launch " + + "re-attributes the device to them"); + } + @FormTest void aClipHandoffAcknowledgedWhenTheRETRYPersistsIt() { // The other end of the same obligation. A failed write leaves the @@ -518,7 +544,7 @@ void aClipHandoffAcknowledgedWhenTheRETRYPersistsIt() { assertFalse(Invites.pendingFallbackPresentForTest(), "the retry did not persist the held record, so this proves nothing"); - assertEquals(1, source.persistedCount(), + assertEquals(1, source.discardedCount(), "the record became durable through the retry and the clip was never " + "told, so its container keeps the code and a launch after an " + "erasure restores the attribution that was erased"); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java index ff266b4e6d5..0d084f47457 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java @@ -55,16 +55,16 @@ public void requestHandoff(AppClipHandoffCallback cb) { callback = cb; } - /** Counts the acknowledgements, which is what the iOS source clears on. */ - private int persisted; + /** Counts the discards, which is what the iOS source clears on. */ + private int discarded; - public void handoffPersisted() { - persisted++; + public void discardHandoff() { + discarded++; } - /** How many handoffs the framework reported as durably stored. */ - int persistedCount() { - return persisted; + /** How many times the framework said it was done with the handoff. */ + int discardedCount() { + return discarded; } /** True once Invites has asked. */ @@ -113,8 +113,6 @@ static RecordingProvider freshInstall() { // the instant it starts -- correct on a device with no App Clip, and // useless for testing anything that happens while one is in flight. // A case that wants an answer installs its own. - pendingHandoff = new PendingHandoffSource(); - Invites.registerAppClipHandoffSource(pendingHandoff); Invites.lookupRetryDelay = 30000L; // Any unspent write failure a previous case armed is disarmed here. // It used to disarm itself, because one failure consumed it; a case @@ -123,6 +121,12 @@ static RecordingProvider freshInstall() { // way to fail. InviteStore.failWritesForTest(null, 0); Invites.reset(); + // Registered AFTER the reset, which now tells the source to discard + // whatever the clip left -- forgetting has to reach a handoff nothing + // has read yet. Registering first counted that discard against the + // fixture and made every case start from one. + pendingHandoff = new PendingHandoffSource(); + Invites.registerAppClipHandoffSource(pendingHandoff); Preferences.delete(Invites.PREF_SLUG); Preferences.delete(Invites.PREF_CONSUMED_ARG); // reset() clears the records; clearProviders() above dropped the From 7aff67edd75ed9e299729eab52627892a438d51a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:37:37 +0300 Subject: [PATCH 77/99] Invites: the share guard would have killed the button on Android Keying it on an outstanding share was wrong, and the justification I wrote for it was wrong too. Display.share() documents that the listener always runs -- and on Android the API 22+ chooser callback deliberately does not: "Android does not expose a dismissal signal for the chooser, so the listener simply does not fire on user-cancel" (AndroidImplementation.buildShareChooserWithCallback). Every user who opens the sheet and backs out reports nothing, so the guard would have been left set with nothing able to clear it and the button would never share again until the form was rebuilt. That is a far worse bug than the double presentation it was closing. The guard now lasts exactly as long as the thing it guards against. The defect is two presses in ONE EDT cycle, because ShareButton defers by a cycle and then shares unconditionally; a flag cleared by a runnable queued behind ShareButton's own covers precisely that, and nothing about the sheet, the platform or the user's answer can hold it. The outstanding invite keeps its own job -- one code per share, reused by a press that arrives before the first is answered -- and is no longer allowed to block a press. A share that is cancelled and retried reuses the code, which is right: it was never shared. Probe: with the guard keyed on the outstanding share again, the cancellation test reports the button presenting once and never again. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/components/InviteButton.java | 52 ++++++++++++----- .../components/InviteButtonMintTest.java | 58 +++++++++++++++++++ 2 files changed, 97 insertions(+), 13 deletions(-) diff --git a/CodenameOne/src/com/codename1/components/InviteButton.java b/CodenameOne/src/com/codename1/components/InviteButton.java index d6860312c25..3d31dd3daae 100644 --- a/CodenameOne/src/com/codename1/components/InviteButton.java +++ b/CodenameOne/src/com/codename1/components/InviteButton.java @@ -26,6 +26,7 @@ import com.codename1.analytics.invite.InviteRequest; import com.codename1.analytics.invite.Invites; import com.codename1.share.ShareResultListener; +import com.codename1.ui.Display; import com.codename1.ui.FontImage; import com.codename1.ui.events.ActionEvent; @@ -66,6 +67,11 @@ public class InviteButton extends ShareButton { // is overridden to answer with the application's listener, so the chain is // otherwise unreachable from outside a real press. ShareResultListener chain; + // True from a press until the next EDT cycle, which is when ShareButton's + // deferred runnable has already presented. Its ONLY job is to collapse + // presses that arrive before that; it is never a "share in progress" + // flag, because nothing guarantees a share ever reports. + private boolean presenting; /// Default constructor. public InviteButton() { @@ -209,27 +215,47 @@ public ShareResultListener getShareResultListener() { /// {@inheritDoc} @Override public void actionPerformed(ActionEvent evt) { - // A press while a share is still outstanding does NOTHING, rather than - // reusing the code and presenting a second time. + // A press is dropped only while ANOTHER PRESS IS STILL ON ITS WAY to + // the share sheet -- not for as long as a share is outstanding. // // ShareButton defers to the next EDT cycle and then shares // unconditionally, so two presses within one cycle enqueue two - // presentations. Reusing the invite made both carry the same code, - // which was the point, but it left the rest: two native sheets - // attempted, the application's result listener called twice, and -- - // because the first result takes `outstanding` -- the second share - // reported to nobody. A real share missing from the funnel is the - // worst of those, and it is the one the reuse introduced. + // presentations: two native sheets attempted, the application's + // listener called twice, and -- because the first result takes + // `outstanding` -- the second share reported to nobody. // - // Safe to swallow the press because the outcome always arrives: - // Display.share() documents that the listener is invoked even where - // the platform cannot report a result, with a null package name, so - // `outstanding` cannot be left set by a share that never answers. - if (outstanding != null) { + // Keying that on `outstanding` instead would have been a far worse + // bug than the one it fixed. Display.share() documents that the + // listener always runs, but on Android the API 22+ chooser callback + // deliberately does not: "Android does not expose a dismissal signal + // for the chooser, so the listener simply does not fire on user-cancel" + // (AndroidImplementation.buildShareChooserWithCallback). A user who + // opens the sheet and backs out would leave `outstanding` set with + // nothing to clear it, and the button would never share again until + // the form was rebuilt. + // + // This flag cannot do that: it is cleared on the next EDT cycle + // whatever happens, by a runnable queued behind the one ShareButton + // itself queues. Nothing about the sheet, the platform or the user's + // answer can hold it. + if (presenting) { return; } + presenting = true; mintForShare(); presentShare(evt); + Display d = Display.getInstance(); + if (d == null) { + // No EDT to clear it on, so it was never set. + presenting = false; + return; + } + d.callSerially(new Runnable() { + @Override + public void run() { + presenting = false; + } + }); } /// Hands the press to [ShareButton], which presents the sheet. diff --git a/maven/core-unittests/src/test/java/com/codename1/components/InviteButtonMintTest.java b/maven/core-unittests/src/test/java/com/codename1/components/InviteButtonMintTest.java index 0bef6dc4305..73c9a2b36e5 100644 --- a/maven/core-unittests/src/test/java/com/codename1/components/InviteButtonMintTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/components/InviteButtonMintTest.java @@ -151,6 +151,61 @@ void presentShare(ActionEvent evt) { + "reported to nobody"); } + @FormTest + void aShareThatNeverReportsDoesNotKillTheButton() { + // The guard must not be keyed on an outstanding share. Display.share() + // documents that the listener always runs, and on Android it does not: + // the API 22+ chooser callback fires only when a target is picked, + // because "Android does not expose a dismissal signal for the chooser" + // (AndroidImplementation.buildShareChooserWithCallback). A user who + // opens the sheet and backs out reports NOTHING, and a guard waiting + // for that report would leave the button dead until the form was + // rebuilt -- a far worse bug than the double presentation it fixes. + Invites.reset(); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).build()); + final int[] presented = new int[1]; + InviteButton button = new InviteButton("Invite a friend") { + @Override + void presentShare(ActionEvent evt) { + presented[0]++; + } + }; + + button.actionPerformed(new ActionEvent(button)); + // The cancellation: no result, ever. Only an EDT cycle passes. + pumpEdt(); + button.actionPerformed(new ActionEvent(button)); + + assertEquals(2, presented[0], + "a cancelled share left the button unable to share again, which on " + + "Android is every user who opens the sheet and backs out"); + } + + /// Lets the runnables a press queued run, which is what the next EDT cycle + /// does on a device. + private static void pumpEdt() { + final boolean[] done = new boolean[1]; + com.codename1.ui.Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + done[0] = true; + } + }); + for (int i = 0; i < 50 && !done[0]; i++) { + com.codename1.ui.Display.getInstance().invokeAndBlock(new Runnable() { + @Override + public void run() { + try { + Thread.sleep(5); + } catch (InterruptedException e) { + // nothing to do + } + } + }); + } + } + @FormTest void aPressAfterTheSheetAnsweredWorksAgain() { // The guard must not be a latch: swallowing every later press would @@ -172,6 +227,9 @@ void presentShare(ActionEvent evt) { button.actionPerformed(new ActionEvent(button)); Invite first = button.getInvite(); button.chain.onResult(ShareResult.sharedTo("com.example.chat")); + // A result cannot arrive in the cycle that presented the sheet, so the + // press that follows one is always in a later cycle. + pumpEdt(); button.actionPerformed(new ActionEvent(button)); Invite second = button.getInvite(); From 8985e5bc8bce9d587a7024a7e3f5e6ee8a65e7da Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 01:52:15 +0300 Subject: [PATCH 78/99] Invites: bound what reaches the outbox, and document what was promised Every field an invite carries was bounded except the two that mattered. The registration json is persisted in the outbox before anything is sent and before any server sees it, and the outbox caps its ENTRY COUNT -- which bounds nothing if one entry can be any size. An unbounded image address, or a parameter map an application filled in a loop, went straight to storage. Refused at build(), where payload, title and description are already refused, so it arrives at the call that built the request rather than as a storage failure days later. setLinkBase() says what it does not do, and now checks. It changes where links are MINTED and nothing else: the Android intent filter and the iOS associated-domain entitlement are written at build time from invite.domain, so a host set only at runtime is a host the installed app does not claim, and every link opens the browser with nothing reporting an error. The builders stamp the registered host into the app, so the two can simply be compared -- logged once rather than refused, because pointing at a staging service and accepting the browser is a legitimate thing to do. ios.invite.appClip documents the handoff it told developers to write. It said a clip of their own would be picked up if it wrote "the documented handoff", and nothing anywhere described it: not the key, not the fields, not that the suite has to be flushed before the clip dies, and not that the clip must NOT clear its own copy. A developer following that hint had to read the native framework source, and every install from a clip that got it wrong is reported as organic. Co-Authored-By: Claude Opus 5 (1M context) --- .../analytics/invite/InviteRequest.java | 35 ++++++++++++ .../codename1/analytics/invite/Invites.java | 51 ++++++++++++++++++ .../codename1/build/shared/BuildHintsIos.java | 26 ++++++++- .../analytics/invite/InviteMintTest.java | 54 +++++++++++++++++++ 4 files changed, 164 insertions(+), 2 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteRequest.java b/CodenameOne/src/com/codename1/analytics/invite/InviteRequest.java index 0a10364c46d..67fd64e62a4 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteRequest.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteRequest.java @@ -58,6 +58,18 @@ public final class InviteRequest { /// The longest accepted [Builder#campaign] or [Builder#channel]. public static final int MAX_TOKEN_LENGTH = 64; + /// The longest accepted preview image address. + public static final int MAX_IMAGE_URL_LENGTH = 512; + + /// The longest accepted custom parameter name. + public static final int MAX_PARAM_KEY_LENGTH = 64; + + /// The longest accepted custom parameter value. + public static final int MAX_PARAM_VALUE_LENGTH = 256; + + /// The most custom parameters an invite may carry. + public static final int MAX_PARAMETERS = 16; + private final String campaign; private final String channel; private final String payload; @@ -285,6 +297,29 @@ public InviteRequest build() { checkLength("payload", payload, MAX_PAYLOAD_LENGTH); checkLength("title", title, MAX_TITLE_LENGTH); checkLength("description", description, MAX_DESCRIPTION_LENGTH); + // Bounded HERE, with everything else, because these two were the + // way past every other bound. + // + // The request is serialized into the registration json and that + // json is persisted in the outbox, before anything has been sent + // and before any server has seen it. The outbox caps its ENTRY + // COUNT, which bounds nothing if one entry can be any size: an + // unbounded image address, or a parameter map an application + // filled in a loop, is written straight to storage. Refusing is + // the same answer the payload, title and description already give, + // and it arrives at the call that built the request rather than as + // a storage failure days later. + checkLength("imageUrl", imageUrl, MAX_IMAGE_URL_LENGTH); + if (parameters.size() > MAX_PARAMETERS) { + throw new IllegalArgumentException( + "an invite carries at most " + MAX_PARAMETERS + " parameters"); + } + for (java.util.Iterator> it = + parameters.entrySet().iterator(); it.hasNext();) { + java.util.Map.Entry e = it.next(); + checkLength("parameter name", e.getKey(), MAX_PARAM_KEY_LENGTH); + checkLength("parameter " + e.getKey(), e.getValue(), MAX_PARAM_VALUE_LENGTH); + } return new InviteRequest(this); } diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 6afcb06a19c..817d1014b76 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -896,13 +896,64 @@ public static void conversion(String action, double value, String currency) { /// the Codename One cloud, honouring the `cloudServerURL` display /// property. /// + /// **Set the `invite.domain` build hint to the same host.** This changes + /// where links are MINTED and nothing else. The Android intent filter and + /// the iOS associated-domain entitlement are written at BUILD time from + /// that hint, so a host set only here is a host the installed app does not + /// claim: every invite link opens the browser instead of the app, and + /// neither the OS nor the framework reports anything. A mismatch is logged + /// once, because it cannot be refused -- pointing at a staging service and + /// accepting the browser is a legitimate thing to do. + /// /// #### Parameters /// /// - `url`: the base address, with no trailing path public static void setLinkBase(String url) { linkBase = url; + warnIfNotTheRegisteredHost(url); + } + + /// Says so when links will be minted for a host the build did not register. + /// + /// The builders stamp the host they registered into the app, which is what + /// `getLinkBase()` prefers, so the two can simply be compared. Reported + /// rather than refused, and reported once: an application that sets this + /// on every start should not fill the log. + private static void warnIfNotTheRegisteredHost(String url) { + if (url == null || url.length() == 0 || linkBaseWarned) { + return; + } + Display d = Display.getInstance(); + String registered = d == null ? null : d.getProperty(PROPERTY_DOMAIN, null); + if (registered == null || registered.length() == 0) { + // Nothing was registered, so there is nothing to disagree with -- + // the default host is in the filter and the entitlement. + registered = DEFAULT_BASE_URL; + } + // hostOf() wants a scheme and the registered value may be a bare + // host, which is exactly what getLinkBase() compensates for when it + // reads the same property. + String a = hostOf(withScheme(url)); + String b = hostOf(withScheme(registered)); + if (a == null || b == null || a.equalsIgnoreCase(b)) { + return; + } + linkBaseWarned = true; + Log.p("Invites.setLinkBase(" + a + ") does not match the host this build " + + "registered (" + b + "). Links will be minted for " + a + ", but the " + + "Android intent filter and the iOS associated domains name " + b + ", so " + + "an installed app will NOT open its own invite links. Set the " + + "invite.domain build hint to " + a + " as well."); } + /// A bare host is what the build hint usually carries; hostOf() needs a + /// scheme to find one. + private static String withScheme(String url) { + return url == null || url.indexOf("://") >= 0 ? url : "https://" + url; + } + + private static boolean linkBaseWarned; + /// The link service base address in use. /// /// #### Returns diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java index 4e77b598fd5..4445633cc9a 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java @@ -232,11 +232,33 @@ static void register(List h) { + "Set it to `false` only if you ship an App Clip of your own: the " + "build then generates no clip, but the app still carries the shared " + "app group and the reader, so a clip of yours that writes the " - + "documented handoff is still picked up. This is the ONLY hint that " + + "handoff below is still picked up. This is the ONLY hint that " + "suppresses the clip: `ios.invite.universalLinks=false` means you " + "manage the associated domains yourself and leaves the clip, the " + "shared app group and the reader in place, because an app that " - + "configured its own domains correctly still needs them.")); + + "configured its own domains correctly still needs them.\n" + + "\n" + + "THE HANDOFF, for a clip of your own. Write one entry into the " + + "`NSUserDefaults` suite named by `ios.invite.appGroup`, under the key " + + "`cn1-invite-app-clip-handoff`. The value is a dictionary with " + + "`code`, a string holding the invite code taken from the last path " + + "segment of the invite url, and `clicked`, a number holding the tap " + + "time as whole seconds since the epoch. `clicked` may be omitted, and " + + "the App Clip is the only thing that ever observes that time -- the " + + "invocation never reaches our redirect -- so a clip that drops it " + + "leaves every attribution dated zero. A code containing a newline is " + + "rejected on the way in.\n" + + "\n" + + "Then call `synchronize` on the suite before returning. A clip is " + + "killed without notice the moment the App Store sheet takes over, and " + + "the write IS the attribution -- there is no second chance to make " + + "it.\n" + + "\n" + + "Do NOT clear the entry after writing it. The installed app reads it, " + + "keeps it until its own record is durable, and clears it then; a clip " + + "that clears its own copy destroys the code whenever that write fails " + + "or the process exits in between, and the install is reported as " + + "organic for ever.")); h.add(new Hint("ios.invite.appGroup") .group(HintGroup.IOS) diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteMintTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteMintTest.java index 3eb04f160d7..eb4a9e44260 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteMintTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteMintTest.java @@ -37,6 +37,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.api.Assertions.assertTrue; class InviteMintTest extends UITestBase { @@ -258,4 +259,57 @@ void aMintedCodeIsNotItsOwnProof() { assertFalse(invite.getUrl().contains("AQIDBAUGBwgJCgsMDQ4PEA"), "the url carries a proof"); } + + @FormTest + void anInviteCannotCarryUnboundedFieldsIntoTheOutbox() { + // The registration json is persisted in the outbox BEFORE anything is + // sent and before any server sees it. The outbox caps its entry COUNT, + // which bounds nothing if one entry can be any size -- so an + // unbounded image address, or a parameter map built in a loop, went + // straight to storage. Payload, title and description were already + // refused at build(); these two were the way past all of them. + StringBuilder huge = new StringBuilder("https://example.com/"); + for (int i = 0; i < InviteRequest.MAX_IMAGE_URL_LENGTH; i++) { + huge.append('x'); + } + try { + InviteRequest.create().imageUrl(huge.toString()).build(); + fail("an image address longer than the limit was accepted and would be " + + "written to storage"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("imageUrl"), expected.getMessage()); + } + + InviteRequest.Builder many = InviteRequest.create(); + for (int i = 0; i <= InviteRequest.MAX_PARAMETERS; i++) { + many.param("k" + i, "v"); + } + try { + many.build(); + fail("an unbounded parameter map was accepted"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("parameters"), expected.getMessage()); + } + + StringBuilder bigValue = new StringBuilder(); + for (int i = 0; i <= InviteRequest.MAX_PARAM_VALUE_LENGTH; i++) { + bigValue.append('y'); + } + try { + InviteRequest.create().param("note", bigValue.toString()).build(); + fail("an unbounded parameter value was accepted"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("note"), expected.getMessage()); + } + } + + @FormTest + void anInviteWithinTheLimitsIsStillAccepted() { + // The bound must not refuse the ordinary case it exists to cap. + InviteRequest r = InviteRequest.create() + .imageUrl("https://example.com/preview.png") + .param("tier", "gold") + .build(); + assertNotNull(r, "an ordinary invite was refused by the new bounds"); + } } From 5b4d7455f1759cb98e61e6cc9a2e10c841b7694e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 02:01:43 +0300 Subject: [PATCH 79/99] Invites: an erasure that could not empty the clip reported success Every store deletion in resetVerified() is gated on its result; the App Clip container was not. discardHandoff() returned nothing, and the native side threw away what it knew -- so a container that refused to empty, or a flush that never reached the disk, let reset() report success with an exact code still on the device. The next launch reads that container and re-attributes from it, which is the one thing an erasure promises will not happen. The discard is now verifiable and the reset is gated on it. The native side READS THE KEY BACK after the flush rather than trusting it: synchronize's own answer says whether a write-out happened, and the question here is whether the entry is gone. Failing is the right outcome rather than the tidy one -- reset() latches and retries, so the erasure is attempted again, and nothing tells the user their attribution is gone while it is still on the device. The obligation flag follows the same rule: it is cleared only when the copy really went, so a later durable write asks again instead of assuming. Native signature: clearAppClipInviteHandoff returns boolean now, so the symbol gains its _R_boolean suffix in both the real and the stub branch. A wrong name here compiles, links and ships the feature inert, so it is checked rather than eyeballed -- the iOS port was rebuilt and check-native-signatures.sh reports no MISSING or SIGNATURE finding. Co-Authored-By: Claude Opus 5 (1M context) --- .../invite/AppClipHandoffSource.java | 12 +++++-- .../codename1/analytics/invite/Invites.java | 33 +++++++++++++------ .../iOSPort/nativeSources/CN1InviteAppClip.m | 16 +++++++-- .../codename1/impl/ios/IOSAppClipHandoff.java | 17 ++++++---- .../src/com/codename1/impl/ios/IOSNative.java | 2 +- .../invite/InviteResilienceTest.java | 26 +++++++++++++++ .../analytics/invite/InviteTestSupport.java | 7 +++- 7 files changed, 89 insertions(+), 24 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffSource.java b/CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffSource.java index 826962ee2c0..fb498dfdf90 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffSource.java +++ b/CodenameOne/src/com/codename1/analytics/invite/AppClipHandoffSource.java @@ -88,6 +88,14 @@ public interface AppClipHandoffSource { /// the device afterwards and undoes exactly what was erased. /// /// A source with nothing to discard -- anything that did not hand over its - /// only copy -- does nothing here. - void discardHandoff(); + /// only copy -- answers true here without doing anything. + /// + /// #### Returns + /// + /// true when no handoff is left on the device. An erasure is REFUSED on + /// false: the container is read on launch, so a copy that survives is an + /// exact code naming an inviter that the next launch re-attributes from, + /// and reporting an erasure that did not happen is worse than failing one + /// that can be retried. + boolean discardHandoff(); } diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 817d1014b76..4af35c751dc 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1229,9 +1229,16 @@ static boolean resetVerified() { // // Unconditional, because "was it consumed?" is not knowable from here // and the answer does not change what to do: forgetting means the copy - // goes either way. A source with nothing to discard does nothing. - discardAnyHandoff(); - boolean cleared = InviteStore.delete(InviteStore.PENDING); + // goes either way. A source with nothing to discard answers true. + // + // GATED like every store deletion beside it. The result used to be + // dropped, so a container that refused to empty -- or a flush that did + // not reach the disk -- let the erasure report success with an exact + // code still on the device, which the next launch reads and + // re-attributes from. That is the one failure this method exists to + // refuse to hide. + boolean cleared = discardAnyHandoff(); + cleared &= InviteStore.delete(InviteStore.PENDING); forgetPendingFallback(); // ATTRIBUTION names the inviter, and the OUTBOX is the queued // registration JSON -- which carries the OLD client id along with the @@ -2066,20 +2073,26 @@ private static boolean writePending(Map record) { /// Separate from `ackHandoff` because the obligation flag does not apply: /// forgetting has to reach a handoff this process never read, and there is /// no record to check a codeSource against. - private static void discardAnyHandoff() { + private static boolean discardAnyHandoff() { AppClipHandoffSource source = appClipSource; - // Nothing is owed any more either way, so the flag goes with it -- or a - // later write of an unrelated record would ask the source to discard a - // handoff that is already gone. - handoffAwaitingAck = false; if (source == null) { - return; + handoffAwaitingAck = false; + return true; } + boolean gone; try { - source.discardHandoff(); + gone = source.discardHandoff(); } catch (Throwable t) { Log.e(t); + gone = false; + } + // The obligation is cleared only when the copy really went. A handoff + // still sitting in the container is still owed to somebody, and a + // later durable write should ask again rather than assume. + if (gone) { + handoffAwaitingAck = false; } + return gone; } private static void ackHandoff(Map record) { diff --git a/Ports/iOSPort/nativeSources/CN1InviteAppClip.m b/Ports/iOSPort/nativeSources/CN1InviteAppClip.m index 3a170373259..168da5838b7 100644 --- a/Ports/iOSPort/nativeSources/CN1InviteAppClip.m +++ b/Ports/iOSPort/nativeSources/CN1InviteAppClip.m @@ -131,11 +131,12 @@ JAVA_OBJECT com_codename1_impl_ios_IOSNative_readAppClipInviteHandoff___java_lan return fromNSString(CN1_THREAD_STATE_PASS_ARG joined); } -void com_codename1_impl_ios_IOSNative_clearAppClipInviteHandoff___java_lang_String( +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_clearAppClipInviteHandoff___java_lang_String_R_boolean( CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT groupObj) { NSUserDefaults *suite = cn1InviteSuite(toNSString(CN1_THREAD_STATE_PASS_ARG groupObj)); if (suite == nil) { - return; + // No container, so there is nothing left holding a code. + return JAVA_TRUE; } [suite removeObjectForKey:kCN1InviteHandoffKey]; // Flushed before returning, exactly as the generated clip flushes the @@ -155,6 +156,14 @@ void com_codename1_impl_ios_IOSNative_clearAppClipInviteHandoff___java_lang_Stri // asymmetry has no justification, and the cost here is one flush on a path // that runs once per install. [suite synchronize]; + // READ BACK, rather than trusting the flush. + // + // The caller gates an erasure on this, and synchronize's own BOOL says + // whether a write-out happened, not whether the key is gone. What the + // erasure needs to know is exactly that, so it is asked directly: if the + // entry survives, the code is still on the device and the reset that + // promised to forget it has not. + return [suite objectForKey:kCN1InviteHandoffKey] == nil ? JAVA_TRUE : JAVA_FALSE; } #else @@ -179,8 +188,9 @@ JAVA_OBJECT com_codename1_impl_ios_IOSNative_readAppClipInviteHandoff___java_lan return JAVA_NULL; } -void com_codename1_impl_ios_IOSNative_clearAppClipInviteHandoff___java_lang_String( +JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_clearAppClipInviteHandoff___java_lang_String_R_boolean( CN1_THREAD_STATE_MULTI_ARG JAVA_OBJECT me, JAVA_OBJECT groupObj) { + return JAVA_TRUE; } #endif // CN1_INCLUDE_INVITE_APPCLIP diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSAppClipHandoff.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSAppClipHandoff.java index f0ab1a0b850..a1b27c60267 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSAppClipHandoff.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSAppClipHandoff.java @@ -111,18 +111,21 @@ public void requestHandoff(AppClipHandoffCallback callback) { /// handoff, settled an invited install as no_match for ever. Nothing /// reports that: the clip ran, the store carried the person across, and /// the install simply looks organic. - public void discardHandoff() { + public boolean discardHandoff() { if (appGroup == null || appGroup.length() == 0) { - return; + // No group, so no container, so nothing is holding a code. + return true; } try { - IOSImplementation.nativeInstance.clearAppClipInviteHandoff(appGroup); + return IOSImplementation.nativeInstance.clearAppClipInviteHandoff(appGroup); } catch (Throwable t) { - // Worth nothing more than a log: the code is stored, so the only - // cost of a container that could not be emptied is the next launch - // reading the same handoff again -- and the framework already - // refuses a second attribution for one install. + // Reported as NOT discarded, because the caller may be an erasure. + // A container that could not be emptied still holds an exact code + // naming an inviter, and it is read on the next launch -- so the + // honest answer is that the handoff is still there, whatever the + // reason. Log.e(t); + return false; } } diff --git a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java index 715cde4ea28..d63f6353059 100644 --- a/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java +++ b/Ports/iOSPort/src/com/codename1/impl/ios/IOSNative.java @@ -2575,6 +2575,6 @@ native void nearbySendPayload(int requestId, String joinedEndpointIds, * * @param appGroup the group identifier */ - native void clearAppClipInviteHandoff(String appGroup); + native boolean clearAppClipInviteHandoff(String appGroup); } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 3ac4ae9f717..b7371c376b2 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -493,6 +493,32 @@ void aClipHandoffWhoseRecordFailedIsNotAcknowledged() { + "write that was supposed to keep it kept failing"); } + @FormTest + void aHandoffThatWillNotGoFailsTheReset() { + // Every store deletion in resetVerified() is gated; the clip container + // was not, so a container that refused to empty -- or a flush that did + // not reach the disk -- let reset() report success with an exact code + // still on the device. The next launch reads it and re-attributes, + // which is precisely what the erasure promised would not happen. + // + // Failing is the right answer rather than the tidy one: reset() + // latches and retries, so the code is tried again, and nothing tells + // the user their attribution is gone while it is not. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + InviteTestSupport.PendingHandoffSource source = InviteTestSupport.pendingHandoff; + source.discardFails = true; + + assertFalse(Invites.resetVerified(), + "an erasure reported success while the App Clip container still held " + + "the code, so the next launch restores the attribution it " + + "promised to forget"); + + source.discardFails = false; + assertTrue(Invites.resetVerified(), + "the erasure kept failing once the container could be emptied"); + } + @FormTest void forgettingDiscardsAHandoffNothingHasReadYet() { // The acknowledgement paths all cover a code THIS process read. A code diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java index 0d084f47457..e1b253842a0 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java @@ -58,8 +58,13 @@ public void requestHandoff(AppClipHandoffCallback cb) { /** Counts the discards, which is what the iOS source clears on. */ private int discarded; - public void discardHandoff() { + /// Set by a test that wants the container to refuse to empty, which + /// is what an erasure has to notice. + boolean discardFails; + + public boolean discardHandoff() { discarded++; + return !discardFails; } /** How many times the framework said it was done with the handoff. */ From ca0d989209568803efacde60dad7da6ff7dcd25c Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 02:12:02 +0300 Subject: [PATCH 80/99] Invites: the App Clip handoff docs failed the developer guide prose gate The hint text I added is rendered into _generated-build-hints.adoc, which vale lints as part of the guide, and it broke the build on four alerts: "our redirect" (first person), "Do NOT" (wants the contraction), "in between" (wordiness) and "for ever" (archaic). Prose that only ever lived in code comments before, now published. Verified by running vale over the regenerated page rather than by reading it: clean at exit 0, and -- because an empty JSON report cannot be told from linting no files at all -- proved to bite by putting "Do NOT" back and watching it fail. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/com/codename1/build/shared/BuildHintsIos.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java index 4445633cc9a..4346828a792 100644 --- a/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java +++ b/maven/build-hint-catalog/src/main/java/com/codename1/build/shared/BuildHintsIos.java @@ -245,7 +245,7 @@ static void register(List h) { + "segment of the invite url, and `clicked`, a number holding the tap " + "time as whole seconds since the epoch. `clicked` may be omitted, and " + "the App Clip is the only thing that ever observes that time -- the " - + "invocation never reaches our redirect -- so a clip that drops it " + + "invocation never reaches the redirect -- so a clip that drops it " + "leaves every attribution dated zero. A code containing a newline is " + "rejected on the way in.\n" + "\n" @@ -254,11 +254,11 @@ static void register(List h) { + "the write IS the attribution -- there is no second chance to make " + "it.\n" + "\n" - + "Do NOT clear the entry after writing it. The installed app reads it, " + + "Don't clear the entry after writing it. The installed app reads it, " + "keeps it until its own record is durable, and clears it then; a clip " + "that clears its own copy destroys the code whenever that write fails " - + "or the process exits in between, and the install is reported as " - + "organic for ever.")); + + "or the process exits first, and the install is then reported as " + + "organic permanently.")); h.add(new Hint("ios.invite.appGroup") .group(HintGroup.IOS) From 42ddf86712540f0de0eb6cb7c8c626c8e48223b9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 02:17:28 +0300 Subject: [PATCH 81/99] Invites: the erasure check I added proved nothing, and a useless answer burned the budget The handoff verification read its own mutation. removeObjectForKey: has already changed that NSUserDefaults instance's in-memory view, so reading the key back answered nil whether or not anything reached the disk: the check passed by construction and the erasure it gates was never actually verified. It now requires BOTH synchronize's result -- the half that knows about the disk -- and the key being absent, because a successful flush of the wrong thing is not the claim either. Every response now re-stamps the in-flight time instead of clearing it. Clearing said "nothing is on the wire", which is true, and lookupInFlight() read it as "ask again whenever you like". That is harmless for an answer that settles something, since nothing asks again -- and wrong for the several that reach a plain return without settling anything: an empty body, JSON that will not parse, a resolved answer carrying no code. The lookup stayed pending with nothing throttling it, so each later checkForInvite() re-issued at once and spent another of the five durable attempts, settling an exact referrer or App Clip code as no_match in seconds. The terminal paths are gated on the state rather than on this field, and every reset and erasure clears it outright. Probe: with the stamp cleared again, the new test sees a request queued where there should be none. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 42 +++++++++++++------ .../iOSPort/nativeSources/CN1InviteAppClip.m | 23 ++++++---- .../invite/InviteResilienceTest.java | 39 +++++++++++++++++ 3 files changed, 84 insertions(+), 20 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 4af35c751dc..82253c73ea8 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -2941,7 +2941,28 @@ static void handleResolution(String payload, String matchType, boolean deferred) static void handleResolution(String payload, String matchType, boolean deferred, int epoch) { if (epoch == lookupEpoch) { - lookupIssuedAt = 0; + // RE-STAMPED, not cleared, because an answer arriving is not the + // same as a question being settled. + // + // Clearing it said "nothing is on the wire", which is true, and + // was read by lookupInFlight() as "ask again whenever you like". + // For every response that settles something that is harmless -- + // the state is terminal and nothing asks again. For one that does + // NOT, and there are several that reach a plain return below + // without settling anything -- an empty body, JSON that will not + // parse, a resolved answer carrying no code -- it left the lookup + // pending with nothing to throttle it. Each later + // checkForInvite() then re-issued immediately and burned another + // of the five durable attempts, so a couple of lifecycle calls + // could settle an exact referrer or App Clip code as no_match in + // seconds. + // + // The retry interval is measured from this field, so recording the + // completed attempt is what makes it apply to a useless answer as + // well as to silence. The terminal paths do not care: they are + // gated on the state, not on this, and every reset and erasure + // clears it outright. + lookupIssuedAt = System.currentTimeMillis(); } // A response that was already on the wire when consent was withdrawn or // the identity was erased must not be acted on. Both of those delete the @@ -2991,19 +3012,16 @@ static void handleResolution(String payload, String matchType, boolean deferred, // existing attempt cap and attribution window bound how // long this can go on. // - // The attempt is STAMPED rather than cleared. Every - // response clears lookupIssuedAt above, which is right for - // an answer that settles something -- nothing will ask - // again -- and wrong for this one: the state stays pending, - // so resumeDeferred() re-issues on the next - // checkForInvite(), and with no timestamp to throttle it an - // application that calls that from two places would spend + // Stamped explicitly, although the entry to this method + // now stamps every response for the same reason. Kept + // because this is the path where it matters most and + // where the reasoning is easiest to lose: the state stays + // pending, so resumeDeferred() re-issues on the next + // checkForInvite(), and with no timestamp to throttle it + // an application calling that from two places would spend // all five attempts in seconds and settle an // offline-minted invite as no_match before its - // registration ever arrived. This is the field the retry - // interval is measured from, so recording the completed - // attempt is what makes the interval apply to "not yet" - // as well as to silence. + // registration ever arrived. lookupIssuedAt = System.currentTimeMillis(); setState(STATE_PENDING); return; diff --git a/Ports/iOSPort/nativeSources/CN1InviteAppClip.m b/Ports/iOSPort/nativeSources/CN1InviteAppClip.m index 168da5838b7..0b08bbc27f6 100644 --- a/Ports/iOSPort/nativeSources/CN1InviteAppClip.m +++ b/Ports/iOSPort/nativeSources/CN1InviteAppClip.m @@ -155,15 +155,22 @@ JAVA_BOOLEAN com_codename1_impl_ios_IOSNative_clearAppClipInviteHandoff___java_l // calls synchronize for that reason; the full app is longer-lived but the // asymmetry has no justification, and the cost here is one flush on a path // that runs once per install. - [suite synchronize]; - // READ BACK, rather than trusting the flush. + // BOTH the flush and the key, because neither alone is evidence. // - // The caller gates an erasure on this, and synchronize's own BOOL says - // whether a write-out happened, not whether the key is gone. What the - // erasure needs to know is exactly that, so it is asked directly: if the - // entry survives, the code is still on the device and the reset that - // promised to forget it has not. - return [suite objectForKey:kCN1InviteHandoffKey] == nil ? JAVA_TRUE : JAVA_FALSE; + // Reading the key back is not enough on its own, and an earlier version of + // this did exactly that: removeObjectForKey: has already changed this + // NSUserDefaults instance's in-memory view, so objectForKey: answers nil + // whether or not anything reached the disk. The check passed by + // construction and the erasure it gates was never actually verified. + // + // synchronize's BOOL is the half that knows about the disk, so it is what + // says the removal is durable; the key is still read afterwards because a + // successful flush of the wrong thing is not what is being claimed either. + // The caller refuses the erasure on false, and a container that still + // holds a code is exactly what it must refuse on. + BOOL flushed = [suite synchronize]; + BOOL gone = [suite objectForKey:kCN1InviteHandoffKey] == nil; + return (flushed && gone) ? JAVA_TRUE : JAVA_FALSE; } #else diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index b7371c376b2..23b2ff17aa2 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -576,6 +576,45 @@ void aClipHandoffAcknowledgedWhenTheRETRYPersistsIt() { + "erasure restores the attribution that was erased"); } + @FormTest + void anAnswerThatSettlesNothingIsThrottledToo() { + // A 2xx whose body is empty, unparseable, or resolved with no code + // reaches a plain return without settling anything -- and the entry to + // handleResolution used to clear the in-flight stamp for EVERY + // response. The lookup was then pending with nothing to throttle it, + // so each later checkForInvite() re-issued at once and burned another + // of the five durable attempts: a couple of lifecycle calls could + // settle an exact code as no_match in seconds. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + callback.onReferrer("utm_source=cn1_invite&cn1_invite=USELESS1", 0L, 0L); + } + }); + Invites.checkForInvite(); + assertEquals(Invites.STATE_PENDING, Invites.getState(), + "the fixture never got a lookup under way"); + + // The useless answer: a success that decides nothing. + Invites.handleResolution("", Invites.MATCH_REFERRER, true); + assertEquals(Invites.STATE_PENDING, Invites.getState(), + "an empty body settled the lookup, so this proves nothing"); + implementation.clearQueuedRequests(); + + // The retry interval has NOT elapsed, so these must do nothing. + Invites.checkForInvite(); + Invites.checkForInvite(); + + assertEquals(0, implementation.getQueuedRequests().size(), + "an answer that settled nothing left the lookup unthrottled, so every " + + "later check re-asked at once and spent the attempt budget"); + } + @FormTest void aNotYetAnswerIsNotAskedAgainImmediately() { // The retry above must be throttled, or the fix for it becomes its own From ee083dddef431cc3a5e685722b836537bc287b73 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 02:28:27 +0300 Subject: [PATCH 82/99] Invites: a handoff-only erasure failure latched nothing resetVerified() reports the failure, and reset() decides whether to LATCH by asking what survived -- which read the three InviteStore records, all of which had gone. So the one failure whose only survivor sits OUTSIDE our storage returned false and latched nothing: no durable marker, nothing blocked, nothing retrying, and the surviving App Clip code read by the next launch. The gate existed and the thing it gates never fired. The discard result is remembered now, because nothing else can see that container. A transient Play referrer failure keeps its retry interval too. It leaves the state pending on purpose, and clearing the in-flight stamp before settleNoHandoff() made lookupInFlight() false at once -- so every later checkForInvite() bound the Play service again. These local attempts do not bump the persisted claim counter, so nothing bounded them but the attribution window. That changed when an existing case retries, so it now lets the interval elapse first. What it is about is unchanged and still asserted: a transient failure is not settled as organic, and the exact answer still lands when the store recovers -- not how soon the retry may start. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 30 ++++++++++++++++++- .../invite/InviteResilienceTest.java | 29 ++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 82253c73ea8..ee8fe2ac9d4 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -230,6 +230,10 @@ public final class Invites { // Set when a clip handoff has been read and not yet made durable. The clip // container is the only copy until then, so it must not be cleared early. private static boolean handoffAwaitingAck; + // True when the last discard left the handoff where it was. The container + // is not one of our records, so this is the only way a reset can tell that + // something survived it. + private static boolean handoffSurvived; // Set when an erasure could not remove the durable records, and cleared // when a later attempt does. Nothing that transmits may run while it is @@ -1180,6 +1184,19 @@ public static void reset() { /// /// true when a record, an attribution or a queued registration remains private static boolean anythingSurvives() { + // The App Clip container counts, and it is not one of the records + // below. + // + // resetVerified() reports false when the handoff could not be + // discarded, but the latch that makes a failed erasure retry was + // decided by reading the three InviteStore records -- all three of + // which had gone. So the one failure whose only survivor is OUTSIDE + // our storage returned false and latched nothing: no durable marker, + // nothing blocked, nothing retrying, and the surviving code read by + // the next launch. + if (handoffSurvived) { + return true; + } Map record = InviteStore.read(InviteStore.PENDING); if (record != null && !record.isEmpty()) { return true; @@ -2092,6 +2109,8 @@ private static boolean discardAnyHandoff() { if (gone) { handoffAwaitingAck = false; } + // Remembered for anythingSurvives(), which cannot see the container. + handoffSurvived = !gone; return gone; } @@ -2566,7 +2585,16 @@ private static void requestAppClipHandoff() { // is the reopenable marker the kill switch writes, so reporting it // here would have every launch reopen a lookup that can never have // anything to find. - lookupIssuedAt = 0; + // Stamped, not cleared, for the reason handleResolution() gives. + // settleNoHandoff() does NOT always settle: when the pending + // record carries referrerRetry -- a transient Play failure, which + // is the ordinary way this is reached on Android -- it leaves the + // state pending on purpose. Clearing the stamp then made + // lookupInFlight() false immediately, so every later + // checkForInvite() bound the Play service again. These local + // attempts do not bump the persisted claim counter, so nothing + // bounded them but the attribution window. + lookupIssuedAt = System.currentTimeMillis(); settleNoHandoff(REASON_NO_MATCH); return; } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 23b2ff17aa2..a25cb6db9bf 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -493,6 +493,27 @@ void aClipHandoffWhoseRecordFailedIsNotAcknowledged() { + "write that was supposed to keep it kept failing"); } + @FormTest + void aHandoffOnlyFailureStillLatchesTheErasure() { + // resetVerified() reports the failure, and reset() decides whether to + // LATCH by asking what survived -- which was answered by reading the + // three InviteStore records, all of which had gone. So the one failure + // whose only survivor sits outside our storage latched nothing: no + // durable marker, nothing blocked, nothing retrying, and the surviving + // code read by the next launch. The whole point of the gate. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + InviteTestSupport.PendingHandoffSource source = InviteTestSupport.pendingHandoff; + source.discardFails = true; + + Invites.reset(); + + assertNotNull(InviteStore.read(InviteStore.ERASURE), + "a reset whose only survivor was the App Clip container reported " + + "success and left nothing retrying, so the next launch " + + "restores the attribution it promised to forget"); + } + @FormTest void aHandoffThatWillNotGoFailsTheReset() { // Every store deletion in resetVerified() is gated; the clip container @@ -1098,6 +1119,14 @@ public void requestReferrer(InstallReferrerCallback callback) { callback.onReferrer("utm_source=cn1_invite&cn1_invite=LATER1", 0L, 0L); } }); + // After the retry interval, which now applies to this path too: a + // transient referrer failure leaves the lookup PENDING, and an + // unthrottled pending lookup re-bound the Play service on every + // lifecycle call without bound, because these local attempts do not + // bump the persisted claim counter. What the case is about is that the + // answer is not settled as organic and still lands when the store + // recovers -- not how soon the retry is allowed. + Invites.lookupRetryDelay = 0; Invites.flush(); Map pending = InviteStore.read(InviteStore.PENDING); assertEquals("LATER1", InviteStore.get(pending, "code", null), From 3a53d487459cfa9253e130175465ffe2788add3e Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 02:39:59 +0300 Subject: [PATCH 83/99] Invites: a foreign link could claim the install, and Play's one shot was burnt early The host is settled before either URL form is read. The query branch ran first and returned the moment it found cn1_invite, so any deep link the application handles for any other domain -- a partner site, a campaign page -- was accepted and claimed, handing a fresh install or a last-touch re-attribution to whoever wrote a url this app happens to open. The install referrer is a bare query string with no host and calls codeFromQuery() directly, so that path is untouched. A path carrying another app's slug is refused. One domain serves every enrolled app, which is why the path has a slug at all; a build whose App Links filter claims /i/ broadly is handed /i/other-app/CODE as readily as its own, and this took the last component regardless -- claiming a stranger's invite and REMEMBERING THEIR SLUG as its own, so its later mints advertised their links. A slug is still learned when this build has none to contradict, which is the bare first-mint case. The Play one-shot flag is burnt on durable persistence, not on handover. The framework marshals onto the EDT, so a callback arriving on a binder thread left the write queued; a process dying in that window lost the exact code for ever, because the next launch would not ask Play again. The source is now told when the record really landed -- the same shape the App Clip handoff already uses -- and a failed write leaves the flag unburnt so the next launch can ask. Probes: with the old order restored a foreign host yields STOLEN1, and with the slug check removed another app's path yields THEIRS1. Co-Authored-By: Claude Opus 5 (1M context) --- .../invite/InstallReferrerSource.java | 17 ++++++ .../codename1/analytics/invite/Invites.java | 61 ++++++++++++++++--- .../referrer/AndroidInstallReferrer.java | 39 +++++++----- .../invite/InviteConsentAndErasureTest.java | 3 + .../analytics/invite/InviteDeliveryTest.java | 6 ++ .../invite/InviteResilienceTest.java | 36 +++++++++++ .../invite/InviteUrlParsingTest.java | 36 +++++++++++ 7 files changed, 173 insertions(+), 25 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/InstallReferrerSource.java b/CodenameOne/src/com/codename1/analytics/invite/InstallReferrerSource.java index cc1547b31c8..6da8af511e5 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InstallReferrerSource.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InstallReferrerSource.java @@ -51,4 +51,21 @@ public interface InstallReferrerSource { /// /// - `callback`: receives the answer, never null void requestReferrer(InstallReferrerCallback callback); + + /// Told that the referrer handed over is now stored somewhere that + /// survives the process, so a source holding a one-shot flag may burn it. + /// + /// The Android source may only ask Play once: the API answers a given + /// install once, and the port records that it has asked so a later launch + /// does not throw the answer away by asking again. Burning that flag when + /// the value was merely HANDED OVER loses the exact code whenever the + /// process dies first -- the framework marshals onto the EDT, so the + /// persist is queued rather than done -- and the next launch then settles + /// an invited install as no-match, permanently, on the one platform whose + /// answer is exact. + /// + /// Called once per accepted referrer, and never when the write failed: the + /// source should keep its flag unburnt so the next launch can ask again. + /// A source with no such flag does nothing here. + void referrerPersisted(); } diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index ee8fe2ac9d4..e43dd283cf3 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1720,13 +1720,19 @@ static String extractCode(String url) { if (frag >= 0) { url = url.substring(0, frag); } + // The HOST is settled before either form is read. + // + // The query branch used to run first and return the moment it found + // the key, so any deep link the application handles for any other + // domain -- a partner's site, a campaign page, anything carrying + // cn1_invite in its query -- was accepted and claimed. That hands a + // fresh install, or a last-touch re-attribution, to whoever wrote a + // url this app happens to open. + // + // This function is the URL path only. The install referrer is a bare + // query string with no host at all, and it calls codeFromQuery() + // directly, so nothing about that path changes. int q = url.indexOf('?'); - if (q >= 0) { - String code = codeFromQuery(url.substring(q + 1)); - if (code != null) { - return code; - } - } String host = hostOf(url); if (host == null) { return null; @@ -1737,6 +1743,12 @@ static String extractCode(String url) { || host.length() != expected.length()) { return null; } + if (q >= 0) { + String code = codeFromQuery(url.substring(q + 1)); + if (code != null) { + return code; + } + } String path = url; int schemeEnd = path.indexOf("://"); if (schemeEnd >= 0) { @@ -1765,9 +1777,25 @@ static String extractCode(String url) { } int slash = rest.lastIndexOf('/'); String code = slash < 0 ? rest : rest.substring(slash + 1); - if (slash > 0) { - // Remember the slug so later invites mint the precise form. - Preferences.set(PREF_SLUG, rest.substring(0, slash)); + String pathSlug = slash > 0 ? rest.substring(0, slash) : null; + String mine = configuredSlug(); + if (pathSlug != null && mine != null && mine.length() > 0 && !mine.equals(pathSlug)) { + // ANOTHER app's invite, on the host we share with it. + // + // One domain serves every enrolled app, which is why the path + // carries a slug at all. A build whose App Links filter claims + // /i/ broadly -- which is what a hand-written filter usually does + // -- is handed /i/other-app/CODE by Android as readily as its own, + // and this took the last component regardless. The app then + // claimed a stranger's invite, and remembered their slug as its + // own, so its later mints advertised their links. + return null; + } + if (pathSlug != null && (mine == null || mine.length() == 0)) { + // Learned only when this build has no slug of its own to + // contradict: the bare form is what a first offline mint produces, + // and the server hands the slugged one back on registration. + Preferences.set(PREF_SLUG, pathSlug); } return code.length() == 0 ? null : code; } @@ -2459,7 +2487,20 @@ public void run() { String.valueOf(clickSeconds * 1000L)); } pending.remove("referrerRetry"); - writePending(pending); + // The source is told only when the record really + // landed. It holds a one-shot flag -- Play answers + // an install once -- and burning it on handover + // lost the exact code whenever the process died + // inside the marshalling window. A failed write + // leaves the flag unburnt, so the next launch asks + // again, which is the outcome a retry can fix. + if (writePending(pending)) { + try { + source.referrerPersisted(); + } catch (Throwable t) { + Log.e(t); + } + } claim(code, "install_referrer", rawReferrer == null ? "" : rawReferrer, MATCH_REFERRER, true, diff --git a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java index eadfbaefeff..895d6c2e7ac 100644 --- a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java +++ b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java @@ -318,23 +318,32 @@ private void deliver(int issued, InstallReferrerClient client, } return; } - // The handoff FIRST, the flag after. + // The handoff only. The flag is burnt by referrerPersisted(), which + // the framework calls once the code is in durable storage. // - // Burning the flag before handing the referrer over meant a process - // killed in between lost the exact code for ever: the next launch saw - // isSupported() false and settled the invited install as no-match. - // Invites persists the code inside this call when it runs on the EDT, - // which is the common case. + // Burning it here lost the exact code whenever the process died first: + // the framework marshals onto the EDT, so a callback arriving on a + // binder thread leaves the persist QUEUED, and the next launch then + // saw isSupported() false and settled an invited install as no-match -- + // permanently, on the one platform whose answer is exact. The window + // was small and it was unbounded in consequence. // - // It is not a guarantee, and saying so is the point: the framework - // marshals onto the EDT, so when this callback arrives on a binder - // thread the persist is queued rather than done, and a process killed - // inside that window still loses it. The window goes from "always" to - // "the callSerially latency", which is the most the SPI shape allows - // without the port knowing what the framework did with the value. - if (referrer(issued, callback, referrer, clickSeconds, beginSeconds)) { - Preferences.set(PREF_ATTEMPTED, true); - } + // This exchange is still marked as having ANSWERED, so a superseded or + // duplicate callback cannot answer again; what waits for durability is + // only the one-shot flag that decides whether Play is ever asked again. + referrer(issued, callback, referrer, clickSeconds, beginSeconds); + } + + /// Burns the one-shot flag, once the framework has the code durably. + /// + /// Play answers a given install once, so asking again would throw the + /// answer away -- which is what this flag prevents. It is set HERE rather + /// than at handover so that a process killed before the framework's write + /// lands leaves it unset, and the next launch asks Play again instead of + /// losing the referrer for good. + @Override + public void referrerPersisted() { + Preferences.set(PREF_ATTEMPTED, true); } /// The one-shot flag is burnt by the exchange that ANSWERED, and only by diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java index b746fd4022d..83789ed9fc0 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java @@ -616,6 +616,9 @@ public boolean isSupported() { return true; } + public void referrerPersisted() { + } + public void requestReferrer(InstallReferrerCallback callback) { callback.onReferrer("utm_source=cn1_invite&cn1_invite=SUSPEND1", 0L, 0L); } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java index 93d4ea4ba5d..a09ad1dfc70 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java @@ -125,6 +125,9 @@ public boolean isSupported() { return true; } + public void referrerPersisted() { + } + public void requestReferrer(InstallReferrerCallback callback) { callback.onReferrer( "utm_source=cn1_invite&utm_medium=referral&cn1_invite=ABC123", @@ -169,6 +172,9 @@ public boolean isSupported() { return true; } + public void referrerPersisted() { + } + public void requestReferrer(InstallReferrerCallback callback) { callback.onUnavailable(Invites.REASON_NO_MATCH); } diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index a25cb6db9bf..d8103d89d77 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -613,6 +613,9 @@ public boolean isSupported() { return true; } + public void referrerPersisted() { + } + public void requestReferrer(InstallReferrerCallback callback) { callback.onReferrer("utm_source=cn1_invite&cn1_invite=USELESS1", 0L, 0L); } @@ -651,6 +654,9 @@ public boolean isSupported() { return true; } + public void referrerPersisted() { + } + public void requestReferrer(InstallReferrerCallback callback) { callback.onReferrer("utm_source=cn1_invite&cn1_invite=THROTTLE", 0L, 0L); } @@ -692,6 +698,9 @@ public boolean isSupported() { return true; } + public void referrerPersisted() { + } + public void requestReferrer(InstallReferrerCallback callback) { callback.onReferrer("utm_source=cn1_invite&cn1_invite=RETRY1", 0L, 0L); } @@ -956,6 +965,9 @@ public boolean isSupported() { return true; } + public void referrerPersisted() { + } + public void requestReferrer(InstallReferrerCallback callback) { callback.onReferrer("utm_source=organic", 0L, 0L); } @@ -1099,6 +1111,9 @@ public boolean isSupported() { return true; } + public void referrerPersisted() { + } + public void requestReferrer(InstallReferrerCallback callback) { callback.onUnavailable(Invites.REASON_NO_MATCH); } @@ -1115,6 +1130,9 @@ public boolean isSupported() { return true; } + public void referrerPersisted() { + } + public void requestReferrer(InstallReferrerCallback callback) { callback.onReferrer("utm_source=cn1_invite&cn1_invite=LATER1", 0L, 0L); } @@ -1145,6 +1163,9 @@ public boolean isSupported() { return true; } + public void referrerPersisted() { + } + public void requestReferrer(InstallReferrerCallback callback) { callback.onReferrer("utm_source=google-play&utm_medium=organic", 0L, 0L); } @@ -1248,6 +1269,9 @@ public boolean isSupported() { return true; } + public void referrerPersisted() { + } + public void requestReferrer(InstallReferrerCallback callback) { callback.onReferrer("utm_source=cn1_invite&cn1_invite=EXACT9", 0L, 0L); } @@ -1482,6 +1506,9 @@ public boolean isSupported() { return true; } + public void referrerPersisted() { + } + public void requestReferrer(InstallReferrerCallback callback) { held[0] = callback; } @@ -1715,6 +1742,9 @@ public boolean isSupported() { return true; } + public void referrerPersisted() { + } + public void requestReferrer(InstallReferrerCallback callback) { held[0] = callback; } @@ -1827,6 +1857,9 @@ public boolean isSupported() { return true; } + public void referrerPersisted() { + } + public void requestReferrer(InstallReferrerCallback callback) { callback.onReferrer("utm_source=cn1_invite&cn1_invite=PROV1", 0L, 0L); } @@ -1939,6 +1972,9 @@ public boolean isSupported() { return !spent; } + public void referrerPersisted() { + } + public void requestReferrer(InstallReferrerCallback callback) { spent = true; callback.onUnavailable(Invites.REASON_NO_MATCH); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java index ba0f5657da0..068dc417124 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java @@ -135,4 +135,40 @@ void aFragmentOnAQueryStyleLinkIsAlsoStripped() { Map pending = InviteStore.read(InviteStore.PENDING); assertEquals("ABC125", InviteStore.get(pending, "code", null)); } + + @FormTest + void aForeignUrlCarryingTheKeyIsNotAnInvite() { + // The query form used to be read BEFORE the host was checked and + // returned the moment it found the key, so any deep link the + // application handles for any other domain -- a partner site, a + // campaign page -- was accepted and claimed. That hands a fresh + // install, or a last-touch re-attribution, to whoever wrote a url this + // app happens to open. + assertNull(Invites.extractCode( + "https://partner.example.com/promo?cn1_invite=STOLEN1"), + "a url on somebody else's host was accepted as an invite"); + // Our own host in the query form is still an invite. + assertEquals("MINE123", Invites.extractCode( + "https://cloud.codenameone.com/anything?cn1_invite=MINE123")); + } + + @FormTest + void anotherAppsSlugOnTheSharedHostIsNotOurInvite() { + // One domain serves every enrolled app, which is why the path carries + // a slug. A build whose App Links filter claims /i/ broadly is handed + // /i/other-app/CODE as readily as its own, and this took the last + // component regardless -- claiming a stranger's invite, and + // remembering their slug as its own so later mints advertised their + // links. + Invites.reset(); + Preferences.set(Invites.PREF_SLUG, "acme"); + + assertNull(Invites.extractCode("https://cloud.codenameone.com/i/other-app/THEIRS1"), + "an invite belonging to another app on the shared host was claimed"); + assertEquals("acme", Preferences.get(Invites.PREF_SLUG, ""), + "the foreign slug was remembered, so later invites mint their links"); + assertEquals("OURS123", + Invites.extractCode("https://cloud.codenameone.com/i/acme/OURS123"), + "our own slugged invite stopped being recognised"); + } } From d80a4bd027a957139b29d6f54a3ab0a8a87a8820 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 02:44:52 +0300 Subject: [PATCH 84/99] Invites: the simulator's referrer source broke the build Adding referrerPersisted() to InstallReferrerSource left the simulator's own implementation behind, so javase stopped compiling and took archetype-smoke, build, ubuntu-latest and windows-latest with it. I looked for implementors with "new InstallReferrerSource()" and this one writes the type fully qualified, so the search that found the fifteen anonymous fixtures in the tests missed the one in the port. The lesson is the search, not the omission: after changing an interface, the thing that finds every implementor is compiling the modules, which is what caught it here. The simulator has no one-shot flag to burn -- the menu item is the trigger and can be used again -- so the method is empty and says why. Co-Authored-By: Claude Opus 5 (1M context) --- Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 8b43954a299..8d73ac24deb 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -7576,6 +7576,12 @@ public void requestReferrer( long now = System.currentTimeMillis() / 1000L; callback.onReferrer(referrer, now - 60L, now); } + + @Override + public void referrerPersisted() { + // The simulator has no one-shot flag to burn: the menu + // item is the trigger, and it can be used again. + } }); Display.getInstance().callSerially(new Runnable() { @Override From 2cda78a1623671faa94e1eb6d429880164a96319 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 02:51:53 +0300 Subject: [PATCH 85/99] Invites: a failed acknowledgement was treated as done discardAnyHandoff() keeps the obligation when the copy did not go; ackHandoff() -- its twin, on the path where a durable write acknowledges the handoff -- cleared the flag before it even read the answer. So a removal the container refused, or a flush that never reached the disk (exactly what the native side now reports), counted as done: the code stayed in the shared container for good, nothing asked again, and the container is read on launch, so it returns if the framework's own record is ever lost or cleared. Both now follow the same rule, and the next durable write of that record retries the discard. The test drives that retry through the claim attempt, which re-saves the same app_clip record -- not through handleUrl(), which replaces it with a direct code and never reaches the acknowledgement. Its first version did the latter and failed against the FIXED code, which is the only reason I found out the retry hangs off the record's own source. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 27 ++++++++++++--- .../invite/InviteResilienceTest.java | 33 +++++++++++++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index e43dd283cf3..d79ae1c499a 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -2147,18 +2147,35 @@ private static void ackHandoff(Map record) { return; } AppClipHandoffSource source = appClipSource; - // Cleared whether or not there is still a source to tell: the flag - // tracks this process's obligation, and a source that has gone away - // cannot be handed anything. - handoffAwaitingAck = false; if (source == null) { + // Nothing left to tell, and nothing it could still be holding + // that this process can reach. + handoffAwaitingAck = false; return; } + boolean gone; try { - source.discardHandoff(); + gone = source.discardHandoff(); } catch (Throwable t) { Log.e(t); + gone = false; + } + // Cleared ONLY when the copy really went, which is the same rule + // discardAnyHandoff() follows and this one did not. + // + // The obligation was dropped before the answer was even read, so a + // removal the container refused -- or a flush that never reached the + // disk, which is exactly what the native side now reports -- was + // treated as done. The code then sat in the shared container for good: + // no later durable write asked again, and the container is read on + // launch, so it comes back if the framework's own record is ever lost + // or cleared. + // + // Left pending instead, and every later durable write retries it. + if (gone) { + handoffAwaitingAck = false; } + handoffSurvived = !gone; } /// Forgets the in-memory copy, for the paths that delete the record. diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index d8103d89d77..5ef2d715a1d 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -493,6 +493,39 @@ void aClipHandoffWhoseRecordFailedIsNotAcknowledged() { + "write that was supposed to keep it kept failing"); } + @FormTest + void anAcknowledgementThatFailedIsRetriedByTheNextWrite() { + // The obligation is cleared only when the copy really went. It used to + // be dropped before the answer was read, so a removal the container + // refused -- or a flush that never reached the disk, which is what the + // native side now reports -- counted as done. The code then sat in the + // shared container for good: nothing asked again, and the container is + // read on launch, so it returns if the framework's record is ever lost. + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + InviteTestSupport.PendingHandoffSource source = InviteTestSupport.pendingHandoff; + Invites.checkForInvite(); + assertTrue(source.wasAsked(), "the fixture never reached the clip handoff"); + + source.discardFails = true; + source.answer("ACKRETRY1", 1700000000L); + assertTrue(source.discardedCount() > 0, + "the fixture never attempted a discard, so this proves nothing"); + + // The container still holds it, so the next durable write of THIS + // record must ask again rather than assume. That write is the claim + // retry: it bumps the attempt count and saves the same app_clip + // record, which is what the acknowledgement hangs off. + int attempted = source.discardedCount(); + source.discardFails = false; + Invites.lookupRetryDelay = 0; + Invites.checkForInvite(); + + assertTrue(source.discardedCount() > attempted, + "a discard the container refused was treated as done, so the code " + + "stays in the shared container and nothing ever asks again"); + } + @FormTest void aHandoffOnlyFailureStillLatchesTheErasure() { // resetVerified() reports the failure, and reset() decides whether to From 5d0e16613cbb0f4d26f9fda40ae9ca7c03b85568 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:05:36 +0300 Subject: [PATCH 86/99] Invites: the proof had a fallback that made it forgeable When the platform CSPRNG threw, minting fell back to FALLBACK_RANDOM and carried on. I justified that in a comment which said the fallback was "still a SecureRandom-seeded generator". It was not: FALLBACK_RANDOM was a plain java.util.Random, and this runtime implements that as a 48-bit linear congruential generator seeded from System.currentTimeMillis() (vm/JavaAPI java.util.Random). I wrote the claim without reading either. It matters because the secret stopped being about uniqueness when the code became its digest. The secret is what proves who minted the invite, so a recipient holding the public code who knows roughly when it was made can search that seed window, recover the proof and register the invite as their own -- the exact theft the proof was added to prevent, handed back on the one platform whose randomness is degraded. So there is no fallback. create() throws, and says so. An invite that cannot be minted is a visible failure on a broken device; an invite minted with a guessable proof is an invisible one on every device the link reaches. InviteButton catches it and presents nothing rather than opening the sheet on whatever text was set last. Separately, only the CURRENT exchange may retry a transient Play referrer failure. That branch decided on the shared `retried` flag alone, which a newer exchange resets -- so a binding that outlived lookupRetryDelay could come back, find the flag clear, advance the sequence and invalidate the newer exchange, whose answer might have been the exact referrer. Every other branch there already passes `issued` to something that checks it. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 42 ++++++++++++++----- .../codename1/components/InviteButton.java | 20 ++++++++- .../referrer/AndroidInstallReferrer.java | 14 +++++++ 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index d79ae1c499a..7c0ba691f66 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -295,10 +295,6 @@ private static boolean lookupInFlight() { // the user has just erased or refused. private static int lookupEpoch; - // Only ever touched on the fallback path in newCode(), and held as a field - // so there is one generator for the process rather than one per call. - private static final java.util.Random FALLBACK_RANDOM = new java.util.Random(); - private Invites() { } @@ -342,6 +338,15 @@ public static void registerAppClipHandoffSource(AppClipHandoffSource source) { /// #### Returns /// /// the invite, never null + /// + /// #### Throws + /// + /// - `IllegalStateException`: when the device cannot supply secure + /// randomness. The code is the digest of a secret and that secret is + /// what proves who minted it, so a guessable one is a forgeable proof -- + /// an invite anybody it is shared with could register as their own. + /// Failing here is visible on the broken device; minting anyway is + /// invisible on every device the link reaches. public static Invite create(InviteRequest request) { if (request == null) { throw new IllegalArgumentException("request is null"); @@ -1668,13 +1673,30 @@ private static String[] newCode() { try { Util.secureRandomBytes(raw); } catch (Throwable t) { - // Weaker randomness now costs more than uniqueness -- a guessable - // secret is a forgeable proof -- but failing the mint outright - // would take the feature down on whatever platform is degraded, - // and the fallback is still a SecureRandom-seeded generator. - // Reported once rather than failing the invite. + // NO FALLBACK. A mint without secure randomness does not happen. + // + // There used to be one, on the reasoning that weak randomness + // degrades uniqueness and that the fallback was "still a + // SecureRandom-seeded generator". That was simply untrue: + // FALLBACK_RANDOM was a plain java.util.Random, and this runtime + // implements it as a 48-bit linear congruential generator seeded + // from System.currentTimeMillis() (vm/JavaAPI java.util.Random). + // + // The secret is no longer only about uniqueness -- the code is its + // digest, and the secret is what proves who minted it. A recipient + // who has the public code and knows roughly when it was made can + // search that seed window, recover the proof, and register the + // invite as their own: exactly the theft the proof was added to + // prevent, handed back on the one platform whose CSPRNG is + // degraded. + // + // So this throws. An invite that cannot be minted is a visible + // failure on a broken device; an invite minted with a guessable + // proof is a silent one on every device it is shared with. Log.e(t); - FALLBACK_RANDOM.nextBytes(raw); + throw new IllegalStateException( + "invite codes need secure randomness, which this device did not " + + "provide; minting would produce a forgeable invite", t); } String proof = trimPadding(Base64.encodeUrlSafe(raw)); String code = trimPadding(Base64.encodeUrlSafe(Hash.sha256(raw))); diff --git a/CodenameOne/src/com/codename1/components/InviteButton.java b/CodenameOne/src/com/codename1/components/InviteButton.java index 3d31dd3daae..e122ab7e580 100644 --- a/CodenameOne/src/com/codename1/components/InviteButton.java +++ b/CodenameOne/src/com/codename1/components/InviteButton.java @@ -26,6 +26,7 @@ import com.codename1.analytics.invite.InviteRequest; import com.codename1.analytics.invite.Invites; import com.codename1.share.ShareResultListener; +import com.codename1.io.Log; import com.codename1.ui.Display; import com.codename1.ui.FontImage; import com.codename1.ui.events.ActionEvent; @@ -242,7 +243,13 @@ public void actionPerformed(ActionEvent evt) { return; } presenting = true; - mintForShare(); + if (mintForShare() == null) { + // Nothing was minted -- the device could not supply secure + // randomness -- so there is no link to share. Presenting anyway + // would open the sheet on whatever text was set last. + presenting = false; + return; + } presentShare(evt); Display d = Display.getInstance(); if (d == null) { @@ -308,7 +315,16 @@ Invite mintForShare() { // of a referral anyway -- a code is not per recipient, it is the // inviter's -- so nothing is lost by not minting a second. if (outstanding == null) { - outstanding = Invites.create(b.build()); + try { + outstanding = Invites.create(b.build()); + } catch (IllegalStateException e) { + // The device could not supply secure randomness, so there is + // no invite to share. Nothing is presented rather than + // sharing a link somebody else could claim -- see + // Invites.create(). + Log.e(e); + return null; + } invite = outstanding; } // The outstanding one, not the accessor's: this is the invite whose diff --git a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java index 895d6c2e7ac..a9a9dd7d8c2 100644 --- a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java +++ b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java @@ -199,6 +199,20 @@ public void onInstallReferrerSetupFinished(int responseCode) { // Transient. Exactly one retry: a loop here would // bind the service repeatedly on a device that is // never going to answer. + // Only the exchange that is STILL CURRENT may + // retry. `retried` is shared and the newer + // exchange resets it, so a binding that outlived + // lookupRetryDelay could come back with this + // transient code, find the flag clear, advance the + // sequence and start its own retry -- invalidating + // the newer exchange, whose answer might have been + // the exact referrer. Every other branch here + // already passes `issued` to a method that checks + // it; this one decided on its own. + if (issued != attemptSeq) { + close(client); + return; + } if (!retried) { retried = true; // The sequence advances BEFORE the close, and From 81743c8580e655c6b49a80c684ea51264e57c396 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:17:46 +0300 Subject: [PATCH 87/99] Invites: an app group list could end up mixing its delimiters Enabling invites alongside Matter, external surfaces or the document provider produced values like "group.invite,group.matter": the invite and widget blocks append with a space, the Matter and surfaces blocks with a comma. A mixed list is broken whichever way it is finally split -- either split yields a token containing the other delimiter -- so neither group matches the entitlement generated for the extension or the clip, and the App Clip handoff and the other shared-container feature both fail. Every producer now appends with the separator the value ALREADY carries, so the list stays homogeneous no matter which feature ran first. Which delimiter is CANONICAL is deliberately not decided here. The hint documents "space-delimited"; two comments in this file say space and a third calls comma "the established" form; and the code that finally splits the value is not in this repository, so picking one could break Matter or surfaces for a claim I cannot check. What is fixed is the state that is wrong under every reading. declaresAppGroup already split on both, so detection was never the problem -- only the writes were. Co-Authored-By: Claude Opus 5 (1M context) --- .../com/codename1/builders/IPhoneBuilder.java | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index c03d9dc227a..36aa885a33e 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -4510,8 +4510,7 @@ public void usesClassMethod(String cls, String method) { String appGroups = request.getArg("ios.app_groups", ""); if (!declaresAppGroup(appGroups, group)) { request.putArgument("ios.app_groups", - appGroups.trim().length() == 0 ? group - : appGroups.trim() + " " + group); + appendAppGroup(appGroups, group)); } try { replaceInFile(new File(buildinRes, @@ -5161,8 +5160,7 @@ public void usesClassMethod(String cls, String method) { } if (!present) { request.putArgument("ios.app_groups", - appGroups.trim().length() == 0 ? matterGroup - : appGroups.trim() + "," + matterGroup); + appendAppGroup(appGroups, matterGroup)); } matterAppGroup = matterGroup; // Commissioning talks to the accessory over BLE before it has @@ -6289,8 +6287,8 @@ public void usesClassMethod(String cls, String method) { if (surfacesExtensionEnabled || surfacesWatchEnabled) { String appGroups = request.getArg("ios.app_groups", ""); if (!declaresAppGroup(appGroups, surfacesAppGroup)) { - request.putArgument("ios.app_groups", appGroups.length() == 0 - ? surfacesAppGroup : appGroups + "," + surfacesAppGroup); + request.putArgument("ios.app_groups", + appendAppGroup(appGroups, surfacesAppGroup)); } } @@ -16915,6 +16913,36 @@ static void collectOptionalFrameworks(java.util.Set set, String arg) { * @param group the group being added * @return true when the group is already declared */ + /** + * Appends an app group using the delimiter the value ALREADY uses. + * + *

ios.app_groups is documented as a space-delimited list and the + * invite and widget blocks write it that way, while the Matter and + * surfaces blocks write commas -- and a comment beside one of them calls + * comma "the established" form. They cannot all be right, and the code + * that finally splits the value is not in this repository, so this does + * not pick a winner.

+ * + *

What it removes is the MIXED value, which is broken whichever way + * the split is done: enabling invites alongside Matter or surfaces + * produced "group.invite,group.matter" or the reverse, and a split on + * either delimiter then yields a token containing the other, so neither + * group matches the entitlement generated for the extension or the clip. + * Following whatever separator is already there keeps the list + * homogeneous no matter which feature ran first.

+ * + * @param declared the existing ios.app_groups value, possibly empty + * @param group the group to add + * @return the new value + */ + static String appendAppGroup(String declared, String group) { + String existing = declared == null ? "" : declared.trim(); + if (existing.length() == 0) { + return group; + } + return existing + (existing.indexOf(',') >= 0 ? "," : " ") + group; + } + static boolean declaresAppGroup(String declared, String group) { if (declared == null || group == null || group.length() == 0) { return false; From 13c4a832010ce8f4eac8da5599c875f704c9831b Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:29:16 +0300 Subject: [PATCH 88/99] Invites: a routed link was handled twice, and a link base could be un-openable handleUrl() consumes the launch argument the moment it recognises an invite. It is the documented route for an app that handles its own deep links, and on Android it runs from the dispatch that setting AppArg fires -- with the onNewIntent splice queuing a checkForInvite() behind it for apps that have no router. The argument was still set when that queued check ran, so the same url was handled twice: invite_opened twice on a resolved install, and on a pending one a duplicate claim whose epoch bump discarded the answer to the first. Only when the url really is the argument -- an application may pass any string here, and clearing an unrelated launch argument is not ours to do. PREF_CONSUMED_ARG is gone. It was declared and deleted on reset and never set or read by anything: a guard in name only, sitting next to the code that needed one. setLinkBase() normalizes a bare host to https and refuses anything else. Invite.getUrl() promises an absolute https url and the generated Android filter and iOS associated domain match nothing else, so a bare host minted relative links and an http:// base minted links that always open the browser -- and http:// would have passed the host check beside it, which compares hosts and not schemes. Probe: with the consume removed, the routed url is still in AppArg and the queued check handles the invite again. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 48 +++++++++++++++++-- .../analytics/invite/InviteTestSupport.java | 2 - .../invite/InviteUrlParsingTest.java | 35 ++++++++++++++ 3 files changed, 79 insertions(+), 6 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 7c0ba691f66..843271bedff 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -204,7 +204,6 @@ public final class Invites { static final String PREF_SLUG = "cn1$inviteSlug"; // Kept only so reset() can clear what earlier versions of this class // persisted. Nothing writes it any more -- see checkForInvite. - static final String PREF_CONSUMED_ARG = "cn1$inviteConsumedArg"; // The referrer key the link service puts on the store url. Compared with // equals and never case folded: String.toLowerCase is locale sensitive and @@ -614,6 +613,24 @@ public static boolean handleUrl(String url) { if (code == null) { return false; } + // CONSUMED here, the moment the url is recognised as an invite. + // + // This is the documented route for an application that handles its own + // deep links, and it is reached from the external-url dispatch that + // Display.setProperty("AppArg", ...) fires synchronously. The Android + // onNewIntent splice queues a checkForInvite() behind that dispatch as + // the fallback for apps with no router -- and for an app that DOES + // route, the argument was still sitting there, so the queued check + // read the same url and handled it a second time: invite_opened twice + // on a resolved install, and on a pending one a duplicate claim whose + // epoch bump discarded the answer to the first. + // + // Only when it really is this url. An application may pass any string + // here, and clearing an unrelated launch argument is not ours to do. + Display display = Display.getInstance(); + if (display != null && url != null && url.equals(display.getProperty("AppArg", null))) { + display.setProperty("AppArg", null); + } ensureProvider(); // A tapped link is a fresh answer and would ordinarily reopen // attribution, but not while an erasure is still owed: claiming writes @@ -914,12 +931,36 @@ public static void conversion(String action, double value, String currency) { /// once, because it cannot be refused -- pointing at a staging service and /// accepting the browser is a legitimate thing to do. /// + /// A bare host is accepted and read as `https://`. Anything else that is + /// not HTTPS is REFUSED: Invite.getUrl() promises an absolute https url, + /// and the generated Android filter and iOS associated domain match + /// nothing else, so an http:// base mints links that always open the + /// browser -- and it would pass the host check below, which compares + /// hosts and not schemes. + /// /// #### Parameters /// /// - `url`: the base address, with no trailing path + /// + /// #### Throws + /// + /// - `IllegalArgumentException`: when the address is not HTTPS public static void setLinkBase(String url) { - linkBase = url; - warnIfNotTheRegisteredHost(url); + String normalized = url; + if (normalized != null && normalized.trim().length() > 0) { + normalized = normalized.trim(); + if (normalized.indexOf("://") < 0) { + // A bare host, which is what the build hint carries and what + // an application copying it would naturally pass. + normalized = "https://" + normalized; + } + if (!normalized.regionMatches(true, 0, "https://", 0, 8)) { + throw new IllegalArgumentException( + "the invite link base must be https, not " + normalized); + } + } + linkBase = normalized; + warnIfNotTheRegisteredHost(normalized); } /// Says so when links will be minted for a host the build did not register. @@ -1279,7 +1320,6 @@ static boolean resetVerified() { // next launch, which is the thing being erased. cleared &= InviteStore.delete(InviteStore.ATTRIBUTION); cleared &= InviteStore.delete(InviteStore.OUTBOX); - Preferences.delete(PREF_CONSUMED_ARG); clearDimensions(); resolved = null; // Loaded, and the answer is "none" -- not "unknown", or the next call diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java index e1b253842a0..dfed816f7a0 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteTestSupport.java @@ -133,7 +133,6 @@ static RecordingProvider freshInstall() { pendingHandoff = new PendingHandoffSource(); Invites.registerAppClipHandoffSource(pendingHandoff); Preferences.delete(Invites.PREF_SLUG); - Preferences.delete(Invites.PREF_CONSUMED_ARG); // reset() clears the records; clearProviders() above dropped the // provider Invites registers, and the next facade call re-adds it. RecordingProvider recorder = new RecordingProvider(); @@ -151,7 +150,6 @@ static void tearDown() { Analytics.setConsent(null); Analytics.setConsentMode(ConsentMode.OPT_IN); Preferences.delete(Invites.PREF_SLUG); - Preferences.delete(Invites.PREF_CONSUMED_ARG); } // The launch argument is process-wide, so a test that sets one and does diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java index 068dc417124..f65cd58987c 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java @@ -171,4 +171,39 @@ void anotherAppsSlugOnTheSharedHostIsNotOurInvite() { Invites.extractCode("https://cloud.codenameone.com/i/acme/OURS123"), "our own slugged invite stopped being recognised"); } + + @FormTest + void aRoutedInviteUrlIsNotHandledTwice() { + // handleUrl() is the documented route for an app that handles its own + // deep links, and on Android it runs from the dispatch that setting + // AppArg fires -- with a checkForInvite() queued behind it as the + // fallback for apps with no router. The argument stayed set, so that + // queued check read the same url and handled it again: invite_opened + // twice on a resolved install, and on a pending one a duplicate claim + // whose epoch bump discarded the answer to the first. + Invites.reset(); + com.codename1.ui.Display d = com.codename1.ui.Display.getInstance(); + d.setProperty("AppArg", "https://cloud.codenameone.com/i/ROUTED1"); + + assertTrue(Invites.handleUrl("https://cloud.codenameone.com/i/ROUTED1"), + "the fixture url was not recognised as an invite"); + + assertNull(d.getProperty("AppArg", null), + "the routed url was left in AppArg, so the queued checkForInvite() " + + "handles the same invite a second time"); + } + + @FormTest + void anUnrelatedAppArgIsLeftAlone() { + // An application may pass any string to handleUrl(). Clearing a launch + // argument that is not the one being handled is not ours to do. + Invites.reset(); + com.codename1.ui.Display d = com.codename1.ui.Display.getInstance(); + d.setProperty("AppArg", "myapp://somewhere/else"); + + Invites.handleUrl("https://cloud.codenameone.com/i/OTHER1"); + + assertEquals("myapp://somewhere/else", d.getProperty("AppArg", null), + "an unrelated launch argument was cleared"); + } } From d597eecc2aa1a57321e9a8d34ad31d934c4c5093 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:38:19 +0300 Subject: [PATCH 89/99] Invites: consuming the argument too early could lose a tapped invite I put the consume right after the url was recognised, which is before the one check that can still report failure: an erasure still owed and a store still refusing leaves handleUrl() returning false -- with the launch argument, the only copy of a freshly tapped invite, already gone. The retry that would have worked once storage recovered had nothing left to read. It now runs after settleErasure() succeeds; everything below that point returns true, which I checked rather than assumed -- zero `return false` remain after it, against four `return true`. The link base must be an ORIGIN. The scheme check I added accepted https://links.example.com/base, which mints /base/i/ -- and the generated Android filter matches /i/, so every link opens the browser while the host comparison beside it stays silent, because the host is right. The path is precisely the part the build cannot know about. A trailing slash is not a path. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 33 ++++++++++++++++--- .../invite/InviteUrlParsingTest.java | 33 +++++++++++++++++++ 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 843271bedff..9ab347862db 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -627,10 +627,6 @@ public static boolean handleUrl(String url) { // // Only when it really is this url. An application may pass any string // here, and clearing an unrelated launch argument is not ours to do. - Display display = Display.getInstance(); - if (display != null && url != null && url.equals(display.getProperty("AppArg", null))) { - display.setProperty("AppArg", null); - } ensureProvider(); // A tapped link is a fresh answer and would ordinarily reopen // attribution, but not while an erasure is still owed: claiming writes @@ -640,6 +636,22 @@ public static boolean handleUrl(String url) { if (!settleErasure()) { return false; } + // Consumed HERE, and not before the check above. + // + // Clearing it first threw the url away on the one path that reports + // failure: an erasure still owed and a store still refusing leaves + // this returning false, and the argument -- the only copy of a + // freshly tapped invite -- was already gone, so the retry that would + // have worked once storage recovered had nothing left to read. + // Everything below this line returns true, so from here the invite + // machinery really does own the url. + // + // Only when it really is this url. An application may pass any string + // here, and clearing an unrelated launch argument is not ours to do. + Display display = Display.getInstance(); + if (display != null && url != null && url.equals(display.getProperty("AppArg", null))) { + display.setProperty("AppArg", null); + } // The same guard beginDeferred() has. checkForInvite() treats a // consumed URL as handled and skips beginDeferred entirely, so without // this a refused user who opened an invite link still had a profile @@ -958,6 +970,19 @@ public static void setLinkBase(String url) { throw new IllegalArgumentException( "the invite link base must be https, not " + normalized); } + // An ORIGIN, with no path of its own. + // + // A prefix check alone accepted https://links.example.com/base, + // which mints /base/i/ -- and the generated Android filter + // matches /i/, so every link opens the browser while the host + // check beside this stays silent, because the host is right. The + // path is the part the build cannot know about. + String origin = trimSlash(normalized); + String host = hostOf(origin); + if (host == null || origin.length() != "https://".length() + host.length()) { + throw new IllegalArgumentException( + "the invite link base must be a bare host, with no path: " + url); + } } linkBase = normalized; warnIfNotTheRegisteredHost(normalized); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java index f65cd58987c..aa4e4e7de35 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java @@ -32,6 +32,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -206,4 +207,36 @@ void anUnrelatedAppArgIsLeftAlone() { assertEquals("myapp://somewhere/else", d.getProperty("AppArg", null), "an unrelated launch argument was cleared"); } + + @FormTest + void theLinkBaseMustBeAnHttpsOrigin() { + // Invite.getUrl() promises an absolute https url, and the generated + // Android filter and iOS associated domain match an https host with + // the /i/ path and nothing else. + Invites.reset(); + // A bare host is what the build hint carries, so it is accepted and + // read as https. + Invites.setLinkBase("links.example.com"); + assertEquals("https://links.example.com", Invites.getLinkBase()); + + try { + Invites.setLinkBase("http://links.example.com"); + fail("an http base was accepted, and every link it mints opens the browser"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("https"), expected.getMessage()); + } + try { + // Mints /base/i/, which the generated filter -- matching + // /i/ -- never sees, while the host check stays silent because + // the host is right. + Invites.setLinkBase("https://links.example.com/base"); + fail("a base carrying a path was accepted"); + } catch (IllegalArgumentException expected) { + assertTrue(expected.getMessage().contains("no path"), expected.getMessage()); + } + // A trailing slash is not a path. + Invites.setLinkBase("https://links.example.com/"); + assertEquals("https://links.example.com", Invites.getLinkBase()); + Invites.setLinkBase(null); + } } From 05fa40de47b3d6aff375190c49411935ccbe6424 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:46:27 +0300 Subject: [PATCH 90/99] Invites: a domain hint written as a URL reached the platform metadata raw "https://links.example.com" is the natural thing to write in invite.domain, and the runtime accepts it -- getLinkBase() adds the scheme only when it is missing, so links mint correctly and nothing looks wrong. The builders take the raw string: android:host gets the whole URL and iOS emits applinks:https://links.example.com. Neither matches the links being minted, so the build succeeds and every invite opens outside the app, which is this feature's signature failure. The hint is reduced to a bare host before any consumer sees it -- scheme, path, query, fragment and port removed. The port belongs in the intent filter's own attribute and an associated domain has no place for one. A value that reduces to nothing is left alone rather than replaced by the default: the builders report an unusable host far better than a silent substitution nobody asked for. This is the build-time twin of the origin check added to setLinkBase(), and the same failure from the other end -- the runtime tolerated the scheme, which is exactly why nothing noticed. Probe: with the reduction removed the test reports where a host belongs. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/builders/InviteBuildHints.java | 44 ++++++++++++++++++- .../builders/InviteBuildHintsTest.java | 23 ++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteBuildHints.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteBuildHints.java index c0757b8e1a4..9c1a803a49d 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteBuildHints.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/InviteBuildHints.java @@ -61,7 +61,49 @@ static String domain(BuildRequest request) { // A blank hint is not a host. Left as the empty string it produced an // intent filter with no host and an entitlement claiming nothing, // which is harder to spot than the default being used. - return trimmed.isEmpty() ? DEFAULT_DOMAIN : trimmed; + if (trimmed.isEmpty()) { + return DEFAULT_DOMAIN; + } + // Reduced to a bare HOST, because that is what every consumer needs + // and none of them checks. + // + // "https://links.example.com" is the natural thing to write here, and + // the runtime accepts it -- getLinkBase() adds the scheme only when it + // is missing, so links mint correctly and nothing looks wrong. The + // builders do not: the Android filter takes the raw string as + // android:host and iOS emits applinks:https://links.example.com. + // Neither matches the links being minted, so the build succeeds and + // every invite opens outside the app, which is this feature's + // signature failure. + return hostOf(trimmed); + } + + /** + * The host part of a hint that may have been written as a URL. + * + *

Strips a scheme, any path, query or fragment, and any port -- an + * intent filter names the port separately and an associated domain has no + * place for one. A value that reduces to nothing is left alone rather than + * silently replaced: the builders report an unusable host far better than + * a default nobody asked for.

+ * + * @param value the trimmed hint + * @return the host it names + */ + private static String hostOf(String value) { + String host = value; + int scheme = host.indexOf("://"); + if (scheme >= 0) { + host = host.substring(scheme + 3); + } + for (int i = 0; i < host.length(); i++) { + char c = host.charAt(i); + if (c == '/' || c == '?' || c == '#' || c == ':') { + host = host.substring(0, i); + break; + } + } + return host.isEmpty() ? value : host; } /** diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteBuildHintsTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteBuildHintsTest.java index 4871de4ba54..809b4588697 100644 --- a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteBuildHintsTest.java +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/InviteBuildHintsTest.java @@ -69,4 +69,27 @@ void aSlugWithStraySpaceIsNormalised() { assertEquals("acme", InviteBuildHints.slug(request("invite.slug", " acme "))); assertEquals("", InviteBuildHints.slug(request("invite.slug", null))); } + + @Test + public void aDomainWrittenAsAUrlIsReducedToItsHost() { + // "https://links.example.com" is the natural thing to write, and the + // runtime accepts it -- getLinkBase() adds the scheme only when it is + // missing, so links mint correctly and nothing looks wrong. The + // builders take the raw string: android:host gets the whole URL and + // iOS emits applinks:https://links.example.com, so the build succeeds + // and every invite opens outside the app. + assertEquals("links.example.com", + InviteBuildHints.domain(request("invite.domain", "https://links.example.com"))); + assertEquals("links.example.com", + InviteBuildHints.domain(request("invite.domain", "https://links.example.com/"))); + assertEquals("links.example.com", + InviteBuildHints.domain(request("invite.domain", "http://links.example.com/base"))); + // A port belongs in the filter's own attribute and has no place in an + // associated domain. + assertEquals("links.example.com", + InviteBuildHints.domain(request("invite.domain", "links.example.com:8443"))); + // And a plain host is untouched. + assertEquals("links.example.com", + InviteBuildHints.domain(request("invite.domain", "links.example.com"))); + } } From 08fd5e5173d62574e3c94ee17c30917d4f76a362 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 04:00:58 +0300 Subject: [PATCH 91/99] Invites: two fixes that were applied to instances instead of to the class Both of these are the same mistake, caught twice in one round: I fixed the case the review named and did not enumerate its siblings. The install referrer is acknowledged from writePending(), beside the App Clip handoff. Keying it on the first write missed the retry inside readPending() -- which claim() reaches on its way out -- so the record became durable with Play's one-shot flag unburnt, and a later launch could read the same referrer again and restore an attribution a reset had removed. This is exactly the hole that was fixed for the clip an hour ago; the referrer path had it too and I only fixed the one I was shown. ALL FIVE app-group producers use appendAppGroup(). I routed three through it and left two -- the document provider, which appends with a comma, and one more that appends with a space -- so an invite build that also enabled the document provider still produced the mixed list the helper exists to prevent. Enumerated this time rather than pattern matched: every putArgument("ios.app_groups", ...) in the file was listed, and none now joins by hand. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 53 ++++++++++++++++--- .../com/codename1/builders/IPhoneBuilder.java | 7 ++- 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 9ab347862db..524b591f2c8 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -2181,6 +2181,7 @@ private static boolean writePending(Map record) { pendingFallback = written ? null : record; if (written) { ackHandoff(record); + ackReferrer(record); } return written; } @@ -2229,6 +2230,41 @@ private static boolean discardAnyHandoff() { return gone; } + /// Lets the install-referrer source burn its one-shot flag, once ours is + /// durable. + /// + /// The twin of `ackHandoff`, and here for the same reason: the first write + /// is not the only one that can make the record durable. A write that + /// fails leaves it in `pendingFallback`, and `readPending()` retries it -- + /// which `claim()` reaches on its way out -- so the referrer became + /// durable with the flag unburnt, and Play answered the same install again + /// on a later launch, restoring an attribution a reset had removed. + private static void ackReferrer(Map record) { + if (!referrerAwaitingAck + || !"install_referrer".equals(record.get("codeSource"))) { + return; + } + InstallReferrerSource source = referrerSource; + if (source == null) { + referrerAwaitingAck = false; + return; + } + boolean told = true; + try { + source.referrerPersisted(); + } catch (Throwable t) { + Log.e(t); + told = false; + } + if (told) { + referrerAwaitingAck = false; + } + } + + // True when a referrer has been handed over and not yet made durable. The + // source is holding a one-shot flag until it is. + private static boolean referrerAwaitingAck; + private static void ackHandoff(Map record) { if (!handoffAwaitingAck || !"app_clip".equals(record.get("codeSource"))) { return; @@ -2598,13 +2634,16 @@ public void run() { // inside the marshalling window. A failed write // leaves the flag unburnt, so the next launch asks // again, which is the outcome a retry can fix. - if (writePending(pending)) { - try { - source.referrerPersisted(); - } catch (Throwable t) { - Log.e(t); - } - } + // Owed from here until the record is durable, + // which may be this write or a later retry of it. + // writePending() reports it either way -- keying + // it on this call alone missed the retry inside + // readPending(), which claim() reaches on its way + // out, so the record became durable with the + // one-shot flag left unburnt and Play answered the + // same install again after a reset. + referrerAwaitingAck = true; + writePending(pending); claim(code, "install_referrer", rawReferrer == null ? "" : rawReferrer, MATCH_REFERRER, true, diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java index 36aa885a33e..63e055d0cbf 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/IPhoneBuilder.java @@ -5636,8 +5636,7 @@ public void usesClassMethod(String cls, String method) { // either separator when READING, which is what hid // this. request.putArgument("ios.app_groups", - appGroups.trim().length() == 0 ? group - : appGroups.trim() + " " + group); + appendAppGroup(appGroups, group)); } } // The call provider's identity is written into Info.plist @@ -6299,8 +6298,8 @@ public void usesClassMethod(String cls, String method) { if (documentProviderEnabled) { String appGroups = request.getArg("ios.app_groups", ""); if (!declaresAppGroup(appGroups, documentsAppGroup)) { - request.putArgument("ios.app_groups", appGroups.length() == 0 - ? documentsAppGroup : appGroups + "," + documentsAppGroup); + request.putArgument("ios.app_groups", + appendAppGroup(appGroups, documentsAppGroup)); } } From 419fd80c9eb36d259204b1e8521e417989d39ae9 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 04:14:16 +0300 Subject: [PATCH 92/99] Invites: forgetting has to reach the Play referrer too A reset before the first checkForInvite() -- an early logout, a privacy reset -- cleared the records and left the referrer source's one-shot flag unburnt. Play answers the same install for as long as that flag is unset, so the next check read the original referrer and restored exactly the attribution the reset promised to forget. The App Clip container had this hole and was fixed; the referrer is the other half of the same shape and I did not look for it until it was pointed out. referrerPersisted() is discardReferrer(), and returns whether anything is left that could answer again. The old name could not describe the erasure path, and the old void could not gate it: the reset now refuses when the flag did not go, like the clip. The Android side READS THE MARKER BACK, because Preferences.set() answers nothing. A store that refused left the marker absent for good and the caller was told it succeeded. handleUrl() takes https only. An application forwarding its broader deep links could hand over myapp://cloud.codenameone.com/i/CODE or the http:// form, and a host-only test accepted both -- persisting and claiming a code although nothing the framework mints or the platforms associate is anything but https. The host being right is what made it look safe. Sixteen implementors of the renamed method, found by compiling core, javase and android rather than by grepping for the short name -- which is how JavaSEPort was missed last time. Co-Authored-By: Claude Opus 5 (1M context) --- .../invite/InstallReferrerSource.java | 27 ++++++-- .../codename1/analytics/invite/Invites.java | 62 +++++++++++++++++-- .../referrer/AndroidInstallReferrer.java | 10 ++- .../com/codename1/impl/javase/JavaSEPort.java | 3 +- .../invite/InviteConsentAndErasureTest.java | 3 +- .../analytics/invite/InviteDeliveryTest.java | 6 +- .../invite/InviteResilienceTest.java | 36 +++++++---- .../invite/InviteUrlParsingTest.java | 16 +++++ 8 files changed, 134 insertions(+), 29 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/InstallReferrerSource.java b/CodenameOne/src/com/codename1/analytics/invite/InstallReferrerSource.java index 6da8af511e5..e851762d654 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InstallReferrerSource.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InstallReferrerSource.java @@ -52,8 +52,8 @@ public interface InstallReferrerSource { /// - `callback`: receives the answer, never null void requestReferrer(InstallReferrerCallback callback); - /// Told that the referrer handed over is now stored somewhere that - /// survives the process, so a source holding a one-shot flag may burn it. + /// Told that the framework is done with the referrer, so a source holding + /// a one-shot flag must burn it. /// /// The Android source may only ask Play once: the API answers a given /// install once, and the port records that it has asked so a later launch @@ -64,8 +64,23 @@ public interface InstallReferrerSource { /// an invited install as no-match, permanently, on the one platform whose /// answer is exact. /// - /// Called once per accepted referrer, and never when the write failed: the - /// source should keep its flag unburnt so the next launch can ask again. - /// A source with no such flag does nothing here. - void referrerPersisted(); + /// Two things end the framework's interest, and BOTH have to burn the + /// flag, which is why this is one method rather than a "persisted" one: + /// + /// - the referrer reached durable storage. Never called while that write + /// is still failing: the flag stays unburnt so the next launch can ask + /// again, which is the outcome a retry can fix. + /// - the framework is FORGETTING -- [Invites#reset] or an erasure. An + /// unconsumed referrer is still an exact code naming an inviter, and + /// Play answers the same install for as long as the flag is unburnt, so + /// one left behind re-attributes the device afterwards and undoes + /// exactly what was erased. + /// + /// #### Returns + /// + /// true when nothing is left that could answer again. An erasure is + /// REFUSED on false, for the same reason the App Clip handoff is: + /// reporting an erasure that did not happen is worse than failing one + /// that can be retried. A source with no flag answers true. + boolean discardReferrer(); } diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 524b591f2c8..c00e7cf679b 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1265,7 +1265,7 @@ private static boolean anythingSurvives() { // our storage returned false and latched nothing: no durable marker, // nothing blocked, nothing retrying, and the surviving code read by // the next launch. - if (handoffSurvived) { + if (handoffSurvived || referrerSurvived) { return true; } Map record = InviteStore.read(InviteStore.PENDING); @@ -1326,6 +1326,15 @@ static boolean resetVerified() { // re-attributes from. That is the one failure this method exists to // refuse to hide. boolean cleared = discardAnyHandoff(); + // The Play referrer too, and the case that needs it is the one where + // it was never CONSUMED. + // + // A reset before the first checkForInvite() -- an early logout, a + // privacy reset -- cleared the records and left the source's one-shot + // flag unburnt. Play answers the same install for as long as that flag + // is unset, so the next check read the original referrer and restored + // exactly the attribution the reset promised to forget. + cleared &= discardAnyReferrer(); cleared &= InviteStore.delete(InviteStore.PENDING); forgetPendingFallback(); // ATTRIBUTION names the inviter, and the OUTBOX is the queued @@ -1820,6 +1829,16 @@ static String extractCode(String url) { // query string with no host at all, and it calls codeFromQuery() // directly, so nothing about that path changes. int q = url.indexOf('?'); + // HTTPS only, and the scheme is checked before the host. + // + // An application that forwards its broader deep links here could hand + // over myapp://cloud.codenameone.com/i/CODE or the http:// form, and a + // host-only test accepted both: the code was persisted and claimed + // although nothing the framework mints or the platforms associate is + // anything but https. The host being right is what made it look safe. + if (!url.regionMatches(true, 0, "https://", 0, 8)) { + return null; + } String host = hostOf(url); if (host == null) { return null; @@ -2206,6 +2225,38 @@ private static boolean writePending(Map record) { /// Separate from `ackHandoff` because the obligation flag does not apply: /// forgetting has to reach a handoff this process never read, and there is /// no record to check a codeSource against. + /// Tells the install-referrer source to burn its one-shot flag, whatever + /// the framework's reason. + /// + /// Separate from `ackReferrer` for the reason `discardAnyHandoff` is + /// separate from `ackHandoff`: forgetting has to reach a referrer this + /// process never read, and there is no record to check a codeSource + /// against. + private static boolean discardAnyReferrer() { + InstallReferrerSource source = referrerSource; + if (source == null) { + referrerAwaitingAck = false; + return true; + } + boolean gone; + try { + gone = source.discardReferrer(); + } catch (Throwable t) { + Log.e(t); + gone = false; + } + if (gone) { + referrerAwaitingAck = false; + } + referrerSurvived = !gone; + return gone; + } + + // True when the last discard left the referrer readable. Play answers the + // same install until the source's flag is burnt, so this is the only way a + // reset can tell that something survived it. + private static boolean referrerSurvived; + private static boolean discardAnyHandoff() { AppClipHandoffSource source = appClipSource; if (source == null) { @@ -2249,16 +2300,17 @@ private static void ackReferrer(Map record) { referrerAwaitingAck = false; return; } - boolean told = true; + boolean gone; try { - source.referrerPersisted(); + gone = source.discardReferrer(); } catch (Throwable t) { Log.e(t); - told = false; + gone = false; } - if (told) { + if (gone) { referrerAwaitingAck = false; } + referrerSurvived = !gone; } // True when a referrer has been handed over and not yet made durable. The diff --git a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java index a9a9dd7d8c2..d7d09c9e920 100644 --- a/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java +++ b/Ports/Android/src/com/codename1/impl/android/referrer/AndroidInstallReferrer.java @@ -332,7 +332,7 @@ private void deliver(int issued, InstallReferrerClient client, } return; } - // The handoff only. The flag is burnt by referrerPersisted(), which + // The handoff only. The flag is burnt by discardReferrer(), which // the framework calls once the code is in durable storage. // // Burning it here lost the exact code whenever the process died first: @@ -356,8 +356,14 @@ private void deliver(int issued, InstallReferrerClient client, /// lands leaves it unset, and the next launch asks Play again instead of /// losing the referrer for good. @Override - public void referrerPersisted() { + public boolean discardReferrer() { Preferences.set(PREF_ATTEMPTED, true); + // READ BACK, because Preferences.set() answers nothing. A store that + // refused leaves the marker absent for good, and Play then returns the + // same install referrer on a later launch -- restoring an attribution + // an erasure had removed. The caller gates that erasure on this, so + // "I called set()" is not the answer it needs. + return Preferences.get(PREF_ATTEMPTED, false); } /// The one-shot flag is burnt by the exchange that ANSWERED, and only by diff --git a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java index 8d73ac24deb..4a6e9c47ce2 100644 --- a/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java +++ b/Ports/JavaSE/src/com/codename1/impl/javase/JavaSEPort.java @@ -7578,9 +7578,10 @@ public void requestReferrer( } @Override - public void referrerPersisted() { + public boolean discardReferrer() { // The simulator has no one-shot flag to burn: the menu // item is the trigger, and it can be used again. + return true; } }); Display.getInstance().callSerially(new Runnable() { diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java index 83789ed9fc0..caabd2312ff 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java @@ -616,7 +616,8 @@ public boolean isSupported() { return true; } - public void referrerPersisted() { + public boolean discardReferrer() { + return true; } public void requestReferrer(InstallReferrerCallback callback) { diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java index a09ad1dfc70..e3667a22f0a 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java @@ -125,7 +125,8 @@ public boolean isSupported() { return true; } - public void referrerPersisted() { + public boolean discardReferrer() { + return true; } public void requestReferrer(InstallReferrerCallback callback) { @@ -172,7 +173,8 @@ public boolean isSupported() { return true; } - public void referrerPersisted() { + public boolean discardReferrer() { + return true; } public void requestReferrer(InstallReferrerCallback callback) { diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 5ef2d715a1d..fb06cdfe05c 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -646,7 +646,8 @@ public boolean isSupported() { return true; } - public void referrerPersisted() { + public boolean discardReferrer() { + return true; } public void requestReferrer(InstallReferrerCallback callback) { @@ -687,7 +688,8 @@ public boolean isSupported() { return true; } - public void referrerPersisted() { + public boolean discardReferrer() { + return true; } public void requestReferrer(InstallReferrerCallback callback) { @@ -731,7 +733,8 @@ public boolean isSupported() { return true; } - public void referrerPersisted() { + public boolean discardReferrer() { + return true; } public void requestReferrer(InstallReferrerCallback callback) { @@ -998,7 +1001,8 @@ public boolean isSupported() { return true; } - public void referrerPersisted() { + public boolean discardReferrer() { + return true; } public void requestReferrer(InstallReferrerCallback callback) { @@ -1144,7 +1148,8 @@ public boolean isSupported() { return true; } - public void referrerPersisted() { + public boolean discardReferrer() { + return true; } public void requestReferrer(InstallReferrerCallback callback) { @@ -1163,7 +1168,8 @@ public boolean isSupported() { return true; } - public void referrerPersisted() { + public boolean discardReferrer() { + return true; } public void requestReferrer(InstallReferrerCallback callback) { @@ -1196,7 +1202,8 @@ public boolean isSupported() { return true; } - public void referrerPersisted() { + public boolean discardReferrer() { + return true; } public void requestReferrer(InstallReferrerCallback callback) { @@ -1302,7 +1309,8 @@ public boolean isSupported() { return true; } - public void referrerPersisted() { + public boolean discardReferrer() { + return true; } public void requestReferrer(InstallReferrerCallback callback) { @@ -1539,7 +1547,8 @@ public boolean isSupported() { return true; } - public void referrerPersisted() { + public boolean discardReferrer() { + return true; } public void requestReferrer(InstallReferrerCallback callback) { @@ -1775,7 +1784,8 @@ public boolean isSupported() { return true; } - public void referrerPersisted() { + public boolean discardReferrer() { + return true; } public void requestReferrer(InstallReferrerCallback callback) { @@ -1890,7 +1900,8 @@ public boolean isSupported() { return true; } - public void referrerPersisted() { + public boolean discardReferrer() { + return true; } public void requestReferrer(InstallReferrerCallback callback) { @@ -2005,7 +2016,8 @@ public boolean isSupported() { return !spent; } - public void referrerPersisted() { + public boolean discardReferrer() { + return true; } public void requestReferrer(InstallReferrerCallback callback) { diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java index aa4e4e7de35..a5fe425232f 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java @@ -239,4 +239,20 @@ void theLinkBaseMustBeAnHttpsOrigin() { assertEquals("https://links.example.com", Invites.getLinkBase()); Invites.setLinkBase(null); } + + @FormTest + void onlyHttpsUrlsCarryInvites() { + // An application forwarding its broader deep links here could hand + // over a custom scheme or plain http on the right host, and a + // host-only test accepted both -- persisting and claiming a code + // although nothing the framework mints or the platforms associate is + // anything but https. + assertNull(Invites.extractCode("myapp://cloud.codenameone.com/i/SCHEME1"), + "a custom-scheme url was accepted as an invite"); + assertNull(Invites.extractCode("http://cloud.codenameone.com/i/PLAIN1"), + "an http url was accepted as an invite"); + assertEquals("REAL123", + Invites.extractCode("https://cloud.codenameone.com/i/REAL123"), + "the https form stopped being recognised"); + } } From 9c1a9e4792ee4e00b56e4fcabdc6e20bcd89429f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 04:18:40 +0300 Subject: [PATCH 93/99] Invites: the registration carries the time the device minted the code The server stamped createdAt when the registration arrived, and an invite minted offline registers when the network comes back. Everything that asks "was this person here before the invite existed" then compared against the wrong instant, so the recipient's own post-install events fell before it and a genuine acquisition was classified as a prior user -- dropping out of the ranking referral bounties are paid from. The device offers its own mint time alongside the proof. It is not trusted: the server clamps a future time and one older than the code's TTL back to its own clock. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/com/codename1/analytics/invite/Invites.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index c00e7cf679b..41ad10f0b9e 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -3956,6 +3956,19 @@ private static boolean queueRegistration(Invite invite, InviteRequest request, // code. Held nowhere else: the outbox goes with an erasure, and the // proof goes with it. body.put("proof", proof); + // The mint time as the DEVICE saw it, which is the only record of when + // an offline invite was actually created. + // + // The server stamped createdAt at registration, and for an invite + // minted offline that can be hours or days late. Everything downstream + // that asks "was this person already here before the invite existed" + // then compares against the wrong instant: the recipient's own + // post-install events fall BEFORE it, the genuine acquisition is + // marked a prior user, and it drops out of the ranking referral + // bounties are paid from. + // + // A device clock is not trusted, only offered -- the server clamps it. + body.put("createdAt", Long.valueOf(invite.getCreatedTimestamp())); putIfSet(body, "campaign", invite.getCampaign()); putIfSet(body, "channel", invite.getChannel()); putIfSet(body, "payload", invite.getPayload()); From ffce0c8de2e62232547dca5d8ba59a7927061fda Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 04:57:26 +0300 Subject: [PATCH 94/99] Install the erasure hook at source registration, and stop leaking a receiver per cancelled share Two review findings, both real. A privacy reset during startup did not reach the referrer. resetClientId() is observed by being a registered provider, and the provider was installed only by an Invites entry point -- so an application that resets identity from its own init(), before anything invite-related runs, reset it with no provider registered. init() never saw the change and the erasure never ran. The first checkForInvite() afterwards then found no baseline and no durable records -- the Play referrer is Play's, the handoff is the App Clip container's, neither is ours -- adopted the already-reset id as its baseline, and attributed the pre-reset referral to the identity the user had just asked for. resetVerified() has discarded an unconsumed referrer for a while. Nothing was calling it. The hook now goes in when a platform source is registered, which both builders splice into the stub inside a Display.callSerially immediately before the application's own init(this) -- on the EDT, with Storage up, and still ahead of any line of application code. Only when a source is actually being installed: passing null removes one, which is what the tests do in teardown. Separately, every cancelled share leaked a BroadcastReceiver. buildShareChooserWithCallback unregisters from inside onReceive, and Android sends nothing when the chooser is dismissed, so a cancel left the receiver registered on the application context holding the listener, the button and its form -- one more on every cancel, for the life of the process. It is fixed in the port rather than in InviteButton because every ShareButton with a result listener had it, invites or not; InviteButton only made the path unconditional, which it has to be, because invite_shared reports the package the user actually picked. One receiver is now reused, so a cancel replaces the held listener instead of adding to a pile. It cannot be driven to zero from here: knowing the chooser was dismissed is the thing Android does not tell us. The fields are per-instance, not static. A lazily initialised static is a different claim, and SpotBugs reads it as a threading bug -- correctly, because nothing here would make it safe if it were true. Both fixes are revert-probed: with the hook removed the new test fails on "the reset did not reach the referrer", and the writer probe in the other repo fails the same way. Also fixes the three forbidden PMD violations that failed build-test (8): an iterator loop that reads as a foreach, its fully qualified Map.Entry, and a test seam whose guarded assignment matched NonThreadSafeSingleton. The last is rewritten as an unconditional assignment rather than given a lock -- this runs on the EDT like the rest of the framework. Co-Authored-By: Claude Opus 5 (1M context) --- .../invite/InviteAttributionProvider.java | 5 +- .../analytics/invite/InviteRequest.java | 4 +- .../analytics/invite/InviteStore.java | 9 +- .../codename1/analytics/invite/Invites.java | 29 + .../codename1/components/InviteButton.java | 14 + .../impl/android/AndroidImplementation.java | 36958 ++++++++-------- .../invite/InviteConsentAndErasureTest.java | 75 + 7 files changed, 18635 insertions(+), 18459 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java b/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java index acdda2ffc01..bdb93d2aaef 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteAttributionProvider.java @@ -52,7 +52,10 @@ final class InviteAttributionProvider extends AbstractAnalyticsProvider { // The last client id this provider saw. A change means resetClientId() // ran, which is what an erasure request looks like from here. - private static final String PREF_LAST_CLIENT_ID = "cn1$inviteLastClientId"; + // Package private: a test models a genuinely first launch by removing it, + // which is the state that decides whether a reset is seen as an identity + // change at all. + static final String PREF_LAST_CLIENT_ID = "cn1$inviteLastClientId"; @Override public String getName() { diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteRequest.java b/CodenameOne/src/com/codename1/analytics/invite/InviteRequest.java index 67fd64e62a4..90a39539364 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteRequest.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteRequest.java @@ -314,9 +314,7 @@ public InviteRequest build() { throw new IllegalArgumentException( "an invite carries at most " + MAX_PARAMETERS + " parameters"); } - for (java.util.Iterator> it = - parameters.entrySet().iterator(); it.hasNext();) { - java.util.Map.Entry e = it.next(); + for (Map.Entry e : parameters.entrySet()) { checkLength("parameter name", e.getKey(), MAX_PARAM_KEY_LENGTH); checkLength("parameter " + e.getKey(), e.getValue(), MAX_PARAM_VALUE_LENGTH); } diff --git a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java index 8db7336c733..09dffb4fc67 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java +++ b/CodenameOne/src/com/codename1/analytics/invite/InviteStore.java @@ -148,9 +148,12 @@ static void failNextDeleteForTest(String name) { static boolean write(String record, Map values) { if (record != null && record.equals(failNextNamed)) { failNamedRemaining--; - if (failNamedRemaining <= 0) { - failNextNamed = null; - } + // Assigned unconditionally rather than inside an if. The guarded + // form is the shape PMD reads as a lazily initialised singleton, + // and the answer to that is not a lock -- this runs on the EDT + // like the rest of the framework, and it is a test seam, not a + // singleton. + failNextNamed = failNamedRemaining > 0 ? failNextNamed : null; return false; } try { diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 41ad10f0b9e..6a0099dd558 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -297,6 +297,29 @@ private static boolean lookupInFlight() { private Invites() { } + // The erasure hook goes in HERE, not at the first Invites entry point. + // + // Analytics.resetClientId() is observed by being a registered provider, + // and ensureProvider() used to run only from a facade call. An application + // that resets identity during startup -- an early privacy reset, a logout + // that runs before anything invite-related -- therefore reset it with no + // provider registered, so init() never saw the change and eraseInternal() + // never ran. The first checkForInvite() afterwards then found no baseline + // and no durable records (the referrer is Play's, not ours, and the + // handoff is the App Clip container's), adopted the already-reset id as + // its baseline, and went on to attribute the pre-reset referral to the new + // identity. resetVerified() has discarded an unconsumed referrer for a + // while; nothing was calling it. + // + // Safe here, and this is the reason: both builders splice these calls into + // the stub inside a Display.callSerially, immediately before the + // application's own init(this). So this runs on the EDT with Display and + // Storage already up, and still before any line of application code that + // could reset anything. + // + // Only when a source is actually being installed. Passing null removes + // one, which is what the tests do in teardown, and registering a provider + // on the way out is not what that means. /// Registers the platform hook that reads the application store's install /// referrer. The Codename One build calls this before the application /// starts on platforms that have one; an application does not. @@ -306,6 +329,9 @@ private Invites() { /// - `source`: the platform source, or null to remove it public static void registerInstallReferrerSource(InstallReferrerSource source) { referrerSource = source; + if (source != null) { + ensureProvider(); + } } /// Registers the platform hook that reads the invite code an iOS App Clip @@ -317,6 +343,9 @@ public static void registerInstallReferrerSource(InstallReferrerSource source) { /// - `source`: the platform source, or null to remove it public static void registerAppClipHandoffSource(AppClipHandoffSource source) { appClipSource = source; + if (source != null) { + ensureProvider(); + } } // ---- sending --------------------------------------------------------- diff --git a/CodenameOne/src/com/codename1/components/InviteButton.java b/CodenameOne/src/com/codename1/components/InviteButton.java index e122ab7e580..89799e66b07 100644 --- a/CodenameOne/src/com/codename1/components/InviteButton.java +++ b/CodenameOne/src/com/codename1/components/InviteButton.java @@ -97,6 +97,20 @@ public InviteButton(String text) { // application's listener in a field of our own -- without that, setting a // listener would silently replace the chain and the funnel would lose // every share. + // Unconditional, and it has to be. invite_shared is a MEASUREMENT -- it + // carries the package the user actually picked -- so a press with no + // listener installed could only report that a chooser was opened, which is + // the assumption this event exists to replace. + // + // What that costs on Android is one dynamically registered receiver, and + // it used to be one PER PRESS: the receiver unregisters itself from inside + // onReceive, and a dismissed chooser sends nothing, so every cancelled + // share left one behind holding this chain, the button and its form. + // AndroidImplementation.buildShareChooserWithCallback now reuses a single + // receiver for the process, so a cancel replaces the held listener instead + // of adding to a pile of them. The fix is there rather than here because + // every ShareButton with a result listener had the same leak, invites or + // not. private void installChain() { chain = new ShareResultListener() { @Override diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index e0dbb9f819b..7eb89dee225 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -1,18452 +1,18506 @@ -/* - * 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.Manifest; -import android.annotation.TargetApi; -import com.codename1.impl.android.permissions.DevicePermission; -import com.codename1.impl.android.permissions.PermissionsHelper; -import com.codename1.location.AndroidLocationManager; -import android.app.*; -import android.content.pm.PackageManager.NameNotFoundException; -import android.media.AudioTimestamp; -import android.support.v4.content.ContextCompat; -import android.view.MotionEvent; -import com.codename1.codescan.ScanResult; -import com.codename1.media.Media; -import com.codename1.ui.geom.Dimension; - - -import android.webkit.CookieSyncManager; -import android.content.*; -import android.content.pm.*; -import android.content.res.AssetFileDescriptor; -import android.content.res.Configuration; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.Canvas; -import android.graphics.Paint; -import android.graphics.Rect; -import android.graphics.Typeface; -import android.graphics.Path; -import android.graphics.drawable.Drawable; -import android.media.AudioManager; -import android.net.Uri; -import android.os.Vibrator; -import android.os.PowerManager; -import android.provider.Settings; -import android.telephony.TelephonyManager; -import android.util.DisplayMetrics; -import android.util.Log; -import android.util.TypedValue; -import android.view.KeyEvent; -import android.view.View; -import android.view.ViewGroup; -import android.view.accessibility.AccessibilityManager; -import android.view.Window; -import android.webkit.WebSettings; -import android.webkit.WebView; -import android.webkit.WebViewClient; -import android.widget.RelativeLayout; -import android.widget.TextView; -import com.codename1.ui.BrowserComponent; -import com.codename1.ui.AccessibilityColorVisionDeficiency; - -import com.codename1.ui.Component; -import com.codename1.ui.Font; -import com.codename1.ui.Image; -import com.codename1.ui.PeerComponent; -import com.codename1.ui.ClipboardContent; -import com.codename1.ui.ClipboardDataProvider; -import com.codename1.ui.events.ActionEvent; -import com.codename1.impl.CodenameOneImplementation; -import com.codename1.impl.VirtualKeyboardInterface; -import com.codename1.ui.plaf.UIManager; -import com.codename1.ui.util.Resources; -import java.lang.ref.SoftReference; -import java.lang.reflect.Method; -import java.net.URISyntaxException; -import java.nio.charset.StandardCharsets; -import java.util.Vector; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.graphics.Matrix; -import android.graphics.drawable.BitmapDrawable; -import android.hardware.Camera; -import android.media.AudioFormat; -import android.media.AudioRecord; -import android.media.ExifInterface; -import android.media.MediaPlayer; -import android.media.MediaRecorder; -import android.net.ConnectivityManager; -import android.net.NetworkInfo; -import android.os.Build; -import android.os.Bundle; -import android.os.PersistableBundle; -import android.os.Environment; -import android.os.Handler; -import android.os.IBinder; -import android.os.Looper; -import android.os.RemoteException; -import android.provider.MediaStore; -import android.provider.Settings; -import android.provider.Settings.Secure; -import android.renderscript.Allocation; -import android.renderscript.Element; -import android.renderscript.RenderScript; -import android.renderscript.ScriptIntrinsicBlur; -import android.support.v4.app.NotificationCompat; -import android.support.v4.content.FileProvider; -import android.support.v4.media.MediaBrowserCompat; -import android.support.v4.media.session.MediaControllerCompat; -import android.support.v4.media.session.PlaybackStateCompat; -import android.telephony.SmsManager; -import android.telephony.gsm.GsmCellLocation; -import android.text.Html; -import android.view.*; -import android.view.View.MeasureSpec; -import android.view.accessibility.AccessibilityEvent; -import android.view.accessibility.AccessibilityManager; -import android.webkit.*; -import android.widget.*; -import com.codename1.background.BackgroundFetch; -import com.codename1.capture.VideoCaptureConstraints; -import com.codename1.codescan.CodeScanner; -import com.codename1.contacts.Contact; -import com.codename1.db.Database; -import com.codename1.impl.android.compat.app.NotificationCompatWrapper; -import com.codename1.impl.android.compat.app.NotificationCompatWrapper.ActionWrapper; -import com.codename1.impl.android.compat.app.RemoteInputWrapper; -import com.codename1.io.BufferedInputStream; -import com.codename1.io.BufferedOutputStream; -import com.codename1.io.*; -import com.codename1.l10n.L10NManager; -import com.codename1.location.LocationManager; -import com.codename1.media.AbstractMedia; -import com.codename1.media.AsyncMedia; -import com.codename1.media.AsyncMedia.MediaErrorType; -import com.codename1.media.AsyncMedia.MediaException; -import com.codename1.media.Audio; -import com.codename1.media.AudioService; -import com.codename1.media.BackgroundAudioService; -import com.codename1.media.MediaProxy; -import com.codename1.media.MediaRecorderBuilder; -import com.codename1.messaging.Message; -import com.codename1.notifications.LocalNotification; -import com.codename1.notifications.NotificationChannelBuilder; -import com.codename1.notifications.NotificationPermissionCallback; -import com.codename1.notifications.NotificationPermissionRequest; -import com.codename1.notifications.NotificationPermissionResult; -import com.codename1.background.ForegroundService; -import com.codename1.background.WorkRequest; -import com.codename1.share.SharedContent; -import com.codename1.payment.Purchase; -import com.codename1.push.PushAction; -import com.codename1.push.PushActionCategory; -import com.codename1.push.PushActionsProvider; -import com.codename1.push.PushCallback; -import com.codename1.push.PushContent; -import com.codename1.ui.*; -import com.codename1.ui.Dialog; -import com.codename1.ui.Display; -import com.codename1.ui.animations.Animation; -import com.codename1.ui.animations.CommonTransitions; -import com.codename1.ui.events.ActionListener; -import com.codename1.ui.geom.GeneralPath; -import com.codename1.ui.geom.Rectangle; -import com.codename1.ui.geom.Shape; -import com.codename1.ui.layouts.BorderLayout; -import com.codename1.ui.plaf.Style; -import com.codename1.ui.util.EventDispatcher; -import com.codename1.util.AsyncResource; -import com.codename1.util.Callback; -import java.io.File; -import java.io.BufferedReader; -import java.io.FileDescriptor; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.io.OutputStreamWriter; -import java.io.PrintWriter; -import java.io.RandomAccessFile; -import java.nio.channels.FileLock; -import java.io.Writer; -import java.lang.reflect.Constructor; -import java.net.HttpURLConnection; -import java.net.URI; -import java.net.URL; -import java.net.URLConnection; -import java.text.DateFormat; -import java.text.NumberFormat; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Date; -import java.util.Hashtable; -import java.util.List; -import java.util.Locale; -import java.util.Set; -import java.util.logging.Level; -import java.util.logging.Logger; -import com.codename1.util.StringUtil; -import com.codename1.util.SuccessCallback; -import java.io.*; -import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Modifier; -import java.net.CookieHandler; -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.NetworkInterface; -import java.net.ServerSocket; -import java.security.MessageDigest; -import java.text.ParseException; -import java.util.*; -import java.util.concurrent.atomic.AtomicLong; -import javax.net.ssl.HttpsURLConnection; -import javax.xml.parsers.ParserConfigurationException; - -import org.json.JSONException; -import org.json.JSONObject; -import org.json.JSONStringer; -import org.xml.sax.SAXException; -//import android.webkit.JavascriptInterface; - -public class AndroidImplementation extends CodenameOneImplementation implements IntentResultListener { - private AndroidCalendarSource calendarSource; - private static final AtomicLong V3_NOTIFICATION_SEQUENCE = new AtomicLong(); - - public static final Thread.UncaughtExceptionHandler exceptionHandler = new Thread.UncaughtExceptionHandler() { - @Override - public void uncaughtException(Thread t, Throwable e) { - try { - com.codename1.crash.CrashProtection.capture(e); - } catch (Throwable ignore) { - } - } - }; - - public static final int FLAG_ONE_SHOT = 0x40000000; - public static final int FLAG_MUTABLE = 0x02000000; - - public static final int FLAG_IMMUTABLE = 0x04000000; - - /** - * make sure these important keys have a negative value when passed to - * Codename One or they might be interpreted as characters. - */ - static final int DROID_IMPL_KEY_LEFT = -23446; - static final int DROID_IMPL_KEY_RIGHT = -23447; - static final int DROID_IMPL_KEY_UP = -23448; - static final int DROID_IMPL_KEY_DOWN = -23449; - static final int DROID_IMPL_KEY_FIRE = -23450; - static final int DROID_IMPL_KEY_MENU = -23451; - static final int DROID_IMPL_KEY_BACK = -23452; - static final int DROID_IMPL_KEY_BACKSPACE = -23453; - static final int DROID_IMPL_KEY_CLEAR = -23454; - static final int DROID_IMPL_KEY_SEARCH = -23455; - static final int DROID_IMPL_KEY_CALL = -23456; - static final int DROID_IMPL_KEY_VOLUME_UP = -23457; - static final int DROID_IMPL_KEY_VOLUME_DOWN = -23458; - static final int DROID_IMPL_KEY_MUTE = -23459; - static final int DROID_IMPL_KEY_ENTER = -23460; - static final int DROID_IMPL_KEY_TAB = -23461; - static final int DROID_IMPL_KEY_ESCAPE = -23462; - static final int DROID_IMPL_KEY_HOME = -23463; - static final int DROID_IMPL_KEY_END = -23464; - static final int DROID_IMPL_KEY_PAGE_UP = -23465; - static final int DROID_IMPL_KEY_PAGE_DOWN = -23466; - static final int DROID_IMPL_KEY_INSERT = -23467; - static final int DROID_IMPL_KEY_FORWARD_DEL = -23468; - static final int DROID_IMPL_KEY_F1 = -23469; - static final int DROID_IMPL_KEY_F2 = -23470; - static final int DROID_IMPL_KEY_F3 = -23471; - static final int DROID_IMPL_KEY_F4 = -23472; - static final int DROID_IMPL_KEY_F5 = -23473; - static final int DROID_IMPL_KEY_F6 = -23474; - static final int DROID_IMPL_KEY_F7 = -23475; - static final int DROID_IMPL_KEY_F8 = -23476; - static final int DROID_IMPL_KEY_F9 = -23477; - static final int DROID_IMPL_KEY_F10 = -23478; - static final int DROID_IMPL_KEY_F11 = -23479; - static final int DROID_IMPL_KEY_F12 = -23480; - static int[] leftSK = new int[]{DROID_IMPL_KEY_MENU}; - - /** - * @return the activity - */ - public static CodenameOneActivity getActivity() { - return activity; - } - - // ---- low level text input source (pure Codename One editors) ---- - - private static volatile com.codename1.ui.TextInputClient activeInputClient; - private static volatile com.codename1.ui.TextInputState activeInputState; - private static volatile com.codename1.ui.TextInputConfig activeInputConfig; - /// Synchronous mirror of edits the input connection has posted but the EDT has not yet - /// applied and echoed back. IMEs (notably Gboard) commit text and immediately re-read the - /// surrounding text; without this mirror they would see pre-commit text and desync their - /// suggestion model. Cleared when the authoritative state from the EDT has caught up with - /// every posted edit (the seq pair below). - private static volatile com.codename1.ui.TextInputState pendingInputState; - /// Generation of the last edit the input connection posted (written on the IME thread). - private static volatile int pendingPostedSeq; - /// Generation of the last posted edit the EDT applied (written on the EDT). - private static volatile int pendingAppliedSeq; - - /// Returns the editing state as the IME must see it right now: the pending synchronous - /// mirror when an edit is in flight, otherwise the last state pushed from the EDT. - static com.codename1.ui.TextInputState currentInputState() { - com.codename1.ui.TextInputState pending = pendingInputState; - return pending != null ? pending : activeInputState; - } - - /// Records the input connection's synchronous mirror of an in-flight edit and returns the - /// edit's generation; the connection marks it applied from the EDT runnable that delivers - /// the edit to the client. - static int setPendingInputState(com.codename1.ui.TextInputState state) { - pendingInputState = state; - return ++pendingPostedSeq; - } - - /// Marks a posted edit as applied on the EDT (called right before the client mutation whose - /// state push may then retire the mirror). - static void markPendingApplied(int seq) { - pendingAppliedSeq = seq; - } - - /// Routes a hardware (Bluetooth / Chromebook) key event to the bound text input client. - /// Hardware keys bypass the IME entirely, and the pure editor's raw key path is disabled - /// while a platform session is active, so without this they would be silently dropped. - /// Returns true when the event was consumed for the client (including the matching key-up - /// of a consumed key-down); false leaves the event to the regular Codename One pipeline - /// (BACK, D-pad game keys on non-editor forms, ...). - static boolean routeHardwareKeyToActiveClient(boolean down, android.view.KeyEvent event) { - com.codename1.ui.TextInputClient client = activeInputClient; - if (client == null || event == null) { - return false; - } - return CN1TextInputConnection.deliverHardwareKey(client, event, down); - } - - /// Re-requests the soft keyboard for the bound text input client. Called on every tap so a - /// keyboard the user dismissed (back gesture) returns when the editor is tapped again, the - /// same behavior a native EditText has. No-op when no client is bound. - static void showSoftInputForActiveClient() { - if (activeInputClient == null) { - return; - } - final CodenameOneActivity a = getActivity(); - final CodenameOneSurface view = instance != null ? instance.myView : null; - if (a == null || view == null) { - return; - } - a.runOnUiThread(new Runnable() { - public void run() { - if (activeInputClient == null) { - return; - } - android.view.View v = view.getAndroidView(); - v.requestFocus(); - android.view.inputmethod.InputMethodManager imm = (android.view.inputmethod.InputMethodManager) - a.getSystemService(android.content.Context.INPUT_METHOD_SERVICE); - if (imm != null) { - imm.showSoftInput(v, 0); - } - } - }); - } - - static com.codename1.ui.TextInputConfig currentInputConfig() { - return activeInputConfig; - } - - /// Called by the rendering view's `onCreateInputConnection` to supply the custom input connection - /// when a pure editor is bound. Returns null when no client is active so the view keeps its default - /// behavior. - static android.view.inputmethod.InputConnection createEditorInputConnection(android.view.View view, android.view.inputmethod.EditorInfo editorInfo) { - com.codename1.ui.TextInputClient client = activeInputClient; - if (client == null) { - return null; - } - configureEditorInfo(editorInfo, activeInputConfig); - return new CN1TextInputConnection(view, client); - } - - /// True when a pure editor text input client is currently bound. - static boolean hasActiveInputClient() { - return activeInputClient != null; - } - - /// The Android autofill hint for a one-time code, spelled out rather than referenced as - /// `View.AUTOFILL_HINT_SMS_OTP` because the constant is newer than the SDK this port - /// compiles against. The string is the contract: it is what an autofill service matches on. - private static final String AUTOFILL_HINT_SMS_OTP = "smsOTPCode"; - - /// What the platform may fill into the currently bound field, or null when it is not a field - /// the platform can fill. - /// - /// Only the one-time code is offered. The rendering surface is a single view standing in for - /// whichever field is being edited, so claiming a hint puts the whole surface forward as that - /// kind of field -- true only while the code field holds the session, which is why the hint is - /// applied when a session starts and dropped when it ends. - private static String[] editorAutofillHints() { - com.codename1.ui.TextInputConfig cfg = activeInputConfig; - if (cfg != null && (cfg.getConstraint() & com.codename1.ui.TextArea.ONE_TIME_CODE) != 0) { - return new String[]{AUTOFILL_HINT_SMS_OTP}; - } - return null; - } - - /// Puts the surface forward as an autofillable field, or withdraws it, to match the field the - /// input session is bound to. Called on the UI thread as a session starts and stops. - /// - /// #### Parameters - /// - /// - `v`: the rendering view - /// - /// - `sessionActive`: true while a client is bound - static void updateEditorAutofill(android.view.View v, boolean sessionActive) { - if (v == null || android.os.Build.VERSION.SDK_INT < 26) { - return; - } - android.view.autofill.AutofillManager afm = - (android.view.autofill.AutofillManager) v.getContext() - .getSystemService(android.view.autofill.AutofillManager.class); - String[] hints = sessionActive ? editorAutofillHints() : null; - if (hints == null) { - v.setImportantForAutofill(android.view.View.IMPORTANT_FOR_AUTOFILL_NO); - v.setAutofillHints((String[]) null); - if (afm != null) { - afm.notifyViewExited(v); - } - return; - } - v.setAutofillHints(hints); - v.setImportantForAutofill(android.view.View.IMPORTANT_FOR_AUTOFILL_YES); - if (afm != null) { - // the session only starts once the framework is told the view was entered; a view - // that merely carries hints is never offered anything - afm.notifyViewEntered(v); - } - } - - /// Applies a value the platform filled in, replacing whatever the field held. Called by the - /// rendering view on the UI thread; the edit itself belongs to the EDT. - /// - /// #### Parameters - /// - /// - `value`: the value the autofill service supplied - /// - /// #### Returns - /// - /// true when the value was taken - static boolean autofillEditor(android.view.autofill.AutofillValue value) { - final com.codename1.ui.TextInputClient client = activeInputClient; - if (client == null || value == null || !value.isText()) { - return false; - } - // Only into a field that asked for this. The hint lives on the surface and is put - // there and taken away on Android's UI thread, while the session it describes changes - // on the EDT, so for a moment after the user moves from a code field to an ordinary - // one the view still advertises smsOTPCode while the session behind it is something - // else. A fill delivered in that gap would otherwise land a code in whatever the user - // tapped into. Asking what the CURRENT session advertises closes it: the answer is - // read from the same field the identity check below uses. - if (editorAutofillHints() == null) { - return false; - } - com.codename1.ui.Display.getInstance().callSerially( - new ApplyAutofilledText(client, value.getTextValue().toString())); - return true; - } - - private static final class ApplyAutofilledText implements Runnable { - private final com.codename1.ui.TextInputClient client; - private final String text; - - ApplyAutofilledText(com.codename1.ui.TextInputClient client, String text) { - this.client = client; - this.text = text; - } - - public void run() { - // The session may be gone: the platform fills on the UI thread and this runs a hop - // later on the EDT, and in between the user can have moved to another field or left - // the screen. Applying it then would edit a field nothing is bound to any more and - // fire its listeners -- and an OtpField's completion listener submits a code, so a - // late fill would verify one for a flow the user has already left. The rest of this - // bridge guards its callbacks the same way. - if (client != activeInputClient || editorAutofillHints() == null) { - return; - } - // A filled value replaces the field rather than being inserted at the caret: the - // platform is answering "the value is this", not typing into what is there. It - // still arrives as a commit rather than a raw range replacement, because a field - // filters what it accepts and a filled value has no more right to bypass that - // than a typed one -- an OTP field asked for six digits and can be handed - // "123-456" by an autofill service that kept the separator, and a replacement - // would leave the field holding a value it would never have let anyone type, - // never reaching the length that completes it. - // Ending any composition first. A commit replaces the composed range in - // preference to the selection, so selecting the whole field is not enough to - // replace the whole field while an input method is mid-word: the filled value - // would land inside the composition and leave whatever surrounded it, which - // for a code field means a full-length wrong code that submits itself. - client.finishComposing(); - client.setSelectionRange(0, client.getTextLength()); - client.commitText(text); - } - } - - /// The value the platform should see for the bound field, or null when nothing is bound. - /// - /// Answered from the state snapshot rather than the editor itself. This runs on Android's UI - /// thread whenever an autofill service asks what the field holds, while the document belongs - /// to the EDT, and reading a length and then a range out of a document another thread is - /// editing is two reads of something that can change in between. Clamped offsets would not - /// rescue it either, since the buffer underneath can be restructured mid-read. The snapshot - /// is immutable and is what the rest of this bridge already uses to answer the platform - /// across that boundary; a value one edit out of date is the correct trade against a crash - /// inside somebody else's autofill query. - static android.view.autofill.AutofillValue editorAutofillValue() { - // Read the state AFTER the guards and confirm the session did not move under it. - // The three fields are assigned separately on the EDT, so taking the state first - // and validating afterwards can pair one field's text with the next field's - // configuration -- and the pairing that matters is a password field's text with a - // code field's hint. One session snapshot would express this better than three - // fields and a re-check, but that is the whole input bridge's shape rather than - // this method's, and the property needed here is only that nothing is returned - // for a session other than the one that was checked. - // - // Gated the same way the write path is, and for a sharper reason: between the EDT - // moving to another field and the UI thread taking the hint off the view, the - // surface still looks like a code field over a session that is something else -- - // and answering this query then would hand that field's text to an SMS autofill - // service. The field after a code field is as likely to be a password as anything. - com.codename1.ui.TextInputClient client = activeInputClient; - if (client == null || editorAutofillHints() == null) { - return null; - } - com.codename1.ui.TextInputState state = activeInputState; - if (state == null || client != activeInputClient) { - return null; - } - String text = state.getText(); - return android.view.autofill.AutofillValue.forText(text == null ? "" : text); - } - - private static void configureEditorInfo(android.view.inputmethod.EditorInfo editorInfo, com.codename1.ui.TextInputConfig cfg) { - int constraint = cfg == null ? 0 : cfg.getConstraint(); - int inputType; - switch (constraint & 0xffff) { - case com.codename1.ui.TextArea.NUMERIC: - inputType = android.text.InputType.TYPE_CLASS_NUMBER - | android.text.InputType.TYPE_NUMBER_FLAG_SIGNED; - break; - case com.codename1.ui.TextArea.DECIMAL: - inputType = android.text.InputType.TYPE_CLASS_NUMBER - | android.text.InputType.TYPE_NUMBER_FLAG_SIGNED - | android.text.InputType.TYPE_NUMBER_FLAG_DECIMAL; - break; - case com.codename1.ui.TextArea.PHONENUMBER: - inputType = android.text.InputType.TYPE_CLASS_PHONE; - break; - case com.codename1.ui.TextArea.EMAILADDR: - inputType = android.text.InputType.TYPE_CLASS_TEXT - | android.text.InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS; - break; - case com.codename1.ui.TextArea.URL: - inputType = android.text.InputType.TYPE_CLASS_TEXT - | android.text.InputType.TYPE_TEXT_VARIATION_URI; - break; - default: - inputType = android.text.InputType.TYPE_CLASS_TEXT; - break; - } - boolean text = (inputType & android.text.InputType.TYPE_MASK_CLASS) == android.text.InputType.TYPE_CLASS_TEXT; - boolean password = (constraint & com.codename1.ui.TextArea.PASSWORD) != 0; - if (password) { - inputType = text - ? inputType | android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD - : android.text.InputType.TYPE_CLASS_NUMBER | android.text.InputType.TYPE_NUMBER_VARIATION_PASSWORD; - text = (inputType & android.text.InputType.TYPE_MASK_CLASS) == android.text.InputType.TYPE_CLASS_TEXT; - } - boolean multiline = cfg == null || cfg.isMultiline(); - if (text) { - if (multiline) { - inputType |= android.text.InputType.TYPE_TEXT_FLAG_MULTI_LINE; - } - if (password || (cfg != null && !cfg.isAutoCorrect())) { - inputType |= android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS; - } - if (!password && cfg != null && cfg.isAutoCapitalize()) { - inputType |= android.text.InputType.TYPE_TEXT_FLAG_CAP_SENTENCES; - } - } - if ((constraint & com.codename1.ui.TextArea.ONE_TIME_CODE) != 0 && text) { - // a code is not a word: prediction would offer completions for it and, worse, learn it - inputType |= android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS; - } - editorInfo.inputType = inputType; - editorInfo.imeOptions = android.view.inputmethod.EditorInfo.IME_FLAG_NO_EXTRACT_UI; - if (multiline) { - editorInfo.imeOptions |= android.view.inputmethod.EditorInfo.IME_ACTION_NONE; - } else { - editorInfo.imeOptions |= imeActionFor(cfg == null - ? com.codename1.ui.TextInputConfig.ACTION_DEFAULT : cfg.getActionType()); - } - editorInfo.initialSelStart = activeInputState != null ? activeInputState.getSelectionStart() : 0; - editorInfo.initialSelEnd = activeInputState != null ? activeInputState.getSelectionEnd() : 0; - } - - private static int imeActionFor(int actionType) { - switch (actionType) { - case com.codename1.ui.TextInputConfig.ACTION_NEXT: - return android.view.inputmethod.EditorInfo.IME_ACTION_NEXT; - case com.codename1.ui.TextInputConfig.ACTION_SEARCH: - return android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH; - case com.codename1.ui.TextInputConfig.ACTION_SEND: - return android.view.inputmethod.EditorInfo.IME_ACTION_SEND; - case com.codename1.ui.TextInputConfig.ACTION_DONE: - default: - return android.view.inputmethod.EditorInfo.IME_ACTION_DONE; - } - } - - /// Maps an Android `EditorInfo.IME_ACTION_*` code back to the `TextInputConfig` action constant - /// delivered to `TextInputClient.onEditorAction`. - static int textInputActionFor(int imeActionCode) { - switch (imeActionCode) { - case android.view.inputmethod.EditorInfo.IME_ACTION_NEXT: - return com.codename1.ui.TextInputConfig.ACTION_NEXT; - case android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH: - return com.codename1.ui.TextInputConfig.ACTION_SEARCH; - case android.view.inputmethod.EditorInfo.IME_ACTION_SEND: - return com.codename1.ui.TextInputConfig.ACTION_SEND; - case android.view.inputmethod.EditorInfo.IME_ACTION_DONE: - return com.codename1.ui.TextInputConfig.ACTION_DONE; - default: - return com.codename1.ui.TextInputConfig.ACTION_DEFAULT; - } - } - - @Override - public boolean isTextInputSupported() { - return true; - } - - @Override - public Object startTextInput(com.codename1.ui.TextInputClient client, com.codename1.ui.TextInputConfig config) { - activeInputClient = client; - activeInputConfig = config; - activeInputState = client.getEditingState(); - pendingInputState = null; - final CodenameOneActivity a = getActivity(); - final CodenameOneSurface view = myView; - if (a == null || view == null) { - return client; - } - a.runOnUiThread(new Runnable() { - public void run() { - android.view.View v = view.getAndroidView(); - v.setFocusable(true); - v.setFocusableInTouchMode(true); - v.requestFocus(); - android.view.inputmethod.InputMethodManager imm = (android.view.inputmethod.InputMethodManager) - a.getSystemService(android.content.Context.INPUT_METHOD_SERVICE); - if (imm != null) { - imm.restartInput(v); - imm.showSoftInput(v, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT); - } - updateEditorAutofill(v, true); - } - }); - return client; - } - - @Override - public void updateTextInputState(Object handle, com.codename1.ui.TextInputState state) { - if (handle == null || handle != activeInputClient || state == null) { - // a stale handle (an unbalanced session that was already replaced) must not - // disturb the currently bound client - return; - } - activeInputState = state; - // retire the connection's synchronous mirror only when this push reflects every posted - // edit; clearing early would hide an in-flight edit from the IME's immediate re-reads - if (pendingAppliedSeq == pendingPostedSeq) { - pendingInputState = null; - } - final CodenameOneActivity a = getActivity(); - final CodenameOneSurface view = myView; - if (a == null || view == null) { - return; - } - a.runOnUiThread(new Runnable() { - public void run() { - android.view.inputmethod.InputMethodManager imm = (android.view.inputmethod.InputMethodManager) - a.getSystemService(android.content.Context.INPUT_METHOD_SERVICE); - if (imm != null && activeInputClient != null) { - com.codename1.ui.TextInputState s = activeInputState; - imm.updateSelection(view.getAndroidView(), s.getSelectionStart(), s.getSelectionEnd(), - s.getComposingStart(), s.getComposingEnd()); - } - } - }); - } - - @Override - public void stopTextInput(Object handle) { - if (handle == null || handle != activeInputClient) { - return; - } - activeInputClient = null; - activeInputState = null; - activeInputConfig = null; - pendingInputState = null; - final CodenameOneActivity a = getActivity(); - final CodenameOneSurface view = myView; - if (a == null || view == null) { - return; - } - a.runOnUiThread(new Runnable() { - public void run() { - android.view.inputmethod.InputMethodManager imm = (android.view.inputmethod.InputMethodManager) - a.getSystemService(android.content.Context.INPUT_METHOD_SERVICE); - if (imm != null) { - imm.hideSoftInputFromWindow(view.getAndroidView().getWindowToken(), 0); - imm.restartInput(view.getAndroidView()); - } - updateEditorAutofill(view.getAndroidView(), false); - } - }); - } - - - @Override - public void setDisableScreenshots(final boolean disable) { - final CodenameOneActivity a = getActivity(); - if (a == null || a.getWindow() == null) { - return; - } - a.runOnUiThread(new Runnable() { - @Override - public void run() { - if (disable) { - a.getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE); - } else { - a.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE); - } - } - }); - } - - /** - * @param aActivity the activity to set - */ - public static void setActivity(CodenameOneActivity aActivity) { - activity = aActivity; - if (activity != null) { - activityComponentName = activity.getComponentName(); - } - - } - CodenameOneSurface myView = null; - private AndroidAccessibilityProvider accessibilityProvider; - private volatile boolean accessibilityTreeUpdateRequired; - CodenameOneTextPaint defaultFont; - private final char[] tmpchar = new char[1]; - private final Rect tmprect = new Rect(); - protected int defaultFontHeight; - private Vibrator v = null; - private boolean vibrateInitialized = false; - private int displayWidth; - private int displayHeight; - static CodenameOneActivity activity; - static ComponentName activityComponentName; - private static PowerManager.WakeLock pushWakeLock; - public static synchronized void acquirePushWakeLock(long timeout) { - if (getContext() == null) return; - try { - if (pushWakeLock == null) { - PowerManager pm = (PowerManager) getContext().getSystemService(Context.POWER_SERVICE); - pushWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "CN1:PushWakeLock"); - } - pushWakeLock.acquire(timeout); - } catch (Exception ex) { - com.codename1.io.Log.e(ex); - } - } - - private static Context context; - private static PermissionPromptCallback permissionPromptCallback; - RelativeLayout relativeLayout; - final Vector nativePeers = new Vector(); - int lastDirectionalKeyEventReceivedByWrapper; - private EventDispatcher callback; - private int timeout = -1; - private CodeScannerImpl scannerInstance; - private HashMap apIds; - private static View viewBelow; - private static View viewAbove; - private static int aboveSpacing; - private static int belowSpacing; - public static boolean asyncView = false; - public static boolean textureView = false; - private AudioService background; - private boolean asyncEditMode = false; - private boolean compatPaintMode; - private MediaRecorder recorder = null; - - private boolean statusBarHidden; - private boolean superPeerMode = true; - - - private ValueCallback mUploadMessage; - public ValueCallback uploadMessage; - - /** - * Keeps track of running contexts. - * @see #startContext(Context) - * @see #stopContext(Context) - */ - private static HashSet activeContexts = new HashSet(); - - /** - * A method to be called when a Context begins its execution. This adds the - * context to the context set. When the contenxt's execution completes, it should - * call {@link #stopContext} to clear up resources. - * @param ctx The context that is starting. - * @see #stopContext(Context) - */ - public static void startContext(Context ctx) { - - while (deinitializingEdt) { - // It is possible that deinitialize was called just before the - // last context was destroyed so there is a pending deinitialize - // working its way through the system. Give it some time - // before forcing the deinitialize - System.out.println("Waiting for deinitializing to complete before starting a new initialization"); - Util.sleep(30); - } - if (deinitializing && instance != null) { - instance.deinitialize(); - } - synchronized(activeContexts) { - activeContexts.add(ctx); - if (instance == null) { - // If this is our first rodeo, just call Display.init() as that should - // be sufficient to set everything up. - Display.init(ctx); - } else { - // If we've initialized before, we should "re-initialize" the implementation - // Reinitializing will force views to be created even if the EDT was already - // running in background mode. - reinit(ctx); - } - } - } - - /** - * Cleans up resources in the given context. This method should be called by - * any Activity or Service that called startContext() when it started. - * @param ctx The context to stop. - * - * @see #startContext(Context) - */ - public static void stopContext(Context ctx) { - synchronized(activeContexts) { - activeContexts.remove(ctx); - if (activeContexts.isEmpty()) { - // If we are the last context, we should deinitialize - syncDeinitialize(); - } else { - if (instance != null && getActivity() != null) { - // if this is an activity, then we should clean up - // our UI resources anyways because the last context - // to be cleaned up might not have access to the UI thread. - instance.deinitialize(); - } - } - } - } - - @Override - public void screenshot(SuccessCallback callback) { - final Activity activity = (Activity) getContext(); - final AndroidScreenshotTask task = new AndroidScreenshotTask(myView, activity, callback); - activity.runOnUiThread(task); - } - - @Override - public void setPlatformHint(String key, String value) { - if(key.equals("platformHint.compatPaintMode")) { - compatPaintMode = value.equalsIgnoreCase("true"); - return; - } - if(key.equals("platformHint.legacyPaint")) { - AndroidAsyncView.legacyPaintLogic = value.equalsIgnoreCase("true");; - } - } - - - /** - * This method in used internally for ads - * @param above shown above the view - * @param below shown below the view - */ - public static void setViewAboveBelow(View above, View below, int spacingAbove, int spacingBelow) { - viewBelow = below; - viewAbove = above; - aboveSpacing = spacingAbove; - belowSpacing = spacingBelow; - } - - static boolean hasViewAboveBelow(){ - return viewBelow != null || viewAbove != null; - } - - /** - * Copy the input stream into the output stream, closes both streams when finishing or in - * a case of an exception - * - * @param i source - * @param o destination - */ - private static void copy(InputStream i, OutputStream o) throws IOException { - copy(i, o, 8192); - } - - /** - * Copy the input stream into the output stream, closes both streams when finishing or in - * a case of an exception - * - * @param i source - * @param o destination - * @param bufferSize the size of the buffer, which should be a power of 2 large enoguh - */ - private static void copy(InputStream i, OutputStream o, int bufferSize) throws IOException { - try { - byte[] buffer = new byte[bufferSize]; - int size = i.read(buffer); - while(size > -1) { - o.write(buffer, 0, size); - size = i.read(buffer); - } - } finally { - sCleanup(o); - sCleanup(i); - } - } - - private static void sCleanup(Object o) { - try { - if(o != null) { - if(o instanceof InputStream) { - ((InputStream)o).close(); - return; - } - if(o instanceof OutputStream) { - ((OutputStream)o).close(); - return; - } - } - } catch(Throwable t) {} - } - - /** - * Copied here since the cleanup method in util would crash append notification that runs when the app isn't in the foreground - */ - private static byte[] readInputStream(InputStream i) throws IOException { - ByteArrayOutputStream b = new ByteArrayOutputStream(); - copy(i, b); - return b.toByteArray(); - } - - - public static void appendNotification(String type, String body, Context a) { - appendNotification(type, body, null, null, a); - } - - /** Receives the managed typed envelope from FCM without applying legacy push decoding. */ - public static void handleV3Push(final String envelope, Context context, - boolean appRunning, Class appStubClass) { - if (appRunning && Display.isInitialized() - && com.codename1.push.PushClient.hasActiveClient()) { - Display.getInstance().callSerially(new Runnable() { - public void run() { - com.codename1.push.PushClient.dispatch(envelope); - } - }); - return; - } - try { - org.json.JSONObject message = new org.json.JSONObject(envelope); - // The pending-push file explicitly encodes whether a legacy type is present. - // A missing type is the sentinel for a typed V3 envelope and is replayed intact. - appendNotification(null, envelope, context); - if (message.optBoolean("silent", false)) { - return; - } - String title = message.optString("title", ""); - String body = message.optString("body", ""); - String image = message.optString("image", ""); - if (title.length() == 0 && body.length() == 0 && image.length() == 0) { - return; - } - if (title.length() == 0) { - title = context.getApplicationInfo().loadLabel(context.getPackageManager()).toString(); - } - Intent intent = new Intent(context, appStubClass); - PendingIntent contentIntent = createPendingIntent(context, 0, intent); - int smallIcon = context.getResources().getIdentifier("ic_stat_notify", "drawable", - context.getPackageName()); - if (smallIcon == 0) { - smallIcon = context.getApplicationInfo().icon; - } - NotificationCompat.Builder builder = new NotificationCompat.Builder(context) - .setContentTitle(title) - .setContentText(body) - .setSmallIcon(smallIcon) - .setContentIntent(contentIntent) - .setAutoCancel(true) - .setWhen(System.currentTimeMillis()); - NotificationManager manager = (NotificationManager) - context.getSystemService(Context.NOTIFICATION_SERVICE); - setNotificationChannel(manager, builder, context); - String collapseKey = message.optString("collapseKey", null); - String messageId = message.optString("id", null); - String notificationTag; - if (collapseKey != null && collapseKey.length() > 0) { - notificationTag = v3NotificationTag("CN1_PUSH_V3_COLLAPSE:", collapseKey); - } else if (messageId != null && messageId.length() > 0) { - notificationTag = v3NotificationTag("CN1_PUSH_V3_MESSAGE:", messageId); - } else { - notificationTag = "CN1_PUSH_V3_EPHEMERAL:" + System.currentTimeMillis() - + ":" + V3_NOTIFICATION_SEQUENCE.incrementAndGet(); - } - manager.notify(notificationTag, 0, builder.build()); - } catch (Exception error) { - Log.e("Codename One", "Failed to handle a Push V3 envelope", error); - } - } - - private static String v3NotificationTag(String prefix, String value) { - if (prefix.length() + value.length() <= 128) { - return prefix + value; - } - try { - byte[] digest = MessageDigest.getInstance("SHA-256") - .digest(value.getBytes(StandardCharsets.UTF_8)); - StringBuilder out = new StringBuilder(prefix.length() + digest.length * 2); - out.append(prefix); - for (byte item : digest) { - int unsigned = item & 0xff; - if (unsigned < 0x10) { - out.append('0'); - } - out.append(Integer.toHexString(unsigned)); - } - return out.toString(); - } catch (Exception error) { - return prefix + Integer.toHexString(value.hashCode()); - } - } - - public static void appendNotification(String type, String body, String image, String category, Context a) { - try { - String[] fileList = a.fileList(); - byte[] data = null; - for (int iter = 0; iter < fileList.length; iter++) { - if (fileList[iter].equals("CN1$AndroidPendingNotifications")) { - InputStream is = a.openFileInput("CN1$AndroidPendingNotifications"); - if(is != null) { - data = readInputStream(is); - sCleanup(a); - break; - } - } - } - DataOutputStream os = new DataOutputStream(a.openFileOutput("CN1$AndroidPendingNotifications", 0)); - if(data != null) { - data[0]++; - os.write(data); - } else { - os.writeByte(1); - } - String bodyType = type; - if (image != null || category != null) { - type = "99"; - } - if(type != null) { - os.writeBoolean(true); - os.writeUTF(type); - } else { - os.writeBoolean(false); - } - if ("99".equals(type)) { - String msg = "body="+java.net.URLEncoder.encode(body, "UTF-8") - +"&type="+java.net.URLEncoder.encode(bodyType, "UTF-8"); - if (category != null) { - msg += "&category="+java.net.URLEncoder.encode(category, "UTF-8"); - } - if (image != null) { - msg += "&image="+java.net.URLEncoder.encode(image, "UTF-8"); - } - os.writeUTF(msg); - - } else { - os.writeUTF(body); - } - os.writeLong(System.currentTimeMillis()); - } catch(IOException err) { - err.printStackTrace(); - } - } - - private static Map splitQuery(String urlencodeQueryString) { - String[] parts = urlencodeQueryString.split("&"); - Map out = new HashMap(); - for (String part : parts) { - int pos = part.indexOf("="); - String k,v; - if (pos > 0) { - k = part.substring(0, pos); - v = part.substring(pos+1); - } else { - k = part; - v = ""; - } - try { - k = java.net.URLDecoder.decode(k, "UTF-8"); - v = java.net.URLDecoder.decode(v, "UTF-8"); - } catch (UnsupportedEncodingException ex) { - // won't happen - com.codename1.io.Log.e(ex); - } - out.put(k, v); - } - return out; - } - - public String getStackTrace(Thread parentThread, Throwable t) { - System.out.println("CN1SS:ERR:Invoking getStackTrace in AndroidImplementation"); - ByteArrayOutputStream bos = new ByteArrayOutputStream(); - PrintWriter w = new PrintWriter(new OutputStreamWriter(bos, StandardCharsets.UTF_8)); - t.printStackTrace(w); - w.close(); - System.out.println("CN1SS:ERR:AndroidImplementation getStackTrace completed"); - return new String(bos.toByteArray(), StandardCharsets.UTF_8); - } - - public static void initPushContent(String message, String image, String messageType, String category, Context context) { - com.codename1.push.PushContent.reset(); - - int iMessageType = 1; - try {iMessageType = Integer.parseInt(messageType);}catch(Throwable t){} - - String actionId = null; - String reply = null; - boolean cancel = true; - if (context instanceof Activity) { - Activity activity = (Activity)context; - Bundle extras = activity.getIntent().getExtras(); - if (extras != null) { - actionId = extras.getString("pushActionId"); - extras.remove("pushActionId"); - - if (actionId != null && RemoteInputWrapper.isSupported()) { - Bundle textExtras = RemoteInputWrapper.getResultsFromIntent(activity.getIntent()); - if (textExtras != null) { - CharSequence cs = textExtras.getCharSequence(actionId + "$Result"); - if (cs != null) { - reply = cs.toString(); - } - } - - - } - } - - } - if (cancel) { - PushNotificationService.cancelNotification(context); - } - com.codename1.push.PushContent.setType(iMessageType); - com.codename1.push.PushContent.setCategory(category); - if (actionId != null) { - com.codename1.push.PushContent.setActionId(actionId); - } - if (reply != null) { - com.codename1.push.PushContent.setTextResponse(reply); - } - switch (iMessageType) { - case 1: - case 5: - com.codename1.push.PushContent.setBody(message);break; - case 2: com.codename1.push.PushContent.setMetaData(message);break; - case 3: { - String[] parts = message.split(";"); - com.codename1.push.PushContent.setMetaData(parts[1]); - com.codename1.push.PushContent.setBody(parts[0]); - break; - } - case 4: { - String[] parts = message.split(";"); - com.codename1.push.PushContent.setTitle(parts[0]); - com.codename1.push.PushContent.setBody(parts[1]); - break; - } - case 101: { - com.codename1.push.PushContent.setBody(message.substring(message.indexOf(" ") + 1)); - com.codename1.push.PushContent.setType(1); - break; - } - case 102: { - String[] parts = message.split(";"); - com.codename1.push.PushContent.setTitle(parts[1]); - com.codename1.push.PushContent.setBody(parts[2]); - com.codename1.push.PushContent.setType(2); - break; - } - } - } - - // Name of file where we install the push notification categories as an XML file - // if the main class implements PushActiosProvider - private static String FILE_NAME_NOTIFICATION_CATEGORIES = "CN1$AndroidNotificationCategories"; - - - - /** - * Action categories are defined on the Main class by implementing the PushActionsProvider, however - * the main class may not be available to the push receiver, so we need to save these categories - * to the file system when the app is installed, then the push receiver can load these actions - * when it sends a push while the app isn't running. - * @param provider A reference to the App's main class - * @throws IOException - */ - public static void installNotificationActionCategories(PushActionsProvider provider) throws IOException { - // Assume that CN1 is running... this will run when the app starts - // up - Context context = getContext(); - boolean requiresUpdate = false; - - File categoriesFile = new File(activity.getFilesDir().getAbsolutePath() + "/" + FILE_NAME_NOTIFICATION_CATEGORIES); - if (!categoriesFile.exists()) { - requiresUpdate = true; - } - if (!requiresUpdate) { - try { - PackageInfo packageInfo = context.getPackageManager().getPackageInfo(context.getApplicationContext().getPackageName(), PackageManager.GET_PERMISSIONS); - if (packageInfo.lastUpdateTime > categoriesFile.lastModified()) { - requiresUpdate = true; - } - } catch (Exception ex) { - ex.printStackTrace(); - } - } - - if (!requiresUpdate) { - return; - } - - OutputStream os = getContext().openFileOutput(FILE_NAME_NOTIFICATION_CATEGORIES, 0); - PushActionCategory[] categories = provider.getPushActionCategories(); - javax.xml.parsers.DocumentBuilderFactory docFactory = javax.xml.parsers.DocumentBuilderFactory.newInstance(); - javax.xml.parsers.DocumentBuilder docBuilder; - try { - docBuilder = docFactory.newDocumentBuilder(); - } catch (ParserConfigurationException ex) { - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - throw new IOException("Faield to create document builder for creating notification categories XML document", ex); - } - - // root elements - org.w3c.dom.Document doc = docBuilder.newDocument(); - org.w3c.dom.Element root = (org.w3c.dom.Element)doc.createElement("categories"); - doc.appendChild(root); - for (PushActionCategory category : categories) { - org.w3c.dom.Element categoryEl = (org.w3c.dom.Element)doc.createElement("category"); - org.w3c.dom.Attr idAttr = doc.createAttribute("id"); - idAttr.setValue(category.getId()); - categoryEl.setAttributeNode(idAttr); - - for (PushAction action : category.getActions()) { - org.w3c.dom.Element actionEl = (org.w3c.dom.Element)doc.createElement("action"); - org.w3c.dom.Attr actionIdAttr = doc.createAttribute("id"); - actionIdAttr.setValue(action.getId()); - actionEl.setAttributeNode(actionIdAttr); - - - org.w3c.dom.Attr actionTitleAttr = doc.createAttribute("title"); - if (action.getTitle() != null) { - actionTitleAttr.setValue(action.getTitle()); - } else { - actionTitleAttr.setValue(action.getId()); - } - actionEl.setAttributeNode(actionTitleAttr); - - if (action.getIcon() != null) { - org.w3c.dom.Attr actionIconAttr = doc.createAttribute("icon"); - String iconVal = action.getIcon(); - try { - // We'll store the resource IDs for the icon - // rather than the icon name because that is what - // the push notifications require. - iconVal = ""+context.getResources().getIdentifier(iconVal, "drawable", context.getPackageName()); - actionIconAttr.setValue(iconVal); - actionEl.setAttributeNode(actionIconAttr); - } catch (Exception ex) { - ex.printStackTrace(); - - } - - } - - if (action.getTextInputPlaceholder() != null) { - org.w3c.dom.Attr textInputPlaceholderAttr = doc.createAttribute("textInputPlaceholder"); - textInputPlaceholderAttr.setValue(action.getTextInputPlaceholder()); - actionEl.setAttributeNode(textInputPlaceholderAttr); - } - if (action.getTextInputButtonText() != null) { - org.w3c.dom.Attr textInputButtonTextAttr = doc.createAttribute("textInputButtonText"); - textInputButtonTextAttr.setValue(action.getTextInputButtonText()); - actionEl.setAttributeNode(textInputButtonTextAttr); - } - categoryEl.appendChild(actionEl); - } - root.appendChild(categoryEl); - - } - try { - javax.xml.transform.TransformerFactory transformerFactory = javax.xml.transform.TransformerFactory.newInstance(); - javax.xml.transform.Transformer transformer = transformerFactory.newTransformer(); - javax.xml.transform.dom.DOMSource source = new javax.xml.transform.dom.DOMSource(doc); - javax.xml.transform.stream.StreamResult result = new javax.xml.transform.stream.StreamResult(os); - transformer.transform(source, result); - - } catch (Exception ex) { - throw new IOException("Failed to save notification categories as XML.", ex); - } - - } - - /** - * Retrieves the app's available push action categories from the XML file in which they - * should have been installed on the first load. - * @param context - * @return - * @throws IOException - */ - private static PushActionCategory[] getInstalledPushActionCategories(Context context) throws IOException { - // NOTE: This method may be called from the PushReceiver when the app isn't running so we can't access - // the main activity context, display properties, or any CN1 stuff. Just native android - - File categoriesFile = new File(context.getFilesDir().getAbsolutePath() + "/" + FILE_NAME_NOTIFICATION_CATEGORIES); - if (!categoriesFile.exists()) { - return new PushActionCategory[0]; - } - javax.xml.parsers.DocumentBuilderFactory docFactory = javax.xml.parsers.DocumentBuilderFactory.newInstance(); - javax.xml.parsers.DocumentBuilder docBuilder; - try { - docBuilder = docFactory.newDocumentBuilder(); - } catch (ParserConfigurationException ex) { - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - throw new IOException("Faield to create document builder for creating notification categories XML document", ex); - } - org.w3c.dom.Document doc; - try { - doc = docBuilder.parse(context.openFileInput(FILE_NAME_NOTIFICATION_CATEGORIES)); - } catch (SAXException ex) { - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - throw new IOException("Failed to parse instaled push action categories", ex); - } - org.w3c.dom.Element root = doc.getDocumentElement(); - java.util.List out = new ArrayList(); - org.w3c.dom.NodeList l = root.getElementsByTagName("category"); - int len = l.getLength(); - for (int i=0; i actions = new ArrayList(); - org.w3c.dom.NodeList al = el.getElementsByTagName("action"); - int alen = al.getLength(); - for (int j=0; j= 23) { - return PendingIntent.getActivity(ctx, value, intent, FLAG_IMMUTABLE); - } else { - return PendingIntent.getActivity(ctx, value, intent, PendingIntent.FLAG_CANCEL_CURRENT); - } - } - - public static PendingIntent createMutablePendingIntent(Context ctx, int value, Intent intent) { - if (android.os.Build.VERSION.SDK_INT >= 23) { - return PendingIntent.getActivity(ctx, value, intent, FLAG_MUTABLE); - } else { - return PendingIntent.getActivity(ctx, value, intent, PendingIntent.FLAG_CANCEL_CURRENT); - } - } - - public static PendingIntent getPendingIntent(Context ctx, int value, Intent intent) { - if (android.os.Build.VERSION.SDK_INT >= 23) { - return PendingIntent.getService(ctx, value, intent, FLAG_IMMUTABLE); - } else { - return PendingIntent.getService(ctx, value, intent, PendingIntent.FLAG_CANCEL_CURRENT); - } - } - - public static PendingIntent getBroadcastPendingIntent(Context ctx, int value, Intent intent) { - if (android.os.Build.VERSION.SDK_INT >= 23) { - // PendingIntent.FLAG_IMMUTABLE - return PendingIntent.getBroadcast(ctx, value, intent, 67108864); - } else { - return PendingIntent.getBroadcast(ctx, value, intent, PendingIntent.FLAG_CANCEL_CURRENT); - } - } - - /** - * Adds actions to a push notification. This is called by the Push broadcast receiver probably before - * Codename One is initialized - * @param provider Reference to the app's main class which implements PushActionsProvider - * @param categoryId The category ID of the push notification. - * @param builder The builder for the push notification. - * @param targetIntent The target intent... this should go to the app's main Activity. - * @param context The current context (inside the Broadcast receiver). - * @throws IOException - */ - public static void addActionsToNotification(PushActionsProvider provider, String categoryId, NotificationCompat.Builder builder, Intent targetIntent, Context context) throws IOException { - // NOTE: THis will likely run when the main activity isn't running so we won't have - // access to any display properties... just native Android APIs will be accessible. - - PushActionCategory category = null; - PushActionCategory[] categories; - if (provider != null) { - categories = provider.getPushActionCategories(); - } else { - categories = getInstalledPushActionCategories(context); - } - for (PushActionCategory candidateCategory : categories) { - if (categoryId.equals(candidateCategory.getId())) { - category = candidateCategory; - break; - } - } - if (category == null) { - return; - } - - int requestCode = 1; - for (PushAction action : category.getActions()) { - Intent newIntent = (Intent)targetIntent.clone(); - newIntent.putExtra("pushActionId", action.getId()); - PendingIntent contentIntent = createMutablePendingIntent(context, requestCode++, newIntent); - try { - int iconId; - try { - iconId = Integer.parseInt(action.getIcon()); - } catch (NumberFormatException ex) { - iconId = 0; - } - if (ActionWrapper.BuilderWrapper.isSupported()) { - // We need to take this abstracted "wrapper" approach because the Action.Builder class, and RemoteInput class - // aren't available until API 22. - // These classes use reflection to provide support for these classes safely. - ActionWrapper.BuilderWrapper actionBuilder = new ActionWrapper.BuilderWrapper(iconId, action.getTitle(), contentIntent); - if (action.getTextInputPlaceholder() != null && RemoteInputWrapper.isSupported()) { - RemoteInputWrapper.BuilderWrapper remoteInputBuilder = new RemoteInputWrapper.BuilderWrapper(action.getId()+"$Result"); - remoteInputBuilder.setLabel(action.getTextInputPlaceholder()); - - RemoteInputWrapper remoteInput = remoteInputBuilder.build(); - actionBuilder.addRemoteInput(remoteInput); - } - ActionWrapper actionWrapper = actionBuilder.build(); - new NotificationCompatWrapper.BuilderWrapper(builder).addAction(actionWrapper); - } else { - builder.addAction(iconId, action.getTitle(), contentIntent); - } - } catch (Exception ex) { - ex.printStackTrace(); - } - } - - } - - public static void firePendingPushes(final PushCallback c, final Context a) { - try { - if(c != null) { - InputStream i = a.openFileInput("CN1$AndroidPendingNotifications"); - if(i == null) { - return; - } - DataInputStream is = new DataInputStream(i); - int count = is.readByte(); - for(int iter = 0 ; iter < count ; iter++) { - boolean hasType = is.readBoolean(); - String actualType = null; - if(hasType) { - actualType = is.readUTF(); - } - final String t; - final String b; - final String category; - final String image; - if ("99".equals(actualType)) { - // This was a rich push - Map vals = splitQuery(is.readUTF()); - t = vals.get("type"); - b = vals.get("body"); - category = vals.get("category"); - image = vals.get("image"); - } else { - t = actualType; - b = is.readUTF(); - category = null; - image = null; - } - long s = is.readLong(); - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - Display.getInstance().setProperty("pendingPush", "true"); - Display.getInstance().setProperty("pushType", t); - initPushContent(b, image, t, category, a); - if(t != null && ("3".equals(t) || "6".equals(t))) { - String[] a = b.split(";"); - c.push(a[0]); - c.push(a[1]); - } else if (t != null && ("101".equals(t))) { - c.push(b.substring(b.indexOf(" ")+1)); - } else { - c.push(b); - } - Display.getInstance().setProperty("pendingPush", null); - } - }); - } - a.deleteFile("CN1$AndroidPendingNotifications"); - } - } catch(IOException err) { - } - } - - public static String[] getPendingPush(String type, Context a) { - InputStream i = null; - try { - i = a.openFileInput("CN1$AndroidPendingNotifications"); - if (i == null) { - return null; - } - DataInputStream is = new DataInputStream(i); - int count = is.readByte(); - Vector v = new Vector(); - for (int iter = 0; iter < count; iter++) { - boolean hasType = is.readBoolean(); - String actualType = null; - if (hasType) { - actualType = is.readUTF(); - } - - final String t; - final String b; - if ("99".equals(actualType)) { - // This was a rich push - Map vals = splitQuery(is.readUTF()); - t = vals.get("type"); - b = vals.get("body"); - //category = vals.get("category"); - //image = vals.get("image"); - } else { - t = actualType; - b = is.readUTF(); - //category = null; - //image = null; - } - long s = is.readLong(); - if(t != null && ("3".equals(t) || "6".equals(t))) { - String[] m = b.split(";"); - v.add(m[0]); - } else if(t != null && "4".equals(t)){ - String[] m = b.split(";"); - v.add(m[1]); - } else if(t != null && "2".equals(t)){ - continue; - }else if (t != null && "101".equals(t)) { - v.add(b.substring(b.indexOf(" ")+1)); - }else{ - v.add(b); - } - } - String [] retVal = new String[v.size()]; - for (int j = 0; j < retVal.length; j++) { - retVal[j] = (String)v.get(j); - } - return retVal; - - } catch (Exception ex) { - ex.printStackTrace(); - } finally { - try { - if(i != null){ - i.close(); - } - } catch (IOException ex) { - } - } - return null; - } - - private static AndroidImplementation instance; - private static final String INTENT_PROPERTY_PREFIX = "android.intent."; - private static final String INTENT_EXTRA_PROPERTY_PREFIX = "android.intent.extra."; - private static final Set intentPropertyKeys = new HashSet(); - private static final Object intentPropertyLock = new Object(); - private static Intent lastPublishedIntent; - - public static AndroidImplementation getInstance() { - return instance; - } - - public static void clearAppArg() { - if (instance != null) { - instance.setAppArg(null); - clearIntentProperties(); - } - } - - /// Delivers a link that arrived at an already-running activity, so the - /// router sees it on Android as it already does on iOS. - /// - /// The two ports were asymmetric here, and silently so. iOS routes every - /// deep link through `Display.setProperty("AppArg", url)`, which fires - /// [com.codename1.router.Navigation#dispatchExternalUrl]. Android's - /// `onNewIntent` only stored the intent, and [#getAppArg] then derived - /// the value lazily through the implementation's own setter -- so - /// `setProperty` never ran and the router never fired. Anything built on - /// `@Route` therefore worked on iOS and did nothing on Android, which - /// reads as a feature that "just doesn't convert" on the platform rather - /// than as a bug. - /// - /// Deliberately narrow. Only `ACTION_VIEW` with an http or https scheme - /// goes through here; `EXTRA_TEXT` shares, `content://` attachments and - /// `EXTRA_STREAM` payloads keep their existing lazy path. Dispatching for - /// every intent would double-fire against the `setAppArg` inside - /// [#getAppArg] and would change behaviour for every share-target - /// application in the field. - /// - /// #### Parameters - /// - /// - `intent`: the intent delivered to the running activity - static void dispatchNewIntentUrl(Intent intent) { - if (intent == null || instance == null || !Display.isInitialized()) { - return; - } - try { - if (!Intent.ACTION_VIEW.equals(intent.getAction())) { - return; - } - android.net.Uri data = intent.getData(); - if (data == null) { - return; - } - String scheme = data.getScheme(); - if (!"http".equals(scheme) && !"https".equals(scheme)) { - return; - } - // Cleared first so the value below is what getAppArg() reports, - // rather than whatever the previous intent left cached. - instance.setAppArg(null); - clearIntentProperties(); - // The intent is stored UNMODIFIED, and the url is marked as delivered by - // remembering the intent's identity instead of by erasing its data. - // - // Two earlier shapes were both wrong. Clearing the data on the intent - // passed in broke the ordinary way to extend onNewIntent() -- - // super.onNewIntent(intent) followed by the subclass reading - // intent.getData(), which had just been nulled underneath it. Storing a - // data-less COPY fixed that one and broke two more readers: the - // documented `android.intent.data` property is published from whatever - // the activity has stored, and native integrations read - // getActivity().getIntent().getData() after onNewIntent(). Both saw a - // warm deep link as no deep link at all while cold links still carried - // it -- an asymmetry an application has no way to work around. - // - // What actually has to be suppressed is narrower than the data: only - // getAppArg()'s rebuilding of the url from the stored intent, because - // CodenameOneActivity.onStop() clears the app arg and the next read - // after a resume would otherwise report the same deep link a second - // time and open one tapped invite twice. - getActivity().setIntent(intent); - markAppArgDelivered(intent); - // Published here rather than left to getAppArg(), since the properties - // for the previous intent were just cleared and the reader that used to - // repopulate them lazily is exactly the one now suppressed. - publishIntentProperties(getActivity(), intent); - Display.getInstance().setProperty("AppArg", data.toString()); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - } - - /// Identity of the intent whose url [#dispatchNewIntentUrl] already delivered as - /// the app arg. Weak because it needs to outlive nothing: the activity holds the - /// intent, and once it stores a different one this reference is free to go. - private static java.lang.ref.WeakReference deliveredAppArgIntent; - - private static void markAppArgDelivered(Intent intent) { - synchronized (intentPropertyLock) { - deliveredAppArgIntent = new java.lang.ref.WeakReference(intent); - } - } - - private static boolean isAppArgDelivered(Intent intent) { - synchronized (intentPropertyLock) { - return deliveredAppArgIntent != null && deliveredAppArgIntent.get() == intent; - } - } - - private static void clearIntentProperties() { - synchronized (intentPropertyLock) { - if (Display.isInitialized()) { - for (String key : new ArrayList(intentPropertyKeys)) { - Display.getInstance().setProperty(key, null); - } - } - intentPropertyKeys.clear(); - lastPublishedIntent = null; - } - } - - private static void publishIntentProperties(Activity activity, Intent intent) { - if (intent == null) { - return; - } - - synchronized (intentPropertyLock) { - if (intent == lastPublishedIntent) { - return; - } - - Map nextProperties = new HashMap(); - nextProperties.put(INTENT_PROPERTY_PREFIX + "action", intent.getAction()); - nextProperties.put(INTENT_PROPERTY_PREFIX + "data", intent.getDataString()); - nextProperties.put(INTENT_PROPERTY_PREFIX + "type", intent.getType()); - - // Only getCallingPackage() is a verified caller identity. Referrer values are caller-controlled. - String callerPackage = activity.getCallingPackage(); - nextProperties.put(INTENT_PROPERTY_PREFIX + "caller", callerPackage); - nextProperties.put(INTENT_PROPERTY_PREFIX + "caller.verified", callerPackage != null ? "true" : "false"); - - Bundle extras = intent.getExtras(); - if (extras != null) { - for (String key : extras.keySet()) { - Object value = extras.get(key); - String propertyKey = key.startsWith(INTENT_EXTRA_PROPERTY_PREFIX) ? key : INTENT_EXTRA_PROPERTY_PREFIX + key; - nextProperties.put(propertyKey, value == null ? null : String.valueOf(value)); - } - } - - if (Display.isInitialized()) { - ArrayList keysToRemove = new ArrayList(); - for (String key : intentPropertyKeys) { - if (!nextProperties.containsKey(key)) { - keysToRemove.add(key); - } - } - for (String key : keysToRemove) { - Display.getInstance().setProperty(key, null); - intentPropertyKeys.remove(key); - } - for (Map.Entry entry : nextProperties.entrySet()) { - Display.getInstance().setProperty(entry.getKey(), entry.getValue()); - intentPropertyKeys.add(entry.getKey()); - } - } else { - intentPropertyKeys.clear(); - intentPropertyKeys.addAll(nextProperties.keySet()); - } - - lastPublishedIntent = intent; - } - } - - public static Context getContext() { - Context out = getActivity(); - if (out != null) { - return out; - } - return context; - } - - public void setContext(Context c) { - context = c; - } - - @Override - public void init(Object m) { - // NOTE: Do not explicitly set the PlayServices instance to anything other than - // an instance of the base PlayServices class. The Build Server will automatically - // swap this for the appropriate subclass depending on the playServicesVersion of - // the build. - PlayServices.setInstance(new PlayServices()); // <---- DO NOT CHANGE - Build server will replace with appropriate subclass instance - if (m instanceof CodenameOneActivity) { - setContext(null); - setActivity((CodenameOneActivity) m); - } else { - setActivity(null); - setContext((Context)m); - } - // The nearby bridge is cached for the life of the process while - // Android recreates the activity freely -- a configuration change, - // or "Don't keep activities". An association chooser opened by the - // old activity delivers its result to the NEW one, where the - // backend's result listener is not installed, so the association - // resource never settled and every later association answered BUSY. - // Told here because this is the one place that knows it changed. - if (nearbyBridge != null) { - nearbyBridge.onActivityChanged(); - } - - instance = this; - if(getActivity() != null && getActivity().hasUI()){ - if (!hasActionBar()) { - try { - getActivity().requestWindowFeature(Window.FEATURE_NO_TITLE); - } catch (Exception e) { - com.codename1.io.Log.p("requestWindowFeature FEATURE_NO_TITLE threw exception: " + e.toString()); - } - } else { - getActivity().invalidateOptionsMenu(); - try { - getActivity().requestWindowFeature(Window.FEATURE_ACTION_BAR); - getActivity().requestWindowFeature(Window.FEATURE_PROGRESS); - - if(android.os.Build.VERSION.SDK_INT >= 21){ - //WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS - getActivity().getWindow().addFlags(-2147483648); - } - } catch (Exception e) { - //Log.d("Codename One", "No idea why this throws a Runtime Error", e); - } - NotifyActionBar notify = new NotifyActionBar(getActivity(), false); - notify.run(); - } - - if(statusBarHidden) { - getActivity().getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE - | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN); - getActivity().getWindow().setStatusBarColor(android.graphics.Color.TRANSPARENT); - } - - if(Display.getInstance().getProperty("StatusbarHidden", "").equals("true")){ - getActivity().getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN); - } - - if(Display.getInstance().getProperty("KeepScreenOn", "").equals("true")){ - getActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - } - - if(Display.getInstance().getProperty("DisableScreenshots", "").equals("true")){ - getActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE); - } - - if (m instanceof CodenameOneActivity) { - ((CodenameOneActivity) m).setDefaultIntentResultListener(this); - ((CodenameOneActivity) m).setIntentResultListener(this); - } - - /** - * translate our default font height depending on the screen density. - * this is required for new high resolution devices. otherwise - * everything looks awfully small. - * - * we use our default font height value of 16 and go from there. i - * thought about using new Paint().getTextSize() for this value but if - * some new version of android suddenly returns values already tranlated - * to the screen then we might end up with too large fonts. the - * documentation is not very precise on that. - */ - final int defaultFontPixelHeight = 16; - this.defaultFontHeight = this.translatePixelForDPI(defaultFontPixelHeight); - - - this.defaultFont = (CodenameOneTextPaint) ((NativeFont) this.createFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_MEDIUM)).font; - Display.getInstance().setTransitionYield(-1); - - initSurface(); - /** - * devices are extremely sensitive so dragging should start a little - * later than suggested by default implementation. - */ - this.setDragStartPercentage(1); - VirtualKeyboardInterface vkb = new AndroidKeyboard(this); - Display.getInstance().registerVirtualKeyboard(vkb); - Display.getInstance().setDefaultVirtualKeyboard(vkb); - - InPlaceEditView.endEdit(); - - getActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN); - - if (nativePeers.size() > 0) { - for (int i = 0; i < nativePeers.size(); i++) { - ((AndroidImplementation.AndroidPeer) nativePeers.elementAt(i)).init(); - } - } - } else { - /** - * translate our default font height depending on the screen density. - * this is required for new high resolution devices. otherwise - * everything looks awfully small. - * - * we use our default font height value of 16 and go from there. i - * thought about using new Paint().getTextSize() for this value but if - * some new version of android suddenly returns values already tranlated - * to the screen then we might end up with too large fonts. the - * documentation is not very precise on that. - */ - final int defaultFontPixelHeight = 16; - this.defaultFontHeight = this.translatePixelForDPI(defaultFontPixelHeight); - - - this.defaultFont = (CodenameOneTextPaint) ((NativeFont) this.createFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_MEDIUM)).font; - } - HttpURLConnection.setFollowRedirects(false); - CookieHandler.setDefault(null); - VideoCaptureConstraints.init(new AndroidVideoCaptureConstraintsCompiler()); - } - - - - @Override - public boolean isInitialized(){ -// Removing the check for null view to prevent strange things from happening when -// calling from a Service context. -// if(getActivity() != null && myView == null){ -// //if the view is null deinitialize the Display -// if(super.isInitialized()){ -// syncDeinitialize(); -// } -// return false; -// } - return super.isInitialized(); - } - - /** - * Reinitializes CN1. - * @param i Context to initialize it with. - * - * @see #startContext(Context) - */ - private static void reinit(Object i) { - if (instance != null && ((i instanceof CodenameOneActivity) || instance.myView == null)) { - instance.init(i); - } - Display.init(i); - - // This is a hack to fix an issue that caused the screen to appear blank when - // the app is loaded from memory after being unloaded. - - // This issue only seems to occur when the Activity had been unloaded - // so to test this you'll need to check the "Don't keep activities" checkbox under/ - // Developer options. - // Developer options. - Display.getInstance().callSerially(new Runnable() { - public void run() { - Display.getInstance().invokeAndBlock(new Runnable(){ public void run(){ - Util.sleep(50); - }}); - if (!Display.isInitialized() || Display.getInstance().isMinimized()) { - return; - } - Form cur = Display.getInstance().getCurrent(); - if (cur != null) { - cur.forceRevalidate(); - } - } - - }); - } - - private static class InvalidateOptionsMenuImpl implements Runnable { - private Activity activity; - - public InvalidateOptionsMenuImpl(Activity activity) { - this.activity = activity; - } - - @Override - public void run() { - activity.invalidateOptionsMenu(); - } - } - - @Override - public Boolean isDarkMode() { - try { - int nightModeFlags = getActivity().getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; - switch (nightModeFlags) { - case Configuration.UI_MODE_NIGHT_YES: - return true; - case Configuration.UI_MODE_NIGHT_NO: - return false; - default: - return null; - } - } catch(Throwable t) { - return null; - } - } - - @Override - public boolean isLargerTextEnabled() { - return getLargerTextScale() > 1.0f; - } - - @Override - public float getLargerTextScale() { - try { - Configuration configuration; - if (getActivity() != null) { - configuration = getActivity().getResources().getConfiguration(); - } else { - configuration = getContext().getResources().getConfiguration(); - } - return configuration.fontScale; - } catch (Throwable t) { - return 1.0f; - } - } - - - private boolean hasActionBar() { - return android.os.Build.VERSION.SDK_INT >= 11; - } - - public int translatePixelForDPI(int pixel) { - return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, pixel, - getContext().getResources().getDisplayMetrics()); - } - - /** - * Returns the platform EDT thread priority - */ - public int getEDTThreadPriority(){ - return Thread.NORM_PRIORITY; - } - - /// Android reports this directly as DisplayMetrics.density, so there is no - /// need to make callers derive it from the density bucket -- the bucket is a - /// coarse DPI band and rounds to a different number than the scale the - /// platform itself lays out with. - /// - /// Read the same way getDeviceDensity does, preferring the activity's own - /// display, because a multi-display device can have a different scale per - /// display and the resources copy is the default one. - @Override - public float getDevicePixelRatio() { - DisplayMetrics metrics = new DisplayMetrics(); - if (getActivity() != null) { - getActivity().getWindowManager().getDefaultDisplay().getMetrics(metrics); - } else if (getContext() != null) { - metrics = getContext().getResources().getDisplayMetrics(); - } else { - return super.getDevicePixelRatio(); - } - // 0 means "not reported", which is what the portable contract expects. - return metrics.density > 0 ? metrics.density : super.getDevicePixelRatio(); - } - - @Override - public int getDeviceDensity() { - DisplayMetrics metrics = new DisplayMetrics(); - if (getActivity() != null) { - getActivity().getWindowManager().getDefaultDisplay().getMetrics(metrics); - } else { - metrics = getContext().getResources().getDisplayMetrics(); - } - - int dpi = metrics.densityDpi; - if (dpi < DisplayMetrics.DENSITY_MEDIUM) { - return Display.DENSITY_LOW; - } - if (dpi < 213) { - return Display.DENSITY_MEDIUM; - } - // 213 == TV - if (dpi <= DisplayMetrics.DENSITY_HIGH) { - return Display.DENSITY_HIGH; - } - if (dpi < 400) { - return Display.DENSITY_VERY_HIGH; - } - if (dpi < 560) { - return Display.DENSITY_HD; - } - if (dpi <= 640) { - return Display.DENSITY_2HD; - } - return Display.DENSITY_4K; - } - - public static boolean isImmersive() { - if (getActivity() == null) { - return false; - } - return isImmersive(getActivity().getWindow()); - } - public static boolean isImmersive(Window window) { - if (Build.VERSION.SDK_INT >= 35) { - // Android 15+ is always immersive (overlay mode by default) - return true; - } - // On Android 34 and below, we can't detect decorFitsSystemWindows - // reliably at runtime. So the app must make the decision explicitly. - return false; - } - public static Rect getSystemBarInsets(final View rootView) { - final Rect result = new Rect(0, 0, 0, 0); - try { - Object insets = View.class - .getMethod("getRootWindowInsets") - .invoke(rootView); - if (insets == null) return result; - // Get android.view.WindowInsets$Type.systemBars() - Class typeClass = Class.forName("android.view.WindowInsets$Type"); - int systemBarsMask = ((Integer) typeClass - .getMethod("systemBars") - .invoke(null)).intValue(); - // Call insets.getInsets(int) - Object insetsObject = insets.getClass() - .getMethod("getInsets", new Class[]{int.class}) - .invoke(insets, new Object[]{systemBarsMask}); - if (insetsObject == null) return result; - Class insetsClass = insetsObject.getClass(); - int left = ((Integer) insetsClass.getField("left").get(insetsObject)).intValue(); - int top = ((Integer) insetsClass.getField("top").get(insetsObject)).intValue(); - int right = ((Integer) insetsClass.getField("right").get(insetsObject)).intValue(); - int bottom = ((Integer) insetsClass.getField("bottom").get(insetsObject)).intValue(); - // Include mandatory gesture insets (e.g. gesture navigation handle area). - // Some devices expose a larger interaction-protected bottom region here - // than in plain system bar insets. - try { - int mandatoryGesturesMask = ((Integer) typeClass - .getMethod("mandatorySystemGestures") - .invoke(null)).intValue(); - Object mandatoryInsetsObject = insets.getClass() - .getMethod("getInsets", new Class[]{int.class}) - .invoke(insets, new Object[]{mandatoryGesturesMask}); - if (mandatoryInsetsObject != null) { - Class mandatoryInsetsClass = mandatoryInsetsObject.getClass(); - left = Math.max(left, ((Integer) mandatoryInsetsClass.getField("left").get(mandatoryInsetsObject)).intValue()); - top = Math.max(top, ((Integer) mandatoryInsetsClass.getField("top").get(mandatoryInsetsObject)).intValue()); - right = Math.max(right, ((Integer) mandatoryInsetsClass.getField("right").get(mandatoryInsetsObject)).intValue()); - bottom = Math.max(bottom, ((Integer) mandatoryInsetsClass.getField("bottom").get(mandatoryInsetsObject)).intValue()); - } - } catch (Throwable t) { - // Ignore if mandatory gesture insets are unavailable. - } - result.set(left, top, right, bottom); - } catch (Throwable t) { - t.printStackTrace(); // Optional: log this or suppress if expected - } - return result; - } - - - public Rectangle getDisplaySafeArea(Rectangle rect) { - if (rect == null) { - rect = new Rectangle(); - } - if (getProperty("android.useSafeAreaInsets", "true").equals("false")) { - return super.getDisplaySafeArea(rect); - } - if (this.myView != null) { - rect.setBounds( - this.myView.getSafeAreaInsets().left, - this.myView.getSafeAreaInsets().top, - getDisplayWidth() - this.myView.getSafeAreaInsets().right - this.myView.getSafeAreaInsets().left, - getDisplayHeight() - this.myView.getSafeAreaInsets().top - this.myView.getSafeAreaInsets().bottom - ); - return rect; - } - - return super.getDisplaySafeArea(rect); - } - - /** - * A status flag to indicate that CN1 is in the process of deinitializing. - */ - private static boolean deinitializing; - private static boolean deinitializingEdt; - - public static void syncDeinitialize() { - if (deinitializingEdt){ - return; - } - deinitializingEdt = true; // This will get unset in {@link #deinitialize()} - deinitializing = true; - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - Display.deinitialize(); - deinitializingEdt = false; - } - }); - } - - public void deinitialize() { - //activity.getWindowManager().removeView(relativeLayout); - super.deinitialize(); - if (getActivity() != null) { - - Runnable r = new Runnable() { - public void run() { - synchronized (AndroidImplementation.this) { - if (!deinitializing) { - return; - } - deinitializing = false; - } - if (nativePeers.size() > 0) { - for (int i = 0; i < nativePeers.size(); i++) { - ((AndroidImplementation.AndroidPeer) nativePeers.elementAt(i)).deinit(); - } - } - if (accessibilityProvider != null) { - accessibilityProvider.dispose(); - accessibilityProvider = null; - } - if (relativeLayout != null) { - relativeLayout.removeAllViews(); - } - relativeLayout = null; - myView = null; - } - }; - - if (Looper.getMainLooper().getThread() == Thread.currentThread()) { - deinitializing = true; - r.run(); - } else { - deinitializing = true; - getActivity().runOnUiThread(r); - } - } else { - deinitializing = false; - } - } - - /** - * init view. a lot of back and forth between this thread and the UI thread. - */ - private void initSurface() { - if (getActivity() != null && myView == null) { - relativeLayout= new RelativeLayout(getActivity()); - relativeLayout.setLayoutParams(new RelativeLayout.LayoutParams( - RelativeLayout.LayoutParams.FILL_PARENT, - RelativeLayout.LayoutParams.FILL_PARENT)); - relativeLayout.setFocusable(false); - - getActivity().getWindow().setBackgroundDrawable(null); - if(asyncView) { - if(android.os.Build.VERSION.SDK_INT < 14){ - myView = new AndroidSurfaceView(getActivity(), AndroidImplementation.this); - } else { - int hardwareAcceleration = 16777216; - getActivity().getWindow().setFlags(hardwareAcceleration, hardwareAcceleration); - myView = new AndroidAsyncView(getActivity(), AndroidImplementation.this); - } - } else { - int hardwareAcceleration = 16777216; - getActivity().getWindow().setFlags(hardwareAcceleration, hardwareAcceleration); - superPeerMode = true; - myView = new AndroidAsyncView(getActivity(), AndroidImplementation.this); - } - myView.getAndroidView().setVisibility(View.VISIBLE); - // Makes the surface an Android drop target, so a drag from another application -- - // or from elsewhere in this one -- reaches the components that asked for it. - AndroidNativeDragAndDrop.install(this, myView.getAndroidView()); - - if (hideOverlayWindowsRequested) { - setHideOverlayWindows(true); - } - - if (Build.VERSION.SDK_INT >= 16) { - final View semanticHost = myView.getAndroidView(); - accessibilityProvider = new AndroidAccessibilityProvider(semanticHost, this); - semanticHost.setAccessibilityDelegate(new View.AccessibilityDelegate() { - @Override - public android.view.accessibility.AccessibilityNodeProvider getAccessibilityNodeProvider(View host) { - return accessibilityProvider; - } - }); - } - - relativeLayout.addView(myView.getAndroidView()); - myView.getAndroidView().setVisibility(View.VISIBLE); - - int id = getActivity().getResources().getIdentifier("main", "layout", getActivity().getApplicationInfo().packageName); - RelativeLayout root = (RelativeLayout) LayoutInflater.from(getActivity()).inflate(id, null); - if(viewAbove != null) { - RelativeLayout.LayoutParams lp = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT); - lp.addRule(RelativeLayout.ALIGN_PARENT_TOP); - lp.addRule(RelativeLayout.CENTER_HORIZONTAL); - - RelativeLayout.LayoutParams lp2 = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT); - lp2.setMargins(0, 0, aboveSpacing, 0); - relativeLayout.setLayoutParams(lp2); - root.addView(viewAbove, lp); - } - root.addView(relativeLayout); - if(viewBelow != null) { - RelativeLayout.LayoutParams lp = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT); - lp.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM); - lp.addRule(RelativeLayout.CENTER_HORIZONTAL); - - RelativeLayout.LayoutParams lp2 = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT); - lp2.setMargins(0, 0, 0, belowSpacing); - relativeLayout.setLayoutParams(lp2); - root.addView(viewBelow, lp); - } - getActivity().setContentView(root); - if (!myView.getAndroidView().hasFocus()) { - myView.getAndroidView().requestFocus(); - } - } - } - - @Override - public void confirmControlView() { - if(myView == null){ - return; - } - myView.getAndroidView().setVisibility(View.VISIBLE); - //ugly workaround for a bug where on some android versions the async view - //came back black from the background. - if(myView instanceof AndroidAsyncView){ - final AndroidAsyncView finalView = (AndroidAsyncView)myView; - new Thread(new Runnable() { - @Override - public void run() { - Util.sleep(1000); - finalView.setPaintViewOnBuffer(false); - } - }).start(); - } - } - - public void hideNotifyPublic() { - super.hideNotify(); - saveTextEditingState(); - } - - public void showNotifyPublic() { - super.showNotify(); - } - - @Override - public boolean isMinimized() { - return getActivity() == null || ((CodenameOneActivity)getActivity()).isBackground(); - } - - @Override - public boolean minimizeApplication() { - Activity activity = getActivity(); - if (activity != null) { - // Move the app task to background instead of explicitly launching HOME. - // Some OEM launchers are no longer exported and can throw SecurityException - // when invoked via an ACTION_MAIN/CATEGORY_HOME intent. - if (activity.moveTaskToBack(true)) { - return true; - } - } - - // Fallback for edge-cases where there is no active activity/task. - Intent startMain = new Intent(Intent.ACTION_MAIN); - startMain.addCategory(Intent.CATEGORY_HOME); - startMain.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - startMain.putExtra("WaitForResult", Boolean.FALSE); - try { - getContext().startActivity(startMain); - return true; - } catch (SecurityException ex) { - Log.e("Codename One", "Unable to minimize application", ex); - return false; - } - } - - @Override - public void restoreMinimizedApplication() { - if (getActivity() != null) { - Intent i = new Intent(getActivity(), getActivity().getClass()); - i.setAction(Intent.ACTION_MAIN); - i.addCategory(Intent.CATEGORY_LAUNCHER); - getContext().startActivity(i); - } - } - - @Override - public boolean isNativeInputImmediate() { - return true; - } - - public void editString(final Component cmp, int maxSize, final int constraint, String text, int keyCode) { - InPlaceEditView.edit(this, cmp, constraint); - } - - protected boolean editInProgress() { - return InPlaceEditView.isEditing(); - } - - @Override - public boolean isAsyncEditMode() { - return asyncEditMode; - } - - void setAsyncEditMode(boolean async) { - asyncEditMode = async; - } - - void callHideTextEditor() { - super.hideTextEditor(); - } - - @Override - public void hideTextEditor() { - InPlaceEditView.hideActiveTextEditor(); - } - - @Override - public boolean isNativeEditorVisible(Component c) { - return super.isNativeEditorVisible(c) && !InPlaceEditView.isActiveTextEditorHidden(); - } - - public static void stopEditing() { - stopEditing(false); - } - - public static void stopEditing(final boolean forceVKBClose){ - if (getActivity() == null) { - return; - } - final boolean[] flag = new boolean[]{false}; - - // InPlaceEditView.endEdit must be called from the UI thread. - // We must wait for this call to be over, otherwise Codename One's painting - // of the next form will be garbled. - getActivity().runOnUiThread(new Runnable() { - @Override - public void run() { - // Must be called from the UI thread - InPlaceEditView.stopEdit(forceVKBClose); - - synchronized (flag) { - flag[0] = true; - flag.notify(); - } - } - }); - - if (!flag[0]) { - // Wait (if necessary) for the asynchronous runOnUiThread to do its work - synchronized (flag) { - - try { - flag.wait(); - } catch (InterruptedException e) { - } - } - } - } - - @Override - public void saveTextEditingState() { - stopEditing(true); - } - - @Override - public void stopTextEditing() { - saveTextEditingState(); - } - - @Override - public void stopTextEditing(final Runnable onFinish) { - final Form f = Display.getInstance().getCurrent(); - f.addSizeChangedListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent evt) { - f.removeSizeChangedListener(this); - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - onFinish.run(); - } - }); - } - }); - stopEditing(true); - } - - - protected void setLastSizeChangedWH(int w, int h) { - // not used? - //this.lastSizeChangeW = w; - //this.lastSizeChangeH = h; - } - - /*@Override - public boolean handleEDTException(final Throwable err) { - - final boolean[] messageComplete = new boolean[]{false}; - - Log.e("Codename One", "Err on EDT", err); - - activity.runOnUiThread(new Runnable() { - @Override - public void run() { - UIManager m = UIManager.getInstance(); - final FrameLayout frameLayout = new FrameLayout( - activity); - final TextView textView = new TextView( - activity); - textView.setGravity(Gravity.CENTER); - frameLayout.addView(textView, new FrameLayout.LayoutParams( - FrameLayout.LayoutParams.FILL_PARENT, - FrameLayout.LayoutParams.WRAP_CONTENT)); - textView.setText("An internal application error occurred: " + err.toString()); - AlertDialog.Builder bob = new AlertDialog.Builder( - activity); - bob.setView(frameLayout); - bob.setTitle(""); - bob.setPositiveButton(m.localize("ok", "OK"), - new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface d, int which) { - d.dismiss(); - synchronized (messageComplete) { - messageComplete[0] = true; - messageComplete.notify(); - } - } - }); - AlertDialog editDialog = bob.create(); - editDialog.show(); - } - }); - - synchronized (messageComplete) { - if (messageComplete[0]) { - return true; - } - try { - messageComplete.wait(); - } catch (Exception ignored) { - ; - } - } - return true; - }*/ - - @Override - public InputStream getResourceAsStream(Class cls, String resource) { - try { - if (resource.startsWith("/")) { - resource = resource.substring(1); - } - return getContext().getAssets().open(resource); - } catch (IOException ex) { - Log.i("Codename One", "Resource not found: " + resource); - return null; - } - } - - @Override - protected void pointerPressed(final int x, final int y) { - super.pointerPressed(x, y); - } - - @Override - protected void pointerPressed(final int[] x, final int[] y) { - super.pointerPressed(x, y); - } - - @Override - protected void pointerReleased(final int x, final int y) { - super.pointerReleased(x, y); - } - - @Override - protected void pointerReleased(final int[] x, final int[] y) { - super.pointerReleased(x, y); - } - - @Override - protected void pointerDragged(int x, int y) { - super.pointerDragged(x, y); - } - - @Override - protected void pointerDragged(int[] x, int[] y) { - super.pointerDragged(x, y); - } - - @Override - protected void pointerHover(int x, int y) { - super.pointerHover(x, y); - } - - @Override - protected void pointerHover(int[] x, int[] y) { - super.pointerHover(x, y); - } - - @Override - protected void pointerHoverPressed(int x, int y) { - super.pointerHoverPressed(x, y); - } - - @Override - protected void pointerHoverPressed(int[] x, int[] y) { - super.pointerHoverPressed(x, y); - } - - @Override - protected void pointerHoverReleased(int x, int y) { - super.pointerHoverReleased(x, y); - } - - @Override - protected void pointerHoverReleased(int[] x, int[] y) { - super.pointerHoverReleased(x, y); - } - - @Override - protected int getDragAutoActivationThreshold() { - return 1000000; - } - - @Override - public void flushGraphics() { - if (myView != null) { - myView.flushGraphics(); - } - - } - - @Override - public void flushGraphics(int x, int y, int width, int height) { - this.tmprect.set(x, y, x + width, y + height); - if (myView != null) { - myView.flushGraphics(this.tmprect); - } - } - - @Override - public int charWidth(Object nativeFont, char ch) { - this.tmpchar[0] = ch; - float w = (nativeFont == null ? this.defaultFont - : (Paint) ((NativeFont) nativeFont).font).measureText(this.tmpchar, 0, 1); - if (w - (int) w > 0) { - return (int) (w + 1); - } - return (int) w; - } - - @Override - public int charsWidth(Object nativeFont, char[] ch, int offset, int length) { - float w = (nativeFont == null ? this.defaultFont - : (Paint) ((NativeFont) nativeFont).font).measureText(ch, offset, length); - if (w - (int) w > 0) { - return (int) (w + 1); - } - return (int) w; - } - - @Override - public int stringWidth(Object nativeFont, String str) { - float w = (nativeFont == null ? this.defaultFont - : (Paint) ((NativeFont) nativeFont).font).measureText(str); - if (w - (int) w > 0) { - return (int) (w + 1); - } - return (int) w; - } - - @Override - public void setNativeFont(Object graphics, Object font) { - if (font == null) { - font = this.defaultFont; - } - if (font instanceof NativeFont) { - ((AndroidGraphics) graphics).setFont((CodenameOneTextPaint) ((NativeFont) font).font); - } else { - ((AndroidGraphics) graphics).setFont((CodenameOneTextPaint) font); - } - } - - @Override - public int getHeight(Object nativeFont) { - CodenameOneTextPaint font = (nativeFont == null ? this.defaultFont - : (CodenameOneTextPaint) ((NativeFont) nativeFont).font); - if(font.fontHeight < 0) { - Paint.FontMetrics fm = font.getFontMetrics(); - font.fontHeight = (int)Math.ceil(fm.bottom - fm.top); - } - return font.fontHeight; - } - - @Override - public int getFontAscent(Object nativeFont) { - Paint font = (nativeFont == null ? this.defaultFont - : (Paint) ((NativeFont) nativeFont).font); - return -Math.round(font.getFontMetrics().ascent); - } - - @Override - public int getFontDescent(Object nativeFont) { - Paint font = (nativeFont == null ? this.defaultFont - : (Paint) ((NativeFont) nativeFont).font); - return Math.abs(Math.round(font.getFontMetrics().descent)); - } - - @Override - public boolean isBaselineTextSupported() { - return true; - } - - - - - - - public int getFace(Object nativeFont) { - if (nativeFont == null) { - return Font.FACE_SYSTEM; - } - return ((NativeFont) nativeFont).face; - } - - public int getStyle(Object nativeFont) { - if (nativeFont == null) { - return Font.STYLE_PLAIN; - } - return ((NativeFont) nativeFont).style; - } - - @Override - public int getSize(Object nativeFont) { - if (nativeFont == null) { - return Font.SIZE_MEDIUM; - } - return ((NativeFont) nativeFont).size; - } - - @Override - public boolean isTrueTypeSupported() { - return true; - } - - @Override - public boolean isNativeFontSchemeSupported() { - return true; - } - - private Typeface fontToRoboto(String fontName) { - if("native:MainThin".equals(fontName)) { - return Typeface.create("sans-serif-thin", Typeface.NORMAL); - } - if("native:MainLight".equals(fontName)) { - return Typeface.create("sans-serif-light", Typeface.NORMAL); - } - if("native:MainRegular".equals(fontName)) { - return Typeface.create("sans-serif", Typeface.NORMAL); - } - - if("native:MainBold".equals(fontName)) { - return Typeface.create("sans-serif-condensed", Typeface.BOLD); - } - - if("native:MainBlack".equals(fontName)) { - return Typeface.create("sans-serif-black", Typeface.BOLD); - } - - if("native:ItalicThin".equals(fontName)) { - return Typeface.create("sans-serif-thin", Typeface.ITALIC); - } - - if("native:ItalicLight".equals(fontName)) { - return Typeface.create("sans-serif-thin", Typeface.ITALIC); - } - - if("native:ItalicRegular".equals(fontName)) { - return Typeface.create("sans-serif", Typeface.ITALIC); - } - - if("native:ItalicBold".equals(fontName)) { - return Typeface.create("sans-serif-condensed", Typeface.BOLD_ITALIC); - } - - if("native:ItalicBlack".equals(fontName)) { - return Typeface.create("sans-serif-black", Typeface.BOLD_ITALIC); - } - - throw new IllegalArgumentException("Unsupported native font type: " + fontName); - } - - @Override - public Object loadTrueTypeFont(String fontName, String fileName) { - if(fontName.startsWith("native:")) { - Typeface t = fontToRoboto(fontName); - int fontStyle = com.codename1.ui.Font.STYLE_PLAIN; - if(t.isBold()) { - fontStyle |= com.codename1.ui.Font.STYLE_BOLD; - } - if(t.isItalic()) { - fontStyle |= com.codename1.ui.Font.STYLE_ITALIC; - } - CodenameOneTextPaint newPaint = new CodenameOneTextPaint(t); - newPaint.setAntiAlias(true); - newPaint.setSubpixelText(true); - return new NativeFont(com.codename1.ui.Font.FACE_SYSTEM, fontStyle, - com.codename1.ui.Font.SIZE_MEDIUM, newPaint, fileName, 0, 0); - } - Typeface t = Typeface.createFromAsset(getContext().getAssets(), fileName); - if(t == null) { - throw new RuntimeException("Font not found: " + fileName); - } - CodenameOneTextPaint newPaint = new CodenameOneTextPaint(t); - newPaint.setAntiAlias(true); - newPaint.setSubpixelText(true); - return new NativeFont(com.codename1.ui.Font.FACE_SYSTEM, - com.codename1.ui.Font.STYLE_PLAIN, com.codename1.ui.Font.SIZE_MEDIUM, newPaint, fileName, 0, 0); - } - - public static class NativeFont { - int face; - int style; - int size; - public Object font; - String fileName; - float height; - int weight; - - public NativeFont(int face, int style, int size, Object font, String fileName, float height, int weight) { - this(face, style, size, font); - this.fileName = fileName; - this.height = height; - this.weight = weight; - } - - public NativeFont(int face, int style, int size, Object font) { - this.face = face; - this.style = style; - this.size = size; - this.font = font; - } - - public boolean equals(Object o) { - if(o == null) { - return false; - } - NativeFont n = ((NativeFont)o); - if(fileName != null) { - return n.fileName != null && fileName.equals(n.fileName) && n.height == height && n.weight == weight; - } - return n.face == face && n.style == style && n.size == size && font.equals(n.font); - } - - public int hashCode() { - return face | style | size; - } - } - - /// Returns a copy of the given native font with its paint's letter spacing set - /// to the supplied value (Android letter spacing is in EM units, independent of - /// font size). Used by Style.letterSpacing so a per-UIID spacing -- matching the - /// Material text-appearance for each component -- is baked into the SAME paint - /// that does both measureText (layout) and drawText (render), keeping advances - /// consistent. Other ports get the default no-op. - @Override - public Object deriveTrueTypeFontWithLetterSpacing(Object font, float letterSpacing) { - NativeFont fnt = (NativeFont) font; - CodenameOneTextPaint copy = new CodenameOneTextPaint((CodenameOneTextPaint) fnt.font); - copy.setLetterSpacing(letterSpacing); - return new NativeFont(fnt.face, fnt.style, fnt.size, copy, fnt.fileName, fnt.height, fnt.weight); - } - - @Override - public Object deriveTrueTypeFont(Object font, float size, int weight) { - NativeFont fnt = (NativeFont)font; - CodenameOneTextPaint paint = (CodenameOneTextPaint)fnt.font; - paint.setAntiAlias(true); - Typeface type = paint.getTypeface(); - int fontstyle = Typeface.NORMAL; - if ((weight & Font.STYLE_BOLD) != 0 || type.isBold()) { - fontstyle |= Typeface.BOLD; - } - if ((weight & Font.STYLE_ITALIC) != 0 || type.isItalic()) { - fontstyle |= Typeface.ITALIC; - } - type = Typeface.create(type, fontstyle); - CodenameOneTextPaint newPaint = new CodenameOneTextPaint(type); - newPaint.setTextSize(size); - newPaint.setAntiAlias(true); - // preserve any letter spacing already configured on the source paint - newPaint.setLetterSpacing(paint.getLetterSpacing()); - NativeFont n = new NativeFont(com.codename1.ui.Font.FACE_SYSTEM, weight, com.codename1.ui.Font.SIZE_MEDIUM, newPaint, fnt.fileName, size, weight); - return n; - } - - @Override - public Object createFont(int face, int style, int size) { - Typeface typeface = null; - switch (face) { - case Font.FACE_MONOSPACE: - typeface = Typeface.MONOSPACE; - break; - default: - typeface = Typeface.DEFAULT; - break; - } - - int fontstyle = Typeface.NORMAL; - if ((style & Font.STYLE_BOLD) != 0) { - fontstyle |= Typeface.BOLD; - } - if ((style & Font.STYLE_ITALIC) != 0) { - fontstyle |= Typeface.ITALIC; - } - - - int height = this.defaultFontHeight; - int diff = height / 3; - - switch (size) { - case Font.SIZE_SMALL: - height -= diff; - break; - case Font.SIZE_LARGE: - height += diff; - break; - } - - Paint font = new CodenameOneTextPaint(Typeface.create(typeface, fontstyle)); - font.setAntiAlias(true); - font.setUnderlineText((style & Font.STYLE_UNDERLINED) != 0); - font.setTextSize(height); - return new NativeFont(face, style, size, font); - - } - - /** - * Loads a native font based on a lookup for a font name and attributes. - * Font lookup values can be separated by commas and thus allow fallback if - * the primary font isn't supported by the platform. - * - * @param lookup string describing the font - * @return the native font object - */ - public Object loadNativeFont(String lookup) { - try { - lookup = lookup.split(";")[0]; - int typeface = Typeface.NORMAL; - String familyName = lookup.substring(0, lookup.indexOf("-")); - String style = lookup.substring(lookup.indexOf("-") + 1, lookup.lastIndexOf("-")); - String size = lookup.substring(lookup.lastIndexOf("-") + 1, lookup.length()); - - if (style.equals("bolditalic")) { - typeface = Typeface.BOLD_ITALIC; - } else if (style.equals("italic")) { - typeface = Typeface.ITALIC; - } else if (style.equals("bold")) { - typeface = Typeface.BOLD; - } - Paint font = new CodenameOneTextPaint(Typeface.create(familyName, typeface)); - font.setAntiAlias(true); - font.setTextSize(Integer.parseInt(size)); - return new NativeFont(0, 0, 0, font); - } catch (Exception err) { - return null; - } - } - - /** - * Indicates whether loading a font by a string is supported by the platform - * - * @return true if the platform supports font lookup - */ - @Override - public boolean isLookupFontSupported() { - return true; - } - - @Override - public boolean isAntiAliasedTextSupported() { - return true; - } - - @Override - public void setAntiAliasedText(Object graphics, boolean a) { - android.graphics.Paint p = ((AndroidGraphics) graphics).getFont(); - if(p != null) { - p.setAntiAlias(a); - } - } - - @Override - public Object getDefaultFont() { - CodenameOneTextPaint paint = new CodenameOneTextPaint(this.defaultFont); - return new NativeFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_MEDIUM, paint); - } - - - private AndroidGraphics nullGraphics; - - private AndroidGraphics getNullGraphics() { - if (nullGraphics == null) { - Bitmap bitmap = Bitmap.createBitmap(getDisplayWidth()==0?100:getDisplayWidth(), getDisplayHeight()==0?100:getDisplayHeight(), - Bitmap.Config.ARGB_8888); - nullGraphics = (AndroidGraphics) this.getNativeGraphics(bitmap); - } - return nullGraphics; - } - - - @Override - public Object getNativeGraphics() { - if(myView != null){ - nullGraphics = null; - return myView.getGraphics(); - }else{ - return getNullGraphics(); - } - } - - @Override - public Object getNativeGraphics(Object image) { - AndroidGraphics g = new AndroidGraphics(this, new Canvas((Bitmap) image), true); - g.underlyingBitmap = (Bitmap) image; - g.setClip(0, 0, ((Bitmap)image).getWidth(), ((Bitmap)image).getHeight()); - return g; - } - - @Override - public void getRGB(Object nativeImage, int[] arr, int offset, int x, int y, - int width, int height) { - ((Bitmap) nativeImage).getPixels(arr, offset, width, x, y, width, - height); - } - - private int sampleSizeOverride = -1; - - @Override - public Object createImage(String path) throws IOException { - int IMAGE_MAX_SIZE = getDisplayHeight(); - if (exists(path)) { - Bitmap b = null; - try { - //Decode image size - BitmapFactory.Options o = new BitmapFactory.Options(); - o.inJustDecodeBounds = true; - o.inPreferredConfig = Bitmap.Config.ARGB_8888; - - InputStream fis = createFileInputStream(path); - BitmapFactory.decodeStream(fis, null, o); - fis.close(); - - int scale = 1; - if (o.outHeight > IMAGE_MAX_SIZE || o.outWidth > IMAGE_MAX_SIZE) { - scale = (int) Math.pow(2, (int) Math.round(Math.log(IMAGE_MAX_SIZE / (double) Math.max(o.outHeight, o.outWidth)) / Math.log(0.5))); - } - - //Decode with inSampleSize - BitmapFactory.Options o2 = new BitmapFactory.Options(); - o2.inPreferredConfig = Bitmap.Config.ARGB_8888; - - if(sampleSizeOverride != -1) { - o2.inSampleSize = sampleSizeOverride; - } else { - String sampleSize = Display.getInstance().getProperty("android.sampleSize", null); - if(sampleSize != null) { - o2.inSampleSize = Integer.parseInt(sampleSize); - } else { - o2.inSampleSize = scale; - } - } - o2.inPurgeable = true; - o2.inInputShareable = true; - fis = createFileInputStream(path); - b = BitmapFactory.decodeStream(fis, null, o2); - fis.close(); - - //fix rotation - ExifInterface exif = new ExifInterface(removeFilePrefix(path)); - int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); - - int angle = 0; - switch (orientation) { - case ExifInterface.ORIENTATION_ROTATE_90: - angle = 90; - break; - case ExifInterface.ORIENTATION_ROTATE_180: - angle = 180; - break; - case ExifInterface.ORIENTATION_ROTATE_270: - angle = 270; - break; - } - - if (sampleSizeOverride < 0 && angle != 0) { - Matrix mat = new Matrix(); - mat.postRotate(angle); - Bitmap correctBmp = Bitmap.createBitmap(b, 0, 0, b.getWidth(), b.getHeight(), mat, true); - b.recycle(); - b = correctBmp; - } - } catch (IOException e) { - } - return b; - } else { - InputStream in = this.getResourceAsStream(getClass(), path); - if (in == null) { - throw new IOException("Resource not found. " + path); - } - try { - return this.createImage(in); - } finally { - if (in != null) { - try { - in.close(); - } catch (Exception ignored) { - ; - } - } - } - } - } - - @Override - public boolean areMutableImagesFast() { - if (myView == null) return false; - return !myView.alwaysRepaintAll(); - } - - @Override - public void repaint(Animation cmp) { - if(myView != null && myView.alwaysRepaintAll()) { - if(cmp instanceof Component) { - Component c = (Component)cmp; - c.setDirtyRegion(null); - if(c.getParent() != null) { - cmp = c.getComponentForm(); - } else { - Form f = getCurrentForm(); - if(f != null) { - cmp = f; - } - } - } else { - // make sure the form is repainted for standalone anims e.g. in the case - // of replace animation - Form f = getCurrentForm(); - if(f != null) { - super.repaint(f); - } - } - } - super.repaint(cmp); - } - - @Override - public Object createImage(InputStream i) throws IOException { - BitmapFactory.Options opts = new BitmapFactory.Options(); - opts.inPreferredConfig = Bitmap.Config.ARGB_8888; - return BitmapFactory.decodeStream(i, null, opts); - } - - @Override - public void releaseImage(Object image) { - Bitmap i = (Bitmap) image; - i.recycle(); - } - - @Override - public Object createImage(byte[] bytes, int offset, int len) { - BitmapFactory.Options opts = new BitmapFactory.Options(); - opts.inPreferredConfig = Bitmap.Config.ARGB_8888; - return BitmapFactory.decodeByteArray(bytes, offset, len, opts); - } - - @Override - public Object createImage(int[] rgb, int width, int height) { - return Bitmap.createBitmap(rgb, width, height, Bitmap.Config.ARGB_8888); - } - - @Override - public boolean isAlphaMutableImageSupported() { - return true; - } - - @Override - public Object scale(Object nativeImage, int width, int height) { - return Bitmap.createScaledBitmap((Bitmap) nativeImage, width, height, - false); - } - - // @Override -// public Object rotate(Object image, int degrees) { -// Matrix matrix = new Matrix(); -// matrix.postRotate(degrees); -// return Bitmap.createBitmap((Bitmap) image, 0, 0, ((Bitmap) image).getWidth(), ((Bitmap) image).getHeight(), matrix, true); -// } - @Override - public boolean isRotationDrawingSupported() { - return false; - } - - @Override - protected boolean cacheLinearGradients() { - return false; - } - - @Override - public boolean isNativeInputSupported() { - return true; - } - - /** - * Returns true if the underlying OS supports opening the native navigation - * application - * @return true if the underlying OS supports launch of native navigation app - */ - public boolean isOpenNativeNavigationAppSupported(){ - return true; - } - - /** - * Opens the native navigation app in the given coordinate. - * @param latitude - * @param longitude - */ - public void openNativeNavigationApp(double latitude, double longitude){ - execute("google.navigation:ll=" + latitude+ "," + longitude); - } - - - @Override - public void openNativeNavigationApp(String location) { - execute("google.navigation:q=" + Util.encodeUrl(location)); - } - - @Override - public Object createMutableImage(int width, int height, int fillColor) { - Bitmap bitmap = Bitmap.createBitmap(width, height, - Bitmap.Config.ARGB_8888); - AndroidGraphics graphics = (AndroidGraphics) this.getNativeGraphics(bitmap); - graphics.fillBitmap(fillColor); - return bitmap; - } - - @Override - public int getImageHeight(Object i) { - return ((Bitmap) i).getHeight(); - } - - @Override - public int getImageWidth(Object i) { - return ((Bitmap) i).getWidth(); - } - - @Override - public void drawImage(Object graphics, Object img, int x, int y) { - ((AndroidGraphics) graphics).drawImage(img, x, y); - } - - @Override - public void tileImage(Object graphics, Object img, int x, int y, int w, int h) { - ((AndroidGraphics) graphics).tileImage(img, x, y, w, h); - } - - public boolean isScaledImageDrawingSupported() { - return true; - } - - public void drawImage(Object graphics, Object img, int x, int y, int w, int h) { - ((AndroidGraphics) graphics).drawImage(img, x, y, w, h); - } - - @Override - public void drawLine(Object graphics, int x1, int y1, int x2, int y2) { - ((AndroidGraphics) graphics).drawLine(x1, y1, x2, y2); - } - - @Override - public boolean isAntiAliasingSupported() { - return true; - } - - @Override - public void setAntiAliased(Object graphics, boolean a) { - ((AndroidGraphics) graphics).getPaint().setAntiAlias(a); - } - - @Override - public void drawPolygon(Object graphics, int[] xPoints, int[] yPoints, int nPoints) { - ((AndroidGraphics) graphics).drawPolygon(xPoints, yPoints, nPoints); - } - - @Override - public void fillPolygon(Object graphics, int[] xPoints, int[] yPoints, int nPoints) { - ((AndroidGraphics) graphics).fillPolygon(xPoints, yPoints, nPoints); - } - - @Override - public void drawRGB(Object graphics, int[] rgbData, int offset, int x, - int y, int w, int h, boolean processAlpha) { - ((AndroidGraphics) graphics).drawRGB(rgbData, offset, x, y, w, h, processAlpha); - } - - @Override - public void drawRect(Object graphics, int x, int y, int width, int height) { - ((AndroidGraphics) graphics).drawRect(x, y, width, height); - } - - @Override - public void drawRoundRect(Object graphics, int x, int y, int width, - int height, int arcWidth, int arcHeight) { - ((AndroidGraphics) graphics).drawRoundRect(x, y, width, height, arcWidth, arcHeight); - } - - @Override - public void drawString(Object graphics, String str, int x, int y) { - ((AndroidGraphics) graphics).drawString(str, x, y); - } - - @Override - public void drawArc(Object graphics, int x, int y, int width, int height, - int startAngle, int arcAngle) { - ((AndroidGraphics) graphics).drawArc(x, y, width, height, startAngle, arcAngle); - } - - @Override - public void fillArc(Object graphics, int x, int y, int width, int height, - int startAngle, int arcAngle) { - ((AndroidGraphics) graphics).fillArc(x, y, width, height, startAngle, arcAngle); - } - - @Override - public void fillRect(Object graphics, int x, int y, int width, int height) { - ((AndroidGraphics) graphics).fillRect(x, y, width, height); - } - - @Override - public void fillRect(Object graphics, int x, int y, int w, int h, byte alpha) { - ((AndroidGraphics) graphics).fillRect(x, y, w, h, alpha); - } - - @Override - public void paintComponentBackground(Object graphics, int x, int y, int width, int height, Style s) { - if((!asyncView) || compatPaintMode ) { - super.paintComponentBackground(graphics, x, y, width, height, s); - return; - } - ((AndroidGraphics) graphics).paintComponentBackground(x, y, width, height, s); - } - - @Override - public void fillLinearGradient(Object graphics, int startColor, int endColor, int x, int y, int width, int height, boolean horizontal) { - if(!asyncView) { - super.fillLinearGradient(graphics, startColor, endColor, x, y, width, height, horizontal); - return; - } - ((AndroidGraphics)graphics).fillLinearGradient(startColor, endColor, x, y, width, height, horizontal); - } - - @Override - public void fillRectRadialGradient(Object graphics, int startColor, int endColor, int x, int y, int width, int height, float relativeX, float relativeY, float relativeSize) { - if(!asyncView) { - super.fillRectRadialGradient(graphics, startColor, endColor, x, y, width, height, relativeX, relativeY, relativeSize); - return; - } - ((AndroidGraphics)graphics).fillRectRadialGradient(startColor, endColor, x, y, width, height, relativeX, relativeY, relativeSize); - } - - @Override - public void fillRadialGradient(Object graphics, int startColor, int endColor, int x, int y, int width, int height) { - ((AndroidGraphics)graphics).fillRadialGradient(startColor, endColor, x, y, width, height); - } - - @Override - public void fillRadialGradient(Object graphics, int startColor, int endColor, int x, int y, int width, int height, int startAngle, int arcAngle) { - ((AndroidGraphics)graphics).fillRadialGradient(startColor, endColor, x, y, width, height, startAngle, arcAngle); - } - - @Override - public void fillGradient(Object graphics, com.codename1.ui.Gradient gradient, - int x, int y, int width, int height) { - // Always route Android multi-stop gradients through the native Shader - // path - the software rasterizer in the base impl would otherwise - // allocate a per-call ARGB buffer on the Bitmap-graphics path used by - // mutable images, which on Android emulator hardware GCs heavily for - // conic / large fills (the case that hung the instrumentation suite). - ((AndroidGraphics) graphics).fillGradient(gradient, x, y, width, height); - } - - @Override - public void drawLabelComponent(Object nativeGraphics, int cmpX, int cmpY, int cmpHeight, int cmpWidth, Style style, String text, Object icon, Object stateIcon, int preserveSpaceForState, int gap, boolean rtl, boolean isOppositeSide, int textPosition, int stringWidth, boolean isTickerRunning, int tickerShiftText, boolean endsWith3Points, int valign) { - if(AndroidAsyncView.legacyPaintLogic) { - super.drawLabelComponent(nativeGraphics, cmpX, cmpY, cmpHeight, cmpWidth, style, text, icon, stateIcon, preserveSpaceForState, gap, rtl, isOppositeSide, textPosition, stringWidth, isTickerRunning, tickerShiftText, endsWith3Points, valign); - return; - } - ((AndroidGraphics)nativeGraphics).drawLabelComponent(cmpX, cmpY, cmpHeight, cmpWidth, style, text, - (Bitmap)icon, (Bitmap)stateIcon, preserveSpaceForState, gap, rtl, isOppositeSide, textPosition, stringWidth, - isTickerRunning, tickerShiftText, endsWith3Points, valign); - } - - - @Override - public void fillRoundRect(Object graphics, int x, int y, int width, - int height, int arcWidth, int arcHeight) { - ((AndroidGraphics) graphics).fillRoundRect(x, y, width, height, arcWidth, arcHeight); - } - - @Override - public int getAlpha(Object graphics) { - return ((AndroidGraphics) graphics).getAlpha(); - } - - @Override - public void setAlpha(Object graphics, int alpha) { - ((AndroidGraphics) graphics).setAlpha(alpha); - } - - @Override - public boolean isAlphaGlobal() { - return true; - } - - @Override - public void setColor(Object graphics, int RGB) { - ((AndroidGraphics) graphics).setColor((getColor(graphics) & 0xff000000) | RGB); - } - - @Override - public int getBackKeyCode() { - return DROID_IMPL_KEY_BACK; - } - - @Override - public int getBackspaceKeyCode() { - return DROID_IMPL_KEY_BACKSPACE; - } - - @Override - public int getClearKeyCode() { - return DROID_IMPL_KEY_CLEAR; - } - - @Override - public int getClipHeight(Object graphics) { - return ((AndroidGraphics) graphics).getClipHeight(); - } - - @Override - public int getClipWidth(Object graphics) { - return ((AndroidGraphics) graphics).getClipWidth(); - } - - @Override - public int getClipX(Object graphics) { - return ((AndroidGraphics) graphics).getClipX(); - } - - @Override - public int getClipY(Object graphics) { - return ((AndroidGraphics) graphics).getClipY(); - } - - @Override - public void setClip(Object graphics, int x, int y, int width, int height) { - ((AndroidGraphics) graphics).setClip(x, y, width, height); - } - - @Override - public boolean isShapeClipSupported(Object graphics){ - return Build.VERSION.SDK_INT > Build.VERSION_CODES.HONEYCOMB; - } - - @Override - public void setClip(Object graphics, Shape shape) { - //Path p = cn1ShapeToAndroidPath(shape); - ((AndroidGraphics) graphics).setClip(shape); - } - - - @Override - public void clipRect(Object graphics, int x, int y, int width, int height) { - ((AndroidGraphics) graphics).clipRect(x, y, width, height); - } - - @Override - public int getColor(Object graphics) { - return ((AndroidGraphics) graphics).getColor(); - } - - @Override - public int getDisplayHeight() { - if (this.myView != null) { - int h = this.myView.getViewHeight(); - displayHeight = h; - return h; - } - return displayHeight; - } - - @Override - public int getDisplayWidth() { - if (this.myView != null) { - int w = this.myView.getViewWidth(); - displayWidth = w; - return w; - } - return displayWidth; - } - - @Override - public int getActualDisplayHeight() { - DisplayMetrics dm = getContext().getResources().getDisplayMetrics(); - return dm.heightPixels; - } - - @Override - public int getGameAction(int keyCode) { - switch (keyCode) { - case DROID_IMPL_KEY_DOWN: - return Display.GAME_DOWN; - case DROID_IMPL_KEY_UP: - return Display.GAME_UP; - case DROID_IMPL_KEY_LEFT: - return Display.GAME_LEFT; - case DROID_IMPL_KEY_RIGHT: - return Display.GAME_RIGHT; - case DROID_IMPL_KEY_FIRE: - return Display.GAME_FIRE; - default: - return 0; - } - } - - @Override - public int getKeyCode(int gameAction) { - switch (gameAction) { - case Display.GAME_DOWN: - return DROID_IMPL_KEY_DOWN; - case Display.GAME_UP: - return DROID_IMPL_KEY_UP; - case Display.GAME_LEFT: - return DROID_IMPL_KEY_LEFT; - case Display.GAME_RIGHT: - return DROID_IMPL_KEY_RIGHT; - case Display.GAME_FIRE: - return DROID_IMPL_KEY_FIRE; - default: - return 0; - } - } - - @Override - public int[] getSoftkeyCode(int index) { - if (index == 0) { - return leftSK; - } - return null; - } - - @Override - public int getSoftkeyCount() { - /** - * one menu button only. we may have to stuff some code here as soon as - * there are devices that no longer have only a single menu button. - */ - return 1; - } - - @Override - public void vibrate(int duration) { - if (!this.vibrateInitialized) { - try { - v = (Vibrator) getContext().getSystemService(Context.VIBRATOR_SERVICE); - } catch (Throwable e) { - Log.e("Codename One", "problem with virbrator(0)", e); - } finally { - this.vibrateInitialized = true; - } - } - if (v != null) { - try { - v.vibrate(duration); - } catch (Throwable e) { - Log.e("Codename One", "problem with virbrator(1)", e); - } - } - } - - @Override - public boolean isTouchDevice() { - return getContext().getPackageManager().hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN); - } - - @Override - public boolean hasPendingPaints() { - //if the view is not visible make sure the edt won't wait. - if (myView != null && myView.getAndroidView().getVisibility() != View.VISIBLE) { - return true; - } else { - return super.hasPendingPaints(); - } - } - - public void revalidate() { - if (myView != null) { - myView.getAndroidView().setVisibility(View.VISIBLE); - Form form = getCurrentForm(); - if (form != null) { - form.revalidate(); - } - flushGraphics(); - } - - } - - @Override - public int getKeyboardType() { - if (Display.getInstance().getDefaultVirtualKeyboard().isVirtualKeyboardShowing()) { - return Display.KEYBOARD_TYPE_VIRTUAL; - } - /** - * can we detect this? but even if we could i think it is best to have - * this fixed to qwerty. we pass unicode values to Codename One in any - * case. check AndroidView.onKeyUpDown() method. and read comment below. - */ - return Display.KEYBOARD_TYPE_QWERTY; - /** - * some info from the MIDP docs about keycodes: - * - * "Applications receive keystroke events in which the individual keys - * are named within a space of key codes. Every key for which events are - * reported to MIDP applications is assigned a key code. The key code - * values are unique for each hardware key unless two keys are obvious - * synonyms for each other. MIDP defines the following key codes: - * KEY_NUM0, KEY_NUM1, KEY_NUM2, KEY_NUM3, KEY_NUM4, KEY_NUM5, KEY_NUM6, - * KEY_NUM7, KEY_NUM8, KEY_NUM9, KEY_STAR, and KEY_POUND. (These key - * codes correspond to keys on a ITU-T standard telephone keypad.) Other - * keys may be present on the keyboard, and they will generally have key - * codes distinct from those list above. In order to guarantee - * portability, applications should use only the standard key codes. - * - * The standard key codes values are equal to the Unicode encoding for - * the character that represents the key. If the device includes any - * other keys that have an obvious correspondence to a Unicode - * character, their key code values should equal the Unicode encoding - * for that character. For keys that have no corresponding Unicode - * character, the implementation must use negative values. Zero is - * defined to be an invalid key code." - * - * Because the MIDP implementation is our reference and that - * implementation does not interpret the given keycodes we behave alike - * and pass on the unicode values. - */ - } - - /** - * Exits the application... - */ - public void exitApplication() { - android.os.Process.killProcess(android.os.Process.myPid()); - } - - /** - * finishAndRemoveTask() arrived in Lollipop, and there is nothing to remove without an - * activity -- a push or background service process owns no task of its own. - */ - @Override - public boolean isExitAndClearTaskSupported() { - return Build.VERSION.SDK_INT >= 21 && getActivity() != null; - } - - @Override - public void exitApplicationAndClearTask() { - final CodenameOneActivity a = getActivity(); - if (a == null || Build.VERSION.SDK_INT < 21) { - exitApplication(); - return; - } - Runnable finishAndKill = new Runnable() { - public void run() { - try { - a.finishAndRemoveTask(); - } catch (Throwable t) { - // A task we failed to remove is still a task we must exit, so log and fall - // through to the kill rather than leaving the application running. - com.codename1.io.Log.e(t); - } - // Killing here is what makes this behave like exitApplication(), which never - // returns to its caller either. It does not race the removal: finishAndRemoveTask() - // is a blocking binder call into the activity manager, so the task is already off - // the recents list when it returns. Measured on an API 36 emulator with a probe - // that ran this exact sequence 29 times -- the task was gone from - // "dumpsys activity recents" every time, while the control that only killed the - // process (what exitApplication() does) left it there every time. - android.os.Process.killProcess(android.os.Process.myPid()); - } - }; - if (Looper.getMainLooper().getThread() == Thread.currentThread()) { - finishAndKill.run(); - } else { - a.runOnUiThread(finishAndKill); - } - } - - @Override - public void notifyPushCompletion() { - if (pushWakeLock != null && pushWakeLock.isHeld()) { - try { - pushWakeLock.release(); - } catch (Exception ex) { - com.codename1.io.Log.e(ex); - } - } - } - - @Override - public void notifyCommandBehavior(int commandBehavior) { - if (commandBehavior == Display.COMMAND_BEHAVIOR_NATIVE) { - if (getActivity() instanceof CodenameOneActivity) { - ((CodenameOneActivity) getActivity()).enableNativeMenu(true); - } - } - } - - private static class NotifyActionBar implements Runnable { - private Activity activity; - private boolean show; - - public NotifyActionBar(Activity activity, int commandBehavior) { - this.activity = activity; - show = commandBehavior == Display.COMMAND_BEHAVIOR_NATIVE; - } - - public NotifyActionBar(Activity activity, boolean show) { - this.activity = activity; - this.show = show; - } - - @Override - public void run() { - activity.invalidateOptionsMenu(); - if (activity.getActionBar() == null) { - return; - } - if (show) { - activity.getActionBar().show(); - } else { - activity.getActionBar().hide(); - } - } - } - - @Override - public String getAppArg() { - if (super.getAppArg() != null) { - // This just maintains backward compatibility in case people are manually - // setting the AppArg in their properties. It reproduces the general - // behaviour the existed when AppArg was just another Display property. - return super.getAppArg(); - } - if (getActivity() == null) { - return null; - } - - android.content.Intent intent = getActivity().getIntent(); - if (intent != null) { - publishIntentProperties(getActivity(), intent); - String sharedText = intent.getStringExtra(Intent.EXTRA_TEXT); - intent.removeExtra(Intent.EXTRA_TEXT); - Uri u = intent.getData(); - String scheme = intent.getScheme(); - if (u != null && isAppArgDelivered(intent)) { - // dispatchNewIntentUrl() already handed this url over as the app arg - // on the warm path. The data stays on the intent for the readers that - // want it -- `android.intent.data` above, and native code asking the - // activity for its intent -- and only the second delivery is dropped. - u = null; - } - if (u == null && intent.getExtras() != null) { - if (intent.getExtras().keySet().contains("android.intent.extra.STREAM")) { - try { - u = (Uri)intent.getParcelableExtra("android.intent.extra.STREAM"); - scheme = u.getScheme(); - System.out.println("u="+u); - } catch (Exception ex) { - Log.d("Codename One", "Failed to load parcelable extra from intent: "+ex.getMessage()); - } - } - - } - if (u != null) { - //String scheme = intent.getScheme(); - intent.setData(null); - if ("content".equals(scheme)) { - try { - InputStream attachment = getActivity().getContentResolver().openInputStream(u); - if (attachment != null) { - String name = getContentName(getActivity().getContentResolver(), u); - if (name != null) { - String filePath = getAppHomePath() - + getFileSystemSeparator() + name; - if(filePath.startsWith("file:")) { - filePath = filePath.substring(5); - } - File f = new File(filePath); - OutputStream tmp = createFileOuputStream(f); - byte[] buffer = new byte[1024]; - int read = -1; - while ((read = attachment.read(buffer)) > -1) { - tmp.write(buffer, 0, read); - } - tmp.close(); - attachment.close(); - setAppArg(addFile(filePath)); - return addFile(filePath); - } - } - } catch (FileNotFoundException e) { - e.printStackTrace(); - return null; - } catch (IOException e) { - e.printStackTrace(); - return null; - } catch (Exception e) { - e.printStackTrace(); - return null; - } - } else { - - /* - // Why do we need this special case? u.toString() - // will include the full URL including query string. - // This special case causes urls like myscheme://part1/part2 - // to only return "/part2" which is obviously problematic and - // is inconsistent with iOS. Is this special case necessary - // in some versions of Android? - String encodedPath = u.getEncodedPath(); - if (encodedPath != null && encodedPath.length() > 0) { - String query = u.getQuery(); - if(query != null && query.length() > 0){ - encodedPath += "?" + query; - } - setAppArg(encodedPath); - return encodedPath; - } - */ - if (sharedText != null) { - setAppArg(sharedText); - return sharedText; - } else { - setAppArg(u.toString()); - return u.toString(); - } - - } - } else if (sharedText != null) { - setAppArg(sharedText); - return sharedText; - } - } - return null; - } - - // taken from https://stackoverflow.com/a/70380413/756809 - private boolean isRunningOnAndroidStudioEmulator() { - return Build.FINGERPRINT.startsWith("google/sdk_gphone") - && Build.FINGERPRINT.endsWith(":user/release-keys") - && "Google".equals(Build.MANUFACTURER) && Build.PRODUCT.startsWith("sdk_gphone") && "google".equals(Build.BRAND) - && Build.MODEL.startsWith("sdk_gphone"); - } - - // taken from https://stackoverflow.com/a/57960169/756809 - private boolean isEmulator() { - return isRunningOnAndroidStudioEmulator() || - ((Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) - || Build.FINGERPRINT.startsWith("generic") - || Build.FINGERPRINT.startsWith("unknown") - || Build.HARDWARE.contains("goldfish") - || Build.HARDWARE.contains("ranchu") - || Build.MODEL.contains("google_sdk") - || Build.MODEL.contains("Emulator") - || Build.MODEL.contains("Android SDK built for x86") - || Build.MODEL.contains("VirtualBox") - || Build.MANUFACTURER.contains("Genymotion") - || Build.PRODUCT.contains("sdk_google") - || Build.PRODUCT.contains("google_sdk") - || Build.PRODUCT.contains("sdk") - || Build.PRODUCT.contains("sdk_x86") - || Build.PRODUCT.contains("vbox86p") - || Build.PRODUCT.contains("emulator") - || Build.PRODUCT.contains("simulator")); - } - - - /** - * @inheritDoc - */ - @Override - public boolean canDial() { - return getContext().getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELEPHONY); - } - - /** - * @inheritDoc - */ - private static String cn1DistributionChannel; - private static boolean cn1DistributionChannelResolved; - /** Codename One channel id-value pair id in the APK Signing Block ('c','n','1','C'). */ - private static final int CN1_CHANNEL_PAIR_ID = 0x636E3143; - - /** - * The distribution channel (app store) stamped into this APK's Signing Block by - * the build server's channel packages, or null for a normal build. Read once and - * cached. Mirrors the daemon's {@code ApkChannelWriter}: locate the signing block - * before the central directory and return the Codename One channel pair's value. - */ - private String readDistributionChannel() { - if (cn1DistributionChannelResolved) { - return cn1DistributionChannel; - } - cn1DistributionChannelResolved = true; - try { - cn1DistributionChannel = cn1ReadChannelFromApk(getContext().getApplicationInfo().sourceDir); - } catch (Throwable t) { - cn1DistributionChannel = null; - } - return cn1DistributionChannel; - } - - private static String cn1ReadChannelFromApk(String path) throws java.io.IOException { - java.io.RandomAccessFile f = new java.io.RandomAccessFile(path, "r"); - try { - long len = f.length(); - long eocd = -1; - long maxBack = Math.min(len, 22 + 0xFFFF); - for (long i = len - 22; i >= len - maxBack && i >= 0; i--) { - if (cn1U32(f, i) == 0x06054b50L) { - eocd = i; - break; - } - } - if (eocd < 0) { - return null; - } - long cdOffset = cn1U32(f, eocd + 16); - if (cdOffset < 24 || cdOffset == 0xFFFFFFFFL) { - return null; - } - byte[] magic = "APK Sig Block 42".getBytes("US-ASCII"); - byte[] m = new byte[magic.length]; - f.seek(cdOffset - 16); - f.readFully(m); - for (int i = 0; i < magic.length; i++) { - if (m[i] != magic[i]) { - return null; - } - } - long sizeOfBlock = cn1U64(f, cdOffset - 24); - long blockStart = cdOffset - 8 - sizeOfBlock; - if (blockStart < 0) { - return null; - } - long p = blockStart + 8, to = cdOffset - 24; - while (p < to) { - long pairLen = cn1U64(f, p); - p += 8; - if (pairLen < 4 || p + pairLen > to + 8) { - break; - } - if ((int) cn1U32(f, p) == CN1_CHANNEL_PAIR_ID) { - byte[] v = new byte[(int) (pairLen - 4)]; - f.seek(p + 4); - f.readFully(v); - return new String(v, "UTF-8"); - } - p += pairLen; - } - return null; - } finally { - f.close(); - } - } - - private static long cn1U32(java.io.RandomAccessFile f, long at) throws java.io.IOException { - f.seek(at); - int b0 = f.read(), b1 = f.read(), b2 = f.read(), b3 = f.read(); - return (b0 & 0xFFL) | ((b1 & 0xFFL) << 8) | ((b2 & 0xFFL) << 16) | ((b3 & 0xFFL) << 24); - } - - private static long cn1U64(java.io.RandomAccessFile f, long at) throws java.io.IOException { - f.seek(at); - long v = 0; - for (int i = 0; i < 8; i++) { - v |= (f.read() & 0xFFL) << (8 * i); - } - return v; - } - - public String getProperty(String key, String defaultValue) { - if(key.equalsIgnoreCase("cn1_push_prefix")) { - /*if(!checkForPermission(Manifest.permission.READ_PHONE_STATE, "This is required to get notifications")){ - return ""; - }*/ - boolean has = hasAndroidMarket(); - if(has) { - return "gcm"; - } - return defaultValue; - } - if ("OS".equals(key)) { - return "Android"; - } - if ("DistributionChannel".equalsIgnoreCase(key) || "cn1.channel".equalsIgnoreCase(key)) { - // The app store this build was distributed through, stamped into the APK - // Signing Block by the Codename One build server's channel packages - // (android.distributionChannels). Empty for a normal Google Play build. - String ch = readDistributionChannel(); - return ch != null ? ch : defaultValue; - } - - // It's possible that this is triggering a Google Play data collection verification error - /*if ("androidId".equals(key)) { - return Settings.Secure.getString(getContext().getContentResolver(), Settings.Secure.ANDROID_ID); - }*/ - - /*if ("cellId".equals(key)) { - try { - if(!checkForPermission(Manifest.permission.READ_PHONE_STATE, "This is required to get the cellId")){ - return defaultValue; - } - String serviceName = Context.TELEPHONY_SERVICE; - TelephonyManager telephonyManager = (TelephonyManager) getContext().getSystemService(serviceName); - int cellId = ((GsmCellLocation) telephonyManager.getCellLocation()).getCid(); - return "" + cellId; - } catch (Throwable t) { - return defaultValue; - } - }*/ - if ("AppName".equals(key)) { - - final PackageManager pm = getContext().getPackageManager(); - ApplicationInfo ai; - try { - ai = pm.getApplicationInfo(getContext().getPackageName(), 0); - } catch (NameNotFoundException e) { - ai = null; - } - String applicationName = (String) (ai != null ? pm.getApplicationLabel(ai) : null); - if(applicationName == null){ - return defaultValue; - } - return applicationName; - } - if ("AppVersion".equals(key)) { - try { - PackageInfo i = getContext().getPackageManager().getPackageInfo(getContext().getApplicationInfo().packageName, 0); - return i.versionName; - } catch (NameNotFoundException ex) { - ex.printStackTrace(); - } - return defaultValue; - } - if ("Platform".equals(key)) { - String p = System.getProperty("platform"); - if(p == null) { - return defaultValue; - } - return p; - } - if ("User-Agent".equals(key)) { - String ua = getUserAgent(); - if(ua == null) { - return defaultValue; - } - return ua; - } - if("OSVer".equals(key)) { - return "" + android.os.Build.VERSION.RELEASE; - } - if("DeviceName".equals(key)) { - return "" + android.os.Build.MODEL; - } - if("DeviceHardwareModel".equals(key)) { - return "" + android.os.Build.MODEL; - } - if("DeviceManufacturer".equals(key)) { - return "" + android.os.Build.MANUFACTURER; - } - if("Emulator".equals(key)) { - return "" + isEmulator(); - } - /*try { - if ("IMEI".equals(key) || "UDID".equals(key)) { - if(!checkForPermission(Manifest.permission.READ_PHONE_STATE, "This is required to get the device ID")){ - return ""; - } - TelephonyManager tm = (TelephonyManager) getContext().getSystemService(Context.TELEPHONY_SERVICE); - String imei = null; - if (tm!=null && tm.getDeviceId() != null) { - // for phones or 3g tablets - imei = tm.getDeviceId(); - } else { - try { - imei = Secure.getString(getContext().getContentResolver(), Secure.ANDROID_ID); - } catch(Throwable t) { - com.codename1.io.Log.e(t); - } - } - return imei; - } - if ("MSISDN".equals(key)) { - if(!checkForPermission(Manifest.permission.READ_PHONE_STATE, "This is required to get the device ID")){ - return ""; - } - TelephonyManager tm = (TelephonyManager) getContext().getSystemService(Context.TELEPHONY_SERVICE); - return tm.getLine1Number(); - } - } catch(Throwable t) { - // will be caused by no permissions. - return defaultValue; - }*/ - - if (getActivity() != null) { - android.content.Intent intent = getActivity().getIntent(); - if(intent != null){ - Bundle extras = intent.getExtras(); - if (extras != null) { - String value = extras.getString(key); - if(value != null) { - return value; - } - } - } - } - - if(!key.startsWith("android.permission")) { - //these keys/values are from the Application Resources (strings values) - try { - int id = getContext().getResources().getIdentifier(key, "string", getContext().getApplicationInfo().packageName); - if (id != 0) { - String val = getContext().getResources().getString(id); - return val; - } - } catch (Exception e) { - } - } - return System.getProperty(key, super.getProperty(key, defaultValue)); - } - - private String getContentName(ContentResolver resolver, Uri uri) { - Cursor cursor = resolver.query(uri, null, null, null, null); - cursor.moveToFirst(); - int nameIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME); - if (nameIndex >= 0) { - String name = cursor.getString(nameIndex); - cursor.close(); - return name; - } - return null; - } - - private String getUserAgent() { - try { - String userAgent = System.getProperty("http.agent"); - if(userAgent != null){ - return userAgent; - } - } catch (Exception e) { - } - if (getActivity() == null) { - return "Android-CN1"; - } - try { - Constructor constructor = WebSettings.class.getDeclaredConstructor(Context.class, WebView.class); - constructor.setAccessible(true); - try { - WebSettings settings = constructor.newInstance(getActivity(), null); - return settings.getUserAgentString(); - } finally { - constructor.setAccessible(false); - } - } catch (Exception e) { - final StringBuffer ua = new StringBuffer(); - if (Thread.currentThread().getName().equalsIgnoreCase("main")) { - WebView m_webview = new WebView(getActivity()); - ua.append(m_webview.getSettings().getUserAgentString()); - m_webview.destroy(); - } else { - final boolean[] flag = new boolean[1]; - Thread thread = new Thread() { - public void run() { - Looper.prepare(); - WebView m_webview = new WebView(getActivity()); - ua.append(m_webview.getSettings().getUserAgentString()); - m_webview.destroy(); - Looper.loop(); - flag[0] = true; - synchronized (flag) { - flag.notify(); - } - } - }; - thread.setUncaughtExceptionHandler(AndroidImplementation.exceptionHandler); - thread.start(); - while (!flag[0]) { - synchronized (flag) { - try { - flag.wait(100); - } catch (InterruptedException ex) { - } - } - } - } - return ua.toString(); - } - } - - private String getMimeType(String url){ - String type = null; - String extension = MimeTypeMap.getFileExtensionFromUrl(url); - if (extension != null) { - MimeTypeMap mime = MimeTypeMap.getSingleton(); - - type = mime.getMimeTypeFromExtension(extension); - } - if (type == null) { - try { - Uri uri = Uri.parse(url); - ContentResolver cr = getContext().getContentResolver(); - type = cr.getType(uri); - } catch (Throwable t) { - t.printStackTrace(); - } - } - return type; - } - - public static void copy(File src, File dst) throws IOException { - InputStream in = new FileInputStream(src); - try { - OutputStream out = new FileOutputStream(dst); - try { - // Transfer bytes from in to out - byte[] buf = new byte[8096]; - int len; - while ((len = in.read(buf)) > 0) { - out.write(buf, 0, len); - } - } finally { - out.close(); - } - } finally { - in.close(); - } - } - - private static File makeTempCacheCopy(File file) throws IOException { - File cacheDir = new File(getContext().getCacheDir(), "intent_files"); - - // Create the storage directory if it does not exist - if (!cacheDir.exists()) { - if (!cacheDir.mkdirs()) { - Log.d(Display.getInstance().getProperty("AppName", "CodenameOne"), "failed to create directory"); - return null; - } - } - - File copy = new File(cacheDir, "tmp-"+System.currentTimeMillis()+file.getName()); - copy(file, copy); - return copy; - - } - - - - private Intent createIntentForURL(String url) { - Intent intent; - Uri uri; - try { - if (url.startsWith("intent")) { - intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME); - } else { - if(url.startsWith("/") || url.startsWith("file:")) { - if (PermissionsHelper.requiresExternalStoragePermissionForMediaAccess()) { - if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to open the file")){ - return null; - } - } - - } - intent = new Intent(); - intent.setAction(Intent.ACTION_VIEW); - if (url.startsWith("/")) { - File f = new File(url); - Uri furi = null; - try { - furi = FileProvider.getUriForFile(getContext(), getContext().getPackageName()+".provider", f); - } catch (Exception ex) { - f = makeTempCacheCopy(f); - furi = FileProvider.getUriForFile(getContext(), getContext().getPackageName()+".provider", f); - } - - - if (Build.VERSION.SDK_INT < 21) { - List resInfoList = getContext().getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); - for (ResolveInfo resolveInfo : resInfoList) { - String packageName = resolveInfo.activityInfo.packageName; - getContext().grantUriPermission(packageName, furi, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); - } - } - - uri = furi; - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK|Intent.FLAG_GRANT_READ_URI_PERMISSION); - }else{ - - if (url.startsWith("file:")) { - File f = new File(removeFilePrefix(url)); - System.out.println("File size: "+f.length()); - - Uri furi = null; - try { - furi = FileProvider.getUriForFile(getContext(), getContext().getPackageName()+".provider", f); - } catch (Exception ex) { - f = makeTempCacheCopy(f); - furi = FileProvider.getUriForFile(getContext(), getContext().getPackageName()+".provider", f); - } - - - if (Build.VERSION.SDK_INT < 21) { - List resInfoList = getContext().getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); - for (ResolveInfo resolveInfo : resInfoList) { - String packageName = resolveInfo.activityInfo.packageName; - getContext().grantUriPermission(packageName, furi, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); - } - } - uri = furi; - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK|Intent.FLAG_GRANT_READ_URI_PERMISSION); - - - } else { - uri = Uri.parse(url); - } - } - String mimeType = getMimeType(url); - if(mimeType != null){ - intent.setDataAndType(uri, mimeType); - }else{ - intent.setData(uri); - } - } - - return intent; - } catch(Exception err) { - com.codename1.io.Log.e(err); - return null; - } - } - - @Override - public Boolean canExecute(String url) { - try { - Intent it = createIntentForURL(url); - if(it == null) { - return false; - } - final PackageManager mgr = getContext().getPackageManager(); - List list = mgr.queryIntentActivities(it, PackageManager.MATCH_DEFAULT_ONLY); - return list.size() > 0; - } catch(Exception err) { - com.codename1.io.Log.e(err); - return false; - } - } - - - public void execute(String url, ActionListener response) { - if (response != null) { - callback = new EventDispatcher(); - callback.addListener(response); - } - - try { - Intent intent = createIntentForURL(url); - if(intent == null) { - return; - } - if(response != null && getActivity() != null){ - getActivity().startActivityForResult(intent, IntentResultListener.URI_SCHEME); - }else { - getContext().startActivity(intent); - } - return; - } catch (Exception ex) { - com.codename1.io.Log.e(ex); - } - - try { - if(editInProgress()) { - stopEditing(true); - } - getContext().startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url))); - } catch (Exception e) { - e.printStackTrace(); - } - } - - - /** - * @inheritDoc - */ - @Override - public void execute(String url) { - execute(url, null); - } - - /** - * @inheritDoc - */ - public void playBuiltinSound(String soundIdentifier) { - if (getActivity() != null && Display.SOUND_TYPE_BUTTON_PRESS.equals(soundIdentifier)) { - getActivity().runOnUiThread(new Runnable() { - public void run() { - if (myView != null) { - myView.getAndroidView().playSoundEffect(AudioManager.FX_KEY_CLICK); - } - } - }); - } - } - - /** - * @inheritDoc - */ - protected void playNativeBuiltinSound(Object data) { - } - - /** - * @inheritDoc - */ - public boolean isBuiltinSoundAvailable(String soundIdentifier) { - return false; - } - - /** - * @inheritDoc - */ - @Override - public boolean isNativeVideoPlayerControlsIncluded() { - return true; - } - - private static final int STATE_PAUSED = 0; - private static final int STATE_PLAYING = 1; - - private int mCurrentState; - - private MediaBrowserCompat mMediaBrowserCompat; - private android.support.v4.media.session.MediaControllerCompat mMediaControllerCompat; - - private android.support.v4.media.session.MediaControllerCompat.Callback mMediaControllerCompatCallback = new android.support.v4.media.session.MediaControllerCompat.Callback() { - - @Override - public void onPlaybackStateChanged(PlaybackStateCompat state) { - super.onPlaybackStateChanged(state); - if( state == null ) { - return; - } - - switch( state.getState() ) { - case PlaybackStateCompat.STATE_PLAYING: { - mCurrentState = STATE_PLAYING; - break; - } - case PlaybackStateCompat.STATE_PAUSED: { - mCurrentState = STATE_PAUSED; - break; - } - } - } - }; - - private MediaBrowserCompat.ConnectionCallback mMediaBrowserCompatConnectionCallback = new MediaBrowserCompat.ConnectionCallback() { - - @Override - public void onConnected() { - super.onConnected(); - try { - mMediaControllerCompat = new MediaControllerCompat(getActivity(), mMediaBrowserCompat.getSessionToken()); - mMediaControllerCompat.registerCallback(mMediaControllerCompatCallback); - MediaControllerCompat.setMediaController(getActivity(), mMediaControllerCompat); - MediaControllerCompat.getMediaController(getActivity()).getTransportControls().play(); - - } catch( RemoteException e ) { - e.printStackTrace(); - } - } - }; - - //BackgroundAudioService remoteControl; - - @Override - public void startRemoteControl() { - super.startRemoteControl(); - getActivity().runOnUiThread(new Runnable() { - public void run() { - mMediaBrowserCompat = new MediaBrowserCompat(getActivity(), new ComponentName(getActivity(), BackgroundAudioService.class), - mMediaBrowserCompatConnectionCallback, getActivity().getIntent().getExtras()); - - mMediaBrowserCompat.connect(); - AndroidNativeUtil.addLifecycleListener(new LifecycleListener() { - @Override - public void onCreate(Bundle savedInstanceState) { - - } - - @Override - public void onResume() { - - } - - @Override - public void onPause() { - - } - - @Override - public void onDestroy() { - if (mMediaBrowserCompat != null) { - if( MediaControllerCompat.getMediaController(getActivity()).getPlaybackState().getState() == PlaybackStateCompat.STATE_PLAYING ) { - MediaControllerCompat.getMediaController(getActivity()).getTransportControls().pause(); - } - - mMediaBrowserCompat.disconnect(); - mMediaBrowserCompat = null; - } - } - - @Override - public void onSaveInstanceState(Bundle b) { - - } - - @Override - public void onLowMemory() { - - } - }); - } - - }); - - } - - @Override - public void stopRemoteControl() { - super.stopRemoteControl(); - if (mMediaBrowserCompat != null) { - if( MediaControllerCompat.getMediaController(getActivity()).getPlaybackState().getState() == PlaybackStateCompat.STATE_PLAYING ) { - MediaControllerCompat.getMediaController(getActivity()).getTransportControls().pause(); - } - - mMediaBrowserCompat.disconnect(); - mMediaBrowserCompat = null; - } - } - - - @Override - public AsyncResource createBackgroundMediaAsync(final String uri) { - final AsyncResource out = new AsyncResource(); - new Thread(new Runnable() { - public void run() { - try { - out.complete(createBackgroundMedia(uri)); - } catch (IOException ex) { - out.error(ex); - } - } - }).start(); - - return out; - } - - private int nextMediaId; - private int backgroundMediaCount; - private ServiceConnection backgroundMediaServiceConnection; - @Override - public Media createBackgroundMedia(final String uri) throws IOException { - int mediaId = nextMediaId++; - backgroundMediaCount++; - - Intent serviceIntent = new Intent(getContext(), AudioService.class); - serviceIntent.putExtra("mediaLink", uri); - serviceIntent.putExtra("mediaId", mediaId); - if (background == null) { - ServiceConnection mConnection = new ServiceConnection() { - - public void onServiceDisconnected(ComponentName name) { - - background = null; - backgroundMediaServiceConnection = null; - } - - public void onServiceConnected(ComponentName name, IBinder service) { - AudioService.LocalBinder mLocalBinder = (AudioService.LocalBinder) service; - AudioService svc = (AudioService)mLocalBinder.getService(); - background = svc; - } - }; - backgroundMediaServiceConnection = mConnection; - boolean boundSuccess = getContext().bindService(serviceIntent, mConnection, getContext().BIND_AUTO_CREATE); - if (!boundSuccess) { - throw new RuntimeException("Failed to bind background media service for uri "+uri); - } - ContextCompat.startForegroundService(getContext(), serviceIntent); - while (background == null) { - Display.getInstance().invokeAndBlock(new Runnable() { - @Override - public void run() { - Util.sleep(200); - } - }); - } - } else { - ContextCompat.startForegroundService(getContext(), serviceIntent); - } - - while (background.getMedia(mediaId) == null) { - Display.getInstance().invokeAndBlock(new Runnable() { - public void run() { - Util.sleep(200); - } - - }); - } - Media ret = new MediaProxy(background.getMedia(mediaId)) { - - - @Override - public void cleanup() { - super.cleanup(); - if (--backgroundMediaCount <= 0) { - if (backgroundMediaServiceConnection != null) { - try { - getContext().unbindService(backgroundMediaServiceConnection); - } catch (IllegalArgumentException ex) { - // This is thrown sometimes if the service has already been unbound - } - } - } - } - }; - - return ret; - - } - - - /** - * @inheritDoc - */ - @Override - public Media createMedia(final String uri, boolean isVideo, final Runnable onCompletion) throws IOException { - if (getActivity() == null) { - return null; - } - if (uri.startsWith("file://")) { - return createMedia(removeFilePrefix(uri), isVideo, onCompletion); - } - File file = null; - if (uri.indexOf(':') < 0) { - // use a file object to play to try and workaround this issue: - // http://code.google.com/p/android/issues/detail?id=4124 - file = new File(uri); - } - - Uri parsedUri = null; - boolean isContentUri = false; - if (file == null) { - parsedUri = Uri.parse(uri); - isContentUri = parsedUri != null && "content".equalsIgnoreCase(parsedUri.getScheme()); - } - - // The document picker grants temporary permissions for content URIs. Requesting - // READ_EXTERNAL_STORAGE again would surface a redundant prompt on Android 13+, so we only - // ask for classic file paths that require the legacy permission. MediaStore URIs still - // require an explicit permission grant, so they remain subject to the legacy check even - // though they also use the content:// scheme. - boolean requiresLegacyPermission = !uri.startsWith(FileSystemStorage.getInstance().getAppHomePath()); - if (isContentUri && parsedUri != null) { - String authority = parsedUri.getAuthority(); - if (authority != null) { - authority = authority.toLowerCase(); - if (!"media".equals(authority) && !authority.startsWith("media.")) { - if (!"com.android.providers.media.documents".equals(authority)) { - requiresLegacyPermission = false; - } - } - } else { - requiresLegacyPermission = false; - } - } - - if(requiresLegacyPermission) { - if(!PermissionsHelper.checkForPermission(isVideo ? DevicePermission.PERMISSION_READ_VIDEO : DevicePermission.PERMISSION_READ_AUDIO, "This is required to play media")){ - return null; - } - } - - Media retVal; - - if (isVideo) { - final AndroidImplementation.Video[] video = new AndroidImplementation.Video[1]; - final boolean[] flag = new boolean[1]; - final File f = file; - final Uri videoUri = parsedUri; - getActivity().runOnUiThread(new Runnable() { - @Override - public void run() { - VideoView v = new VideoView(getActivity()); - v.setZOrderMediaOverlay(true); - if (f != null) { - v.setVideoURI(Uri.fromFile(f)); - } else { - v.setVideoURI(videoUri != null ? videoUri : Uri.parse(uri)); - } - video[0] = new AndroidImplementation.Video(v, getActivity(), onCompletion); - flag[0] = true; - synchronized (flag) { - flag.notify(); - } - } - }); - while (!flag[0]) { - synchronized (flag) { - try { - flag.wait(100); - } catch (InterruptedException ex) { - } - } - } - return video[0]; - } else { - MediaPlayer player; - if (file != null) { - FileInputStream is = new FileInputStream(file); - player = new MediaPlayer(); - player.setDataSource(is.getFD()); - player.prepare(); - } else { - player = MediaPlayer.create(getActivity(), parsedUri != null ? parsedUri : Uri.parse(uri)); - if (player == null && isContentUri) { - // Android 13+ introduces stricter access rules for content:// URIs returned - // from the system document picker. The picker grants our activity a - // persistable read permission, but some OEM builds still reject the URI when it - // is passed directly to MediaPlayer. Opening the descriptor ourselves keeps the - // same permission grant while avoiding the OEM bug. - ContentResolver resolver = getContext().getContentResolver(); - if (resolver != null && parsedUri != null) { - AssetFileDescriptor afd = null; - try { - afd = resolver.openAssetFileDescriptor(parsedUri, "r"); - if (afd != null) { - player = new MediaPlayer(); - player.setDataSource(afd.getFileDescriptor(), afd.getStartOffset(), afd.getLength()); - player.prepare(); - } - } finally { - if (afd != null) { - try { - afd.close(); - } catch (IOException ignore) { - } - } - } - } - } - } - if (player == null) { - throw new IOException("Unable to create media player for uri " + uri); - } - retVal = new Audio(getActivity(), player, null, onCompletion); - } - return retVal; - } - - @Override - public void addCompletionHandler(Media media, Runnable onCompletion) { - super.addCompletionHandler(media, onCompletion); - if (media instanceof Video) { - ((Video)media).addCompletionHandler(onCompletion); - } else if (media instanceof Audio) { - ((Audio)media).addCompletionHandler(onCompletion); - } else if (media instanceof MediaProxy) { - ((MediaProxy)media).addCompletionHandler(onCompletion); - } - } - - @Override - public void removeCompletionHandler(Media media, Runnable onCompletion) { - super.removeCompletionHandler(media, onCompletion); - if (media instanceof Video) { - ((Video)media).removeCompletionHandler(onCompletion); - } else if (media instanceof Audio) { - ((Audio)media).removeCompletionHandler(onCompletion); - } else if (media instanceof MediaProxy) { - ((MediaProxy)media).removeCompletionHandler(onCompletion); - } - } - - - - /** - * @inheritDoc - */ - @Override - public Media createMedia(InputStream stream, String mimeType, final Runnable onCompletion) throws IOException { - if (getActivity() == null) { - return null; - } - /*if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to play media")){ - return null; - }*/ - boolean isVideo = mimeType.contains("video"); - - if (!isVideo && stream instanceof FileInputStream) { - MediaPlayer player = new MediaPlayer(); - player.setDataSource(((FileInputStream) stream).getFD()); - player.prepare(); - return new Audio(getActivity(), player, stream, onCompletion); - } - String extension = MimeTypeMap.getFileExtensionFromUrl(mimeType); - final File temp = File.createTempFile("mtmp", extension == null ? "dat" : extension); - temp.deleteOnExit(); - OutputStream out = createFileOuputStream(temp); - - byte buf[] = new byte[256]; - int len = 0; - while ((len = stream.read(buf, 0, buf.length)) > -1) { - out.write(buf, 0, len); - } - out.close(); - stream.close(); - - final Runnable finish = new Runnable() { - - @Override - public void run() { - if(onCompletion != null){ - Display.getInstance().callSerially(onCompletion); - - // makes sure the file is only deleted after the onCompletion was invoked - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - temp.delete(); - } - }); - return; - } - temp.delete(); - } - }; - - if (isVideo) { - final AndroidImplementation.Video[] retVal = new AndroidImplementation.Video[1]; - final boolean[] flag = new boolean[1]; - - getActivity().runOnUiThread(new Runnable() { - @Override - public void run() { - VideoView v = new VideoView(getActivity()); - v.setZOrderMediaOverlay(true); - v.setVideoURI(Uri.fromFile(temp)); - retVal[0] = new AndroidImplementation.Video(v, getActivity(), finish); - flag[0] = true; - synchronized (flag) { - flag.notify(); - } - } - }); - while (!flag[0]) { - synchronized (flag) { - try { - flag.wait(100); - } catch (InterruptedException ex) { - } - } - } - - return retVal[0]; - } else { - return createMedia(createFileInputStream(temp), mimeType, finish); - } - - } - - @Override - public boolean isSoundPoolSupported() { - return getContext() != null; - } - - @Override - public com.codename1.media.SoundPoolPeer createSoundPool(int maxStreams) { - if (getContext() == null) { - return null; - } - return new com.codename1.media.GameSoundPool(this, maxStreams); - } - - @Override - public Media createMediaRecorder(MediaRecorderBuilder builder) throws IOException { - return createMediaRecorder(builder.getPath(), builder.getMimeType(), builder.getSamplingRate(), builder.getBitRate(), builder.getAudioChannels(), 0, builder.isRedirectToAudioBuffer()); - } - - @Override - public Media createMediaRecorder(final String path, final String mimeType) throws IOException { - MediaRecorderBuilder builder = new MediaRecorderBuilder() - .path(path) - .mimeType(mimeType); - return createMediaRecorder(builder); - } - - - - private Media createMediaRecorder(final String path, final String mimeType, final int sampleRate, final int bitRate, final int audioChannels, final int maxDuration, final boolean redirectToAudioBuffer) throws IOException { - if (getActivity() == null) { - return null; - } - if(!checkForPermission(Manifest.permission.RECORD_AUDIO, "This is required to record audio")){ - return null; - } - final Media[] record = new Media[1]; - final IOException[] error = new IOException[1]; - - final Object lock = new Object(); - synchronized (lock) { - getActivity().runOnUiThread(new Runnable() { - @Override - public void run() { - synchronized (lock) { - if (redirectToAudioBuffer) { - final int channelConfig =audioChannels == 1 ? android.media.AudioFormat.CHANNEL_IN_MONO - : audioChannels == 2 ? android.media.AudioFormat.CHANNEL_IN_STEREO - : android.media.AudioFormat.CHANNEL_IN_MONO; - final AudioRecord recorder = new AudioRecord( - MediaRecorder.AudioSource.MIC, - sampleRate, - channelConfig, - AudioFormat.ENCODING_PCM_16BIT, - AudioRecord.getMinBufferSize(sampleRate, channelConfig, AudioFormat.ENCODING_PCM_16BIT) - ); - - final com.codename1.media.AudioBuffer audioBuffer = com.codename1.media.MediaManager.getAudioBuffer(path, true, 64); - - record[0] = new AbstractMedia() { - private int lastTime; - private boolean isRecording; - @Override - protected void playImpl() { - if (isRecording) { - return; - } - isRecording = true; - recorder.startRecording(); - fireMediaStateChange(State.Playing); - new Thread(new Runnable() { - public void run() { - float[] audioData = new float[audioBuffer.getMaxSize()]; - short[] buffer = new short[AudioRecord.getMinBufferSize(recorder.getSampleRate(), recorder.getChannelCount(), AudioFormat.ENCODING_PCM_16BIT)]; - int read = -1; - int index = 0; - - while (isRecording && (read = recorder.read(buffer, 0, buffer.length)) >= 0) { - if (read > 0) { - for (int i=0; i= audioData.length) { - audioBuffer.copyFrom(sampleRate, audioChannels, audioData, 0, index); - index = 0; - } - } - if (index > 0) { - audioBuffer.copyFrom(sampleRate, audioChannels, audioData, 0, index); - index = 0; - } - } - } - - } - - }).start(); - } - - @Override - protected void pauseImpl() { - if (!isRecording) { - return; - } - isRecording = false; - recorder.stop(); - - - fireMediaStateChange(State.Paused); - } - - @Override - public void prepare() { - - } - - @Override - public void cleanup() { - pauseImpl(); - recorder.release(); - com.codename1.media.MediaManager.releaseAudioBuffer(path); - - } - - @Override - public int getTime() { - if (isRecording) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - AudioTimestamp ts = new AudioTimestamp(); - recorder.getTimestamp(ts, AudioTimestamp.TIMEBASE_MONOTONIC); - lastTime = (int) (ts.framePosition / ((float) sampleRate / 1000f)); - } - } - return lastTime; - } - - @Override - public void setTime(int time) { - - } - - @Override - public int getDuration() { - return getTime(); - } - - @Override - public void setVolume(int vol) { - - } - - @Override - public int getVolume() { - return 0; - } - - @Override - public boolean isPlaying() { - return recorder.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING; - } - - @Override - public Component getVideoComponent() { - return null; - } - - @Override - public boolean isVideo() { - return false; - } - - @Override - public boolean isFullScreen() { - return false; - } - - @Override - public void setFullScreen(boolean fullScreen) { - - } - - @Override - public void setNativePlayerMode(boolean nativePlayer) { - - } - - @Override - public boolean isNativePlayerMode() { - return false; - } - - @Override - public void setVariable(String key, Object value) { - - } - - @Override - public Object getVariable(String key) { - return null; - } - - }; - lock.notify(); - } else { - MediaRecorder recorder = new MediaRecorder(); - recorder.setAudioSource(MediaRecorder.AudioSource.MIC); - - if(mimeType.contains("amr")){ - recorder.setOutputFormat(MediaRecorder.OutputFormat.AMR_NB); - recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB); - }else{ - recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); - recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); - recorder.setAudioSamplingRate(sampleRate); - recorder.setAudioEncodingBitRate(bitRate); - } - if (audioChannels > 0) { - recorder.setAudioChannels(audioChannels); - } - if (maxDuration > 0) { - recorder.setMaxDuration(maxDuration); - } - recorder.setOutputFile(removeFilePrefix(path)); - try { - recorder.prepare(); - record[0] = new AndroidRecorder(recorder); - } catch (IllegalStateException ex) { - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - } catch (IOException ex) { - error[0] = ex; - } finally { - lock.notify(); - } - } - - - - } - } - }); - - try { - lock.wait(); - } catch (InterruptedException ex) { - ex.printStackTrace(); - } - - if (error[0] != null) { - throw error[0]; - } - - return record[0]; - } - } - - public String [] getAvailableRecordingMimeTypes(){ - // audio/aac and audio/mp4 result in the same thing - // AAC are wrapped in an mp4 container. - return new String[]{"audio/amr", "audio/aac", "audio/mp4"}; - } - - - /** - * @inheritDoc - */ - public Object createSoftWeakRef(Object o) { - return new SoftReference(o); - } - - /** - * @inheritDoc - */ - public Object extractHardRef(Object o) { - SoftReference w = (SoftReference) o; - if (w != null) { - return w.get(); - } - return null; - } - - /** - * @inheritDoc - */ - public PeerComponent createNativePeer(Object nativeComponent) { - if (!(nativeComponent instanceof View)) { - throw new IllegalArgumentException(nativeComponent.getClass().getName()); - } - return new AndroidImplementation.AndroidPeer((View) nativeComponent); - } - - private final java.util.Map glSurfaces = - new java.util.IdentityHashMap(); - - private final com.codename1.impl.gpu.GpuImplementation gpuImpl = - new com.codename1.impl.gpu.GpuImplementation() { - @Override - public PeerComponent createPeer(final com.codename1.gpu.RenderView view) { - final CodenameOneActivity a = getActivity(); - if (a == null) { - return null; - } - // The GLSurfaceView must be constructed on the UI thread; block until - // it exists so we can wrap and return its peer to the caller. - final AndroidGLSurface[] holder = new AndroidGLSurface[1]; - final java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1); - a.runOnUiThread(new Runnable() { - public void run() { - try { - holder[0] = new AndroidGLSurface(a, view); - } catch (Throwable t) { - t.printStackTrace(); - } finally { - latch.countDown(); - } - } - }); - try { - latch.await(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - AndroidGLSurface surface = holder[0]; - if (surface == null) { - return null; - } - PeerComponent peer = createNativePeer(surface); - if (peer != null) { - glSurfaces.put(peer, surface); - } - return peer; - } - - @Override - public void setContinuous(PeerComponent peer, final boolean continuous) { - final AndroidGLSurface surface = glSurfaces.get(peer); - if (surface == null) { - return; - } - final CodenameOneActivity a = getActivity(); - if (a == null) { - return; - } - a.runOnUiThread(new Runnable() { - public void run() { - surface.setRenderMode(continuous - ? android.opengl.GLSurfaceView.RENDERMODE_CONTINUOUSLY - : android.opengl.GLSurfaceView.RENDERMODE_WHEN_DIRTY); - } - }); - } - - @Override - public void requestRender(PeerComponent peer) { - AndroidGLSurface surface = glSurfaces.get(peer); - if (surface != null) { - surface.requestRender(); - } - } - }; - - @Override - public com.codename1.impl.gpu.GpuImplementation getGpuImplementation() { - return gpuImpl; - } - - private void blockNativeFocusAll(boolean block) { - synchronized (this.nativePeers) { - final int size = this.nativePeers.size(); - for (int i = 0; i < size; i++) { - AndroidImplementation.AndroidPeer next = (AndroidImplementation.AndroidPeer) this.nativePeers.get(i); - next.blockNativeFocus(block); - } - } - } - - public void onFocusChange(View view, boolean bln) { - - if (bln) { - /** - * whenever the base view receives focus we automatically block - * possible native subviews from gaining focus. - */ - blockNativeFocusAll(true); - if (this.lastDirectionalKeyEventReceivedByWrapper != 0) { - /** - * because we also consume any key event in the OnKeyListener of - * the native wrappers, we have to simulate key events to make - * Codename One move the focus to the next component. - */ - if (myView == null) { - return; - } - if (!myView.getAndroidView().isInTouchMode()) { - switch (lastDirectionalKeyEventReceivedByWrapper) { - case AndroidImplementation.DROID_IMPL_KEY_LEFT: - case AndroidImplementation.DROID_IMPL_KEY_RIGHT: - case AndroidImplementation.DROID_IMPL_KEY_UP: - case AndroidImplementation.DROID_IMPL_KEY_DOWN: - Display.getInstance().keyPressed(lastDirectionalKeyEventReceivedByWrapper); - Display.getInstance().keyReleased(lastDirectionalKeyEventReceivedByWrapper); - break; - default: - Log.d("Codename One", "unexpected keycode: " + lastDirectionalKeyEventReceivedByWrapper); - break; - } - } else { - Log.d("Codename One", "base view gained focus but no key event to process."); - } - lastDirectionalKeyEventReceivedByWrapper = 0; - } - } - - } - - @Override - public void edtIdle(boolean enter) { - super.edtIdle(enter); - if(enter) { - // check if we have peers waiting for resize... - if(myView instanceof AndroidAsyncView) { - ((AndroidAsyncView)myView).resizeViews(); - } - } - } - - static final Map activePeers = new HashMap(); - - - /** - * wrapper component that capsules a native view object in a Codename One - * component. this involves A LOT of back and forth between the Codename One - * EDT and the Android UI thread. - * - * - * To use it you would: - * - * 1) create your native Android view(s). Make sure to work on the Android - * UI thread when constructing and modifying them. 2) create a Codename One - * peer component by calling: - * - * com.codename1.ui.PeerComponent.create(myAndroidView); - * - * 3) currently the view's size is not automatically calculated from the - * native view. so you should set the preferred size of the Codename One - * component manually. - * - * - */ - class AndroidPeer extends PeerComponent { - - private View v; - private AndroidImplementation.AndroidRelativeLayout layoutWrapper = null; - private int currentVisible = View.INVISIBLE; - private boolean lightweightMode; - - public AndroidPeer(View vv) { - super(vv); - this.v = vv; - if(!superPeerMode) { - v.measure(MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), - MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); - } - } - - @Override - protected Image generatePeerImage() { - try { - Bitmap bmp = AndroidNativeUtil.renderViewOnBitmap(v, getWidth(), getHeight()); - if(bmp == null) { - return Image.createImage(5, 5); - } - Image image = new AndroidImplementation.NativeImage(bmp); - return image; - } catch(Throwable t) { - t.printStackTrace(); - return Image.createImage(5, 5); - } - } - - protected boolean shouldRenderPeerImage() { - return !superPeerMode && (lightweightMode || !isInitialized()); - } - - protected void setLightweightMode(boolean l) { - if(superPeerMode) { - if (l != lightweightMode) { - lightweightMode = l; - if (lightweightMode) { - Image img = generatePeerImage(); - if (img != null) { - peerImage = img; - } - } - - } - return; - } - doSetVisibility(!l); - if (lightweightMode == l) { - return; - } - lightweightMode = l; - } - - @Override - public void setVisible(boolean visible) { - super.setVisible(visible); - this.doSetVisibility(visible); - } - - void doSetVisibility(final boolean visible) { - if (getActivity() == null) { - return; - } - getActivity().runOnUiThread(new Runnable() { - public void run() { - currentVisible = visible ? View.VISIBLE : View.INVISIBLE; - v.setVisibility(currentVisible); - if (visible) { - v.bringToFront(); - } - } - }); - if(visible){ - layoutPeer(); - } - } - - private void doSetVisibilityInternal(final boolean visible) { - if (getActivity() == null) { - return; - } - getActivity().runOnUiThread(new Runnable() { - public void run() { - currentVisible = visible ? View.VISIBLE : View.INVISIBLE; - v.setVisibility(currentVisible); - if (visible) { - v.bringToFront(); - } - } - }); - } - - protected void deinitialize() { - if(!superPeerMode) { - Image i = generatePeerImage(); - setPeerImage(i); - super.deinitialize(); - synchronized (nativePeers) { - nativePeers.remove(this); - } - deinit(); - }else{ - Image img = generatePeerImage(); - if (img != null) { - peerImage = img; - } - - if(myView instanceof AndroidAsyncView){ - ((AndroidAsyncView)myView).removePeerView(v); - } - super.deinitialize(); - } - } - - public void deinit(){ - if (getActivity() == null) { - return; - } - if (peerImage == null) { - peerImage = generatePeerImage(); - } - final boolean [] removed = new boolean[1]; - getActivity().runOnUiThread(new Runnable() { - public void run() { - try { - if (layoutWrapper != null && AndroidImplementation.this.relativeLayout != null) { - AndroidImplementation.this.relativeLayout.removeView(layoutWrapper); - AndroidImplementation.this.relativeLayout.requestLayout(); - layoutWrapper = null; - } - } finally { - removed[0] = true; - } - } - }); - while (!removed[0]) { - Display.getInstance().invokeAndBlock(new Runnable() { - public void run() { - if (!removed[0]) { - try { - Thread.sleep(5); - } catch(InterruptedException er) {} - } - } - }); - } - } - - protected void initComponent() { - super.initComponent(); - if(!superPeerMode) { - synchronized (nativePeers) { - nativePeers.add(this); - } - init(); - setPeerImage(null); - } - } - - public void init(){ - if(superPeerMode || getActivity() == null) { - return; - } - runOnUiThreadAndBlock(new Runnable() { - public void run() { - if (layoutWrapper == null) { - /** - * wrap the native item in a layout that we can move - * around on the surface view as we like. - */ - layoutWrapper = new AndroidImplementation.AndroidRelativeLayout(activity, AndroidImplementation.AndroidPeer.this, v); - layoutWrapper.setBackgroundDrawable(null); - v.setVisibility(currentVisible); - v.setFocusable(AndroidImplementation.AndroidPeer.this.isFocusable()); - v.setFocusableInTouchMode(true); - ArrayList viewList = new ArrayList(); - viewList.add(layoutWrapper); - v.addFocusables(viewList, View.FOCUS_DOWN); - v.addFocusables(viewList, View.FOCUS_UP); - v.addFocusables(viewList, View.FOCUS_LEFT); - v.addFocusables(viewList, View.FOCUS_RIGHT); - if (v.isFocusable() || v.isFocusableInTouchMode()) { - if (AndroidImplementation.AndroidPeer.super.hasFocus()) { - AndroidImplementation.this.blockNativeFocusAll(true); - blockNativeFocus(false); - if (!v.hasFocus()) { - v.requestFocus(); - } - - } else { - blockNativeFocus(true); - } - layoutWrapper.setOnKeyListener(new View.OnKeyListener() { - public boolean onKey(View view, int i, KeyEvent ke) { - lastDirectionalKeyEventReceivedByWrapper = CodenameOneView.internalKeyCodeTranslate(ke.getKeyCode()); - - // move focus back to base view. - if (AndroidImplementation.this.myView == null) return false; - AndroidImplementation.this.myView.getAndroidView().requestFocus(); - - /** - * if the wrapper has focus, then only because - * the wrapped native component just lost focus. - * we consume whatever key events we receive, - * just to make sure no half press/release - * sequence reaches the base view (and therefore - * Codename One). - */ - return true; - } - }); - layoutWrapper.setOnFocusChangeListener(new View.OnFocusChangeListener() { - public void onFocusChange(View view, boolean bln) { - Log.d("Codename One", "on focus change. " + view.toString() + " focus:" + bln + " touchmode: " + v.isInTouchMode()); - } - }); - layoutWrapper.setOnTouchListener(new View.OnTouchListener() { - public boolean onTouch(View v, MotionEvent me) { - if (myView == null) return false; - return myView.getAndroidView().onTouchEvent(me); - } - }); - } - if(AndroidImplementation.this.relativeLayout != null){ - // not sure why this happens but we got an exception where add view was called with - // a layout that was already added... - if(layoutWrapper.getParent() != null) { - ((ViewGroup)layoutWrapper.getParent()).removeView(layoutWrapper); - } - AndroidImplementation.this.relativeLayout.addView(layoutWrapper); - } - } - } - }); - } - private Image peerImage; - public void paint(final Graphics g) { - if(superPeerMode) { - Object nativeGraphics = com.codename1.ui.Accessor.getNativeGraphics(g); - - Object o = v.getLayoutParams(); - AndroidAsyncView.LayoutParams lp; - if(o instanceof AndroidAsyncView.LayoutParams) { - lp = (AndroidAsyncView.LayoutParams) o; - if (lp == null) { - lp = new AndroidAsyncView.LayoutParams( - getX() + g.getTranslateX(), - getY() + g.getTranslateY(), - getWidth(), - getHeight(), AndroidPeer.this); - final AndroidAsyncView.LayoutParams finalLp = lp; - activity.runOnUiThread(new Runnable() { - @Override - public void run() { - v.setLayoutParams(finalLp); - } - }); - lp.dirty = true; - } else { - int x = getX() + g.getTranslateX(); - int y = getY() + g.getTranslateY(); - int w = getWidth(); - int h = getHeight(); - if (x != lp.x || y != lp.y || w != lp.w || h != lp.h) { - lp.dirty = true; - lp.x = x; - lp.y = y; - lp.w = w; - lp.h = h; - } - } - } else { - final AndroidAsyncView.LayoutParams finalLp = new AndroidAsyncView.LayoutParams( - getX() + g.getTranslateX(), - getY() + g.getTranslateY(), - getWidth(), - getHeight(), AndroidPeer.this); - activity.runOnUiThread(new Runnable() { - @Override - public void run() { - v.setLayoutParams(finalLp); - } - }); - finalLp.dirty = true; - lp = finalLp; - } - - // this is a mutable image or side menu etc. where the peer is drawn on a different form... - // Special case... - if(nativeGraphics.getClass() == AndroidGraphics.class) { - if(peerImage == null) { - peerImage = generatePeerImage(); - } - //systemOut("Drawing native image"); - g.drawImage(peerImage, getX(), getY()); - return; - } - synchronized(activePeers) { - activePeers.put(v, this); - } - ((AndroidGraphics) nativeGraphics).drawView(v, lp); - if (lightweightMode && peerImage != null) { - g.drawImage(peerImage, getX(), getY(), getWidth(), getHeight()); - } - } else { - super.paint(g); - } - } - - boolean _initialized() { - return isInitialized(); - } - - @Override - protected void onPositionSizeChange() { - if(!superPeerMode) { - Form f = getComponentForm(); - if (v.getVisibility() == View.INVISIBLE - && f != null - && Display.getInstance().getCurrent() == f) { - doSetVisibilityInternal(true); - return; - } - layoutPeer(); - } - } - - protected void layoutPeer(){ - if (getActivity() == null) { - return; - } - if(!superPeerMode) { - // called by Codename One EDT to position the native component. - activity.runOnUiThread(new Runnable() { - public void run() { - if (layoutWrapper != null) { - if (v.getVisibility() == View.VISIBLE) { - - RelativeLayout.LayoutParams layoutParams = layoutWrapper.createMyLayoutParams( - AndroidImplementation.AndroidPeer.this.getAbsoluteX(), - AndroidImplementation.AndroidPeer.this.getAbsoluteY(), - AndroidImplementation.AndroidPeer.this.getWidth(), - AndroidImplementation.AndroidPeer.this.getHeight()); - layoutWrapper.setLayoutParams(layoutParams); - if (AndroidImplementation.this.relativeLayout != null) { - AndroidImplementation.this.relativeLayout.requestLayout(); - } - - } - } - } - }); - } - } - - void blockNativeFocus(boolean block) { - if (layoutWrapper != null) { - layoutWrapper.setDescendantFocusability(block - ? ViewGroup.FOCUS_BLOCK_DESCENDANTS : ViewGroup.FOCUS_AFTER_DESCENDANTS); - } - } - - @Override - public boolean isFocusable() { - // EDT - if (v != null) { - return v.isFocusableInTouchMode() || v.isFocusable(); - } else { - return super.isFocusable(); - } - } - - @Override - public void onSetFocusable(final boolean focusable) { - // EDT - if (getActivity() == null) { - return; - } - getActivity().runOnUiThread(new Runnable() { - public void run() { - v.setFocusable(focusable); - } - }); - } - - @Override - protected void focusGained() { - Log.d("Codename One", "native focus gain"); - // EDT - super.focusGained(); - if (getActivity() == null) { - return; - } - getActivity().runOnUiThread(new Runnable() { - public void run() { - // allow this one to gain focus - blockNativeFocus(false); - if (!v.hasFocus()) { - if (v.isInTouchMode()) { - v.requestFocusFromTouch(); - } else { - v.requestFocus(); - } - } - } - }); - } - - @Override - protected void focusLost() { - Log.d("Codename One", "native focus loss"); - // EDT - super.focusLost(); - if (layoutWrapper != null && getActivity() != null) { - getActivity().runOnUiThread(new Runnable() { - public void run() { - if(isInitialized()) { - // request focus of the wrapper. that will trigger the - // android focus listener and move focus back to the - // base view. - layoutWrapper.requestFocus(); - } - } - }); - } - } - - public void release() { - deinitialize(); - } - - @Override - protected Dimension calcPreferredSize() { - int w = 1; - int h = 1; - Drawable d = v.getBackground(); - if (d != null) { - w = d.getMinimumWidth(); - h = d.getMinimumHeight(); - } - w = Math.max(v.getMeasuredWidth(), w); - h = Math.max(v.getMeasuredHeight(), h); - if (v instanceof TextView) { - TextView tv = (TextView)v; - w = (int) android.text.Layout.getDesiredWidth(((TextView) v).getText(), ((TextView) v).getPaint()); - int heightMeasureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED); - tv.measure(w, heightMeasureSpec); - h = (int)Math.max(h, tv.getMeasuredHeight()); - - - } - return new Dimension(w, h); - } - } - - /** - * inner class that wraps the native components. this is a useful thingy to - * handle focus stuff and buffering. - */ - class AndroidRelativeLayout extends RelativeLayout { - - private AndroidImplementation.AndroidPeer peer; - - public AndroidRelativeLayout(Context activity, AndroidImplementation.AndroidPeer peer, View v) { - super(activity); - - this.peer = peer; - this.setLayoutParams(createMyLayoutParams(peer.getAbsoluteX(), peer.getAbsoluteY(), - peer.getWidth(), peer.getHeight())); - if (v.getParent() != null) { - ((ViewGroup)v.getParent()).removeView(v); - } - this.addView(v, new RelativeLayout.LayoutParams( - RelativeLayout.LayoutParams.FILL_PARENT, - RelativeLayout.LayoutParams.FILL_PARENT)); - this.setDrawingCacheEnabled(false); - this.setAlwaysDrawnWithCacheEnabled(false); - this.setFocusable(true); - this.setFocusableInTouchMode(false); - this.setDescendantFocusability(ViewGroup.FOCUS_AFTER_DESCENDANTS); - - } - - /** - * create a layout parameter object that holds the native component's - * position. - * - * @return - */ - private RelativeLayout.LayoutParams createMyLayoutParams(int x, int y, int width, int height) { - RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams( - RelativeLayout.LayoutParams.WRAP_CONTENT, - RelativeLayout.LayoutParams.WRAP_CONTENT); - layoutParams.addRule(RelativeLayout.ALIGN_PARENT_LEFT); - layoutParams.addRule(RelativeLayout.ALIGN_PARENT_TOP); - layoutParams.width = width; - layoutParams.height = height; - layoutParams.leftMargin = x; - layoutParams.topMargin = y; - return layoutParams; - } - - @Override - public boolean dispatchKeyEvent(KeyEvent event) { - - int keycode = event.getKeyCode(); - keycode = CodenameOneView.internalKeyCodeTranslate(keycode); - if (keycode == AndroidImplementation.DROID_IMPL_KEY_BACK) { - switch (event.getAction()) { - case KeyEvent.ACTION_DOWN: - // Claim the gesture so the activity's - // OnBackInvokedCallback stands down; on Android 16 the - // platform can deliver both for one press. See - // PredictiveBackBridge. - PredictiveBackBridge.keyEventBackStarted(); - Display.getInstance().keyPressed(keycode); - break; - case KeyEvent.ACTION_UP: - PredictiveBackBridge.keyEventBackFinished(); - Display.getInstance().keyReleased(keycode); - break; - } - return true; - } else { - return super.dispatchKeyEvent(event); - } - } - - - } - - private boolean testedNativeTheme; - private boolean nativeThemeAvailable; - - public boolean hasNativeTheme() { - if (!testedNativeTheme) { - testedNativeTheme = true; - try { - InputStream is; - if (android.os.Build.VERSION.SDK_INT < 14 && !isTablet()) { - is = getResourceAsStream(getClass(), "/androidTheme.res"); - } else { - is = getResourceAsStream(getClass(), "/android_holo_light.res"); - } - nativeThemeAvailable = is != null; - if (is != null) { - is.close(); - } - } catch (IOException ex) { - ex.printStackTrace(); - } - } - return nativeThemeAvailable; - } - - /** - * Installs the native theme, this is only applicable if hasNativeTheme() - * returned true. Notice that this method might replace the - * DefaultLookAndFeel instance and the default transitions. - */ - public void installNativeTheme() { - hasNativeTheme(); - if (!nativeThemeAvailable) { - return; - } - try { - // Resolve desired theme flavor. and.themeMode is the per-platform - // hint (auto | modern | material | hololight | legacy); the legacy - // name cn1.androidTheme is still honored for back-compat. The - // cross-platform shortcut nativeTheme=modern/legacy (deprecated - // alias: cn1.nativeTheme) feeds in when no platform-specific hint - // is set. Default stays on android_holo_light - what master - // shipped and what existing screenshot goldens are anchored - // against. The ancient pre-Holo androidTheme.res is only reached - // via explicit and.hololight=true (historical back-compat) or - // and.themeMode=legacy. - Display d = Display.getInstance(); - String mode = d.getProperty("and.themeMode", - d.getProperty("cn1.androidTheme", null)); - if (mode == null) { - String shared = d.getProperty("nativeTheme", - d.getProperty("cn1.nativeTheme", null)); - if ("modern".equalsIgnoreCase(shared)) { - mode = "material"; - } else if ("legacy".equalsIgnoreCase(shared)) { - mode = "hololight"; - } else if ("true".equalsIgnoreCase(d.getProperty("and.hololight", "false"))) { - mode = "legacy"; - } else { - mode = "hololight"; - } - } else { - mode = mode.toLowerCase(); - } - - String resPath; - if ("material".equals(mode) || "modern".equals(mode) || "auto".equals(mode)) { - resPath = "/AndroidMaterialTheme.res"; - } else if ("hololight".equals(mode) || "holo".equals(mode)) { - resPath = "/android_holo_light.res"; - } else { - resPath = "/androidTheme.res"; - } - - InputStream is = getResourceAsStream(getClass(), resPath); - if (is == null) { - // Modern theme may not be in the apk if the framework build - // skipped native-themes generation. Fall back to Holo Light - // (master's default) so the app still boots with a known look. - is = getResourceAsStream(getClass(), "/android_holo_light.res"); - } - Resources r = Resources.open(is); - Hashtable h = r.getTheme(r.getThemeResourceNames()[0]); - h.put("@commandBehavior", "Native"); - UIManager.getInstance().setThemeProps(h); - is.close(); - Display.getInstance().setCommandBehavior(Display.COMMAND_BEHAVIOR_NATIVE); - } catch (IOException ex) { - ex.printStackTrace(); - } - } - - public boolean isNativeBrowserComponentSupported() { - return true; - } - - @Override - public void setNativeBrowserScrollingEnabled(final PeerComponent browserPeer, final boolean e) { - super.setNativeBrowserScrollingEnabled(browserPeer, e); - if (getActivity() == null) { - return; - } - getActivity().runOnUiThread(new Runnable() { - public void run() { - AndroidBrowserComponent bc = (AndroidBrowserComponent)browserPeer; - bc.setScrollingEnabled(e); - } - }); - } - - @Override - public void setPinchToZoomEnabled(final PeerComponent browserPeer, final boolean e) { - super.setPinchToZoomEnabled(browserPeer, e); - if (getActivity() == null) { - return; - } - getActivity().runOnUiThread(new Runnable() { - public void run() { - AndroidBrowserComponent bc = (AndroidBrowserComponent)browserPeer; - bc.setPinchZoomEnabled(e); - } - }); - } - - public PeerComponent createBrowserComponent(final Object parent) { - if (getActivity() == null) { - return null; - } - final AndroidImplementation.AndroidBrowserComponent[] bc = new AndroidImplementation.AndroidBrowserComponent[1]; - final Throwable[] error = new Throwable[1]; - final Object lock = new Object(); - - getActivity().runOnUiThread(new Runnable() { - @Override - public void run() { - - synchronized (lock) { - try { - WebView wv = new WebView(getActivity()) { - - @Override - public boolean dispatchKeyEvent(KeyEvent event) { - - int keycode = event.getKeyCode(); - keycode = CodenameOneView.internalKeyCodeTranslate(keycode); - if (keycode == AndroidImplementation.DROID_IMPL_KEY_BACK || - (keycode == KeyEvent.KEYCODE_MENU && - Display.getInstance().getCommandBehavior() != Display.COMMAND_BEHAVIOR_NATIVE)) { - boolean backKey = - keycode == AndroidImplementation.DROID_IMPL_KEY_BACK; - switch (event.getAction()) { - case KeyEvent.ACTION_DOWN: - // Claim the gesture so the - // activity's OnBackInvokedCallback - // stands down; on Android 16 the - // platform can deliver both for one - // press. See PredictiveBackBridge. - if (backKey) { - PredictiveBackBridge.keyEventBackStarted(); - } - Display.getInstance().keyPressed(keycode); - break; - case KeyEvent.ACTION_UP: - if (backKey) { - PredictiveBackBridge.keyEventBackFinished(); - } - Display.getInstance().keyReleased(keycode); - break; - } - return true; - } else { - if(Display.getInstance().getProperty( - "android.propogateKeyEvents", "false"). - equalsIgnoreCase("true") && - myView instanceof AndroidAsyncView) { - switch (event.getAction()) { - case KeyEvent.ACTION_DOWN: - Display.getInstance().keyPressed(keycode); - break; - case KeyEvent.ACTION_UP: - Display.getInstance().keyReleased(keycode); - break; - } - return true; - } - - return super.dispatchKeyEvent(event); - } - } - }; - wv.setOnTouchListener(new View.OnTouchListener() { - - @Override - public boolean onTouch(View v, MotionEvent event) { - switch (event.getAction()) { - case MotionEvent.ACTION_DOWN: - case MotionEvent.ACTION_UP: - if (!v.hasFocus()) { - v.requestFocus(); - } - break; - } - return false; - } - }); - - if (android.os.Build.VERSION.SDK_INT >= 19) { - if ("true".equals(Display.getInstance().getProperty("android.webContentsDebuggingEnabled", "false"))) { - wv.setWebContentsDebuggingEnabled(true); - } - } - wv.getSettings().setDomStorageEnabled(true); - wv.getSettings().setAllowFileAccess(true); - wv.getSettings().setAllowContentAccess(true); - wv.requestFocus(View.FOCUS_DOWN); - wv.setFocusableInTouchMode(true); - if (android.os.Build.VERSION.SDK_INT >= 17) { - wv.getSettings().setMediaPlaybackRequiresUserGesture(false); - } - bc[0] = new AndroidImplementation.AndroidBrowserComponent(wv, getActivity(), parent); - lock.notify(); - } catch (Throwable t) { - error[0] = t; - lock.notify(); - } - } - } - }); - while (bc[0] == null && error[0] == null) { - Display.getInstance().invokeAndBlock(new Runnable() { - public void run() { - synchronized (lock) { - if (bc[0] == null && error[0] == null) { - try { - lock.wait(20); - } catch (InterruptedException ex) { - ex.printStackTrace(); - } - } - } - } - - }); - } - if (error[0] != null) { - throw new RuntimeException(error[0]); - } - return bc[0]; - } - - public void setBrowserProperty(PeerComponent browserPeer, String key, Object value) { - ((AndroidImplementation.AndroidBrowserComponent) browserPeer).setProperty(key, value); - } - - public String getBrowserTitle(PeerComponent browserPeer) { - return ((AndroidImplementation.AndroidBrowserComponent) browserPeer).getTitle(); - } - - public String getBrowserURL(PeerComponent browserPeer) { - return ((AndroidImplementation.AndroidBrowserComponent) browserPeer).getURL(); - } - - @Override - public void setBrowserURL(PeerComponent browserPeer, String url, Map headers) { - if (url.startsWith("jar:")) { - url = url.substring(6); - if(url.indexOf("/") != 0) { - url = "/"+url; - } - - url = "file:///android_asset"+url; - } - AndroidImplementation.AndroidBrowserComponent bc = (AndroidImplementation.AndroidBrowserComponent) browserPeer; - if(bc.parent.fireBrowserNavigationCallbacks(url)) { - bc.setURL(url, headers); - } - } - - @Override - public boolean isURLWithCustomHeadersSupported() { - return true; - } - - @Override - public void setBrowserURL(PeerComponent browserPeer, String url) { - setBrowserURL(browserPeer, url, null); - } - - public void browserStop(PeerComponent browserPeer) { - ((AndroidImplementation.AndroidBrowserComponent) browserPeer).stop(); - } - - public void browserDestroy(PeerComponent browserPeer) { - ((AndroidImplementation.AndroidBrowserComponent) browserPeer).destroy(); - } - - /** - * Reload the current page - * - * @param browserPeer browser instance - */ - public void browserReload(PeerComponent browserPeer) { - ((AndroidImplementation.AndroidBrowserComponent) browserPeer).reload(); - } - - /** - * Indicates whether back is currently available - * - * @param browserPeer browser instance - * @return true if back should work - */ - public boolean browserHasBack(PeerComponent browserPeer) { - return ((AndroidImplementation.AndroidBrowserComponent) browserPeer).hasBack(); - } - - public boolean browserHasForward(PeerComponent browserPeer) { - return ((AndroidImplementation.AndroidBrowserComponent) browserPeer).hasForward(); - } - - public void browserBack(PeerComponent browserPeer) { - ((AndroidImplementation.AndroidBrowserComponent) browserPeer).back(); - } - - public void browserForward(PeerComponent browserPeer) { - ((AndroidImplementation.AndroidBrowserComponent) browserPeer).forward(); - } - - public void browserClearHistory(PeerComponent browserPeer) { - ((AndroidImplementation.AndroidBrowserComponent) browserPeer).clearHistory(); - } - - public void setBrowserPage(PeerComponent browserPeer, String html, String baseUrl) { - ((AndroidImplementation.AndroidBrowserComponent) browserPeer).setPage(html, baseUrl); - } - - public void browserExposeInJavaScript(PeerComponent browserPeer, Object o, String name) { - ((AndroidImplementation.AndroidBrowserComponent) browserPeer).exposeInJavaScript(o, name); - } - - private boolean useEvaluateJavascript() { - return android.os.Build.VERSION.SDK_INT >= 19; - } - - - private int jsCallbackIndex=0; - - private void execJSUnsafe(WebView web, String js) { - if (useEvaluateJavascript()) { - web.evaluateJavascript(js, null); - } else { - web.loadUrl("javascript:(function(){"+js+"})()"); - } - } - - private void execJSSafe(final WebView web, final String js) { - if (useJSDispatchThread()) { - runOnJSDispatchThread(new Runnable() { - public void run() { - getActivity().runOnUiThread(new Runnable() { - public void run() { - execJSUnsafe(web, js); - } - }); - } - }); - } else { - getActivity().runOnUiThread(new Runnable() { - public void run() { - execJSUnsafe(web, js); - } - }); - } - } - - private void execJSUnsafe(final AndroidBrowserComponent bc, final String javaScript, final ValueCallback resultCallback) { - if (useEvaluateJavascript()) { - try { - bc.web.evaluateJavascript(javaScript, resultCallback); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - resultCallback.onReceiveValue(null); - } - } else { - jsCallbackIndex = (++jsCallbackIndex) % 1024; - int index = jsCallbackIndex; - - // The jsCallback is a special java object exposed to javascript that we use - // to return values from javascript to java. - synchronized (bc.jsCallback){ - // Initialize the return value to null - while (!bc.jsCallback.isIndexAvailable(index)) { - index++; - } - jsCallbackIndex = index+1; - } - final int fIndex = index; - // We are placing the javascript inside eval() so we need to escape - // the input. - String escaped = StringUtil.replaceAll(javaScript, "\\", "\\\\"); - escaped = StringUtil.replaceAll(escaped, "'", "\\'"); - - final String js = "javascript:(function(){" - - + "try{" - +bc.jsCallback.jsInit() - +bc.jsCallback.jsCleanup() - + AndroidBrowserComponentCallback.JS_RETURNVAL_VARNAME+"["+index+"]" - + "=eval('"+escaped +"');} catch (e){console.log(e)};" - + AndroidBrowserComponentCallback.JS_VAR_NAME+".addReturnValue(" + index+", ''+" - - + AndroidBrowserComponentCallback.JS_RETURNVAL_VARNAME+"["+index+"]" - + ");})()"; - - // Send the Javascript string via SetURL. - // NOTE!! This is sent asynchronously so we will need to wait for - // the result to come in. - bc.setURL(js, null); - if (resultCallback == null) { - return; - } - Thread t = new Thread(new Runnable() { - public void run() { - int maxTries = 500; - int tryCounter = 0; - - // If we are not on the EDT, then it is safe to just loop and wait. - while (!bc.jsCallback.isValueSet(fIndex) && tryCounter++ < maxTries) { - synchronized(bc.jsCallback){ - Util.wait(bc.jsCallback, 20); - } - } - - if (bc.jsCallback.isValueSet(fIndex)) { - String retval = bc.jsCallback.getReturnValue(fIndex); - bc.jsCallback.remove(fIndex); - resultCallback.onReceiveValue(retval != null ? JSONObject.quote(retval) : null); - - } else { - com.codename1.io.Log.e(new RuntimeException("Failed to execute javascript "+js+" after maximum wait time.")); - resultCallback.onReceiveValue(null); - } - } - }); - t.start(); - - } - } - - private void execJSSafe(final AndroidBrowserComponent bc, final String javaScript, final ValueCallback resultCallback) { - if (useJSDispatchThread()) { - runOnJSDispatchThread(new Runnable() { - public void run() { - getActivity().runOnUiThread(new Runnable() { - public void run() { - execJSUnsafe(bc, javaScript, resultCallback); - } - }); - } - }); - } else { - getActivity().runOnUiThread(new Runnable() { - public void run() { - execJSUnsafe(bc, javaScript, resultCallback); - } - }); - } - } - - - - @Override - public void browserExecute(final PeerComponent browserPeer, final String javaScript) { - final AndroidImplementation.AndroidBrowserComponent bc = (AndroidImplementation.AndroidBrowserComponent) browserPeer; - execJSSafe(bc.web, javaScript); - } - - private com.codename1.util.EasyThread jsDispatchThread; - private com.codename1.util.EasyThread jsDispatchThread() { - if (jsDispatchThread == null) { - jsDispatchThread = com.codename1.util.EasyThread.start("JS Dispatch Thread"); - } - return jsDispatchThread; - } - - private boolean useJSDispatchThread() { - - // Before version 24, we need a separate JS dispatch thread to prevent deadlocks - return true;//Build.VERSION.SDK_INT < 24; - } - - public boolean isJSDispatchThread() { - if (useJSDispatchThread()) { - return jsDispatchThread().isThisIt(); - } else { - return (Looper.getMainLooper().getThread() == Thread.currentThread()); - } - } - - public boolean runOnJSDispatchThread(Runnable r) { - if (isJSDispatchThread()) { - r.run(); - return true; - } - if (useJSDispatchThread()) { - jsDispatchThread().run(r); - } else { - getActivity().runOnUiThread(r); - } - return false; - } - - /** - * Executes javascript and returns a string result where appropriate. - * @param browserPeer - * @param javaScript - * @return - */ - @Override - public String browserExecuteAndReturnString(final PeerComponent browserPeer, final String javaScript) { - final AndroidImplementation.AndroidBrowserComponent bc = (AndroidImplementation.AndroidBrowserComponent) browserPeer; - final String[] result = new String[1]; - final boolean[] complete = new boolean[1]; - - execJSSafe(bc, javaScript, new ValueCallback() { - @Override - public void onReceiveValue(String value) { - synchronized(result) { - complete[0] = true; - result[0] = value; - result.notify(); - } - } - }); - synchronized(result) { - if (!complete[0]) { - Util.wait(result, 10000); - } - } - if (result[0] == null) { - return null; - } else { - org.json.JSONTokener tok = new org.json.JSONTokener("{\"result\":"+result[0]+"}"); - try { - JSONObject jso = new JSONObject(tok); - return jso.getString("result"); - } catch (Throwable ex) { - com.codename1.io.Log.e(ex); - return null; - } - - } - - - } - - public boolean supportsBrowserExecuteAndReturnString(PeerComponent browserPeer) { - return true; - } - - public boolean canForceOrientation() { - return true; - } - - public void lockOrientation(boolean portrait) { - if (getActivity() == null) { - return; - } - if(portrait){ - getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); - }else{ - getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); - } - } - - public void unlockOrientation() { - if (getActivity() == null) { - return; - } - getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR); - } - - - - public boolean isAffineSupported() { - return true; - } - - public void resetAffine(Object nativeGraphics) { - ((AndroidGraphics) nativeGraphics).resetAffine(); - } - - public void scale(Object nativeGraphics, float x, float y) { - ((AndroidGraphics) nativeGraphics).scale(x, y); - } - - public void rotate(Object nativeGraphics, float angle) { - ((AndroidGraphics) nativeGraphics).rotate(angle); - } - - public void rotate(Object nativeGraphics, float angle, int x, int y) { - ((AndroidGraphics) nativeGraphics).rotate(angle, x, y); - } - - @Override - public void pushClip(Object graphics) { - ((AndroidGraphics) graphics).pushClip(); - } - - @Override - public void popClip(Object graphics) { - ((AndroidGraphics) graphics).popClip(); - } - - @Override - public boolean isTranslateMatrixSupported() { - return true; - } - - @Override - public void translateMatrix(Object nativeGraphics, float x, float y) { - ((AndroidGraphics) nativeGraphics).translateMatrix(x, y); - } - - public void shear(Object nativeGraphics, float x, float y) { - } - - public boolean isTablet() { - return (getContext().getResources().getConfiguration().screenLayout - & Configuration.SCREENLAYOUT_SIZE_MASK) - >= Configuration.SCREENLAYOUT_SIZE_LARGE; - } - - // Foldable / device posture, backed by androidx.window via reflection. The androidx.window - // dependency is only present when the app opts in with the android.foldableSupport build hint; - // when absent these all degrade safely to "not foldable". The tracker is started lazily so it - // only spins up for apps that query the posture APIs. - @Override - public boolean isFoldable() { - AndroidFoldablePosture.start(getActivity()); - return AndroidFoldablePosture.isFoldable(); - } - - @Override - public int getDevicePosture() { - AndroidFoldablePosture.start(getActivity()); - return AndroidFoldablePosture.getPosture(); - } - - @Override - public int getFoldOrientation() { - AndroidFoldablePosture.start(getActivity()); - return AndroidFoldablePosture.getFoldOrientation(); - } - - @Override - public boolean isPostureSeparating() { - AndroidFoldablePosture.start(getActivity()); - return AndroidFoldablePosture.isSeparating(); - } - - @Override - public com.codename1.ui.geom.Rectangle getFoldBounds(com.codename1.ui.geom.Rectangle rect) { - AndroidFoldablePosture.start(getActivity()); - return AndroidFoldablePosture.getFoldBounds(rect); - } - - private Boolean watchCache; - - @Override - public boolean isWatch() { - if(watchCache == null) { - // PackageManager.FEATURE_WATCH ("android.hardware.type.watch") is - // the canonical Wear OS marker; use the string literal so this - // compiles regardless of the configured minimum SDK level. - watchCache = getContext().getPackageManager() - .hasSystemFeature("android.hardware.type.watch"); - } - return watchCache; - } - - private Boolean tvCache; - - @Override - public boolean isTV() { - if(tvCache == null) { - // PackageManager.FEATURE_TELEVISION ("android.hardware.type.television") - // and FEATURE_LEANBACK ("android.software.leanback") are the canonical - // Android TV / Google TV markers; use the string literals so this - // compiles regardless of the configured minimum SDK level. - android.content.pm.PackageManager pm = getContext().getPackageManager(); - boolean tv = pm.hasSystemFeature("android.hardware.type.television") - || pm.hasSystemFeature("android.software.leanback"); - if(!tv) { - // Fall back to the runtime UI mode (covers emulators/devices that - // expose the TV ui-mode without declaring the hardware feature). - android.app.UiModeManager um = (android.app.UiModeManager) - getContext().getSystemService(Context.UI_MODE_SERVICE); - tv = um != null && um.getCurrentModeType() - == Configuration.UI_MODE_TYPE_TELEVISION; - } - tvCache = tv; - } - return tvCache; - } - - @Override - public com.codename1.car.spi.CarBridge getCarBridge() { - // The Android Auto glue (injected by the builder only when the app references - // com.codename1.car) registers its bridge here; null otherwise so the API no-ops. - return AndroidCarSupport.getBridge(); - } - - @Override - public boolean isCarConnected() { - com.codename1.car.spi.CarBridge b = AndroidCarSupport.getBridge(); - return b != null && b.isConnected(); - } - - @Override - public com.codename1.wearable.spi.WearableBridge getWearableBridge() { - // The Wearable Data Layer glue is injected by the builder only when the app references - // com.codename1.wearable; without it this is null and the API no-ops. - Context ctx = getContext(); - return ctx == null ? null : AndroidWearableSupport.getBridge(ctx); - } - - private com.codename1.surfaces.spi.SurfaceBridge surfaceBridge; - - @Override - public com.codename1.surfaces.spi.SurfaceBridge getSurfaceBridge() { - if (surfaceBridge == null) { - surfaceBridge = new com.codename1.impl.android.surfaces.AndroidSurfaceBridge(); - } - return surfaceBridge; - } - - private com.codename1.documents.spi.DocumentProviderBridge documentProviderBridge; - - @Override - public com.codename1.documents.spi.DocumentProviderBridge getDocumentProviderBridge() { - if (documentProviderBridge == null) { - documentProviderBridge = - new com.codename1.impl.android.documents.AndroidDocumentProviderBridge(); - } - return documentProviderBridge; - } - - private com.codename1.continuity.spi.ContinuityBridge continuityBridge; - - /// Returns the continuity bridge, which on Android exists for one job: - /// flushing the state checkpoint when the platform says the process may - /// be killed. Neither cross-device capability exists here and both report - /// themselves unsupported. - /// - /// Synchronized for the reason the intent bridge is: two callers arriving - /// together would each construct one, and each construction registers a - /// lifecycle listener -- so the loser's listener would stay registered and - /// the app would checkpoint twice on every save. - @Override - public synchronized com.codename1.continuity.spi.ContinuityBridge getContinuityBridge() { - if (continuityBridge == null) { - continuityBridge = - new com.codename1.impl.android.continuity.AndroidContinuityBridge(); - } - return continuityBridge; - } - - private com.codename1.intents.spi.IntentBridge intentBridge; - - @Override - // Synchronized for the same reason as the JavaSE bridge: two callers arriving together - // each see a null field and each construct one, and whichever loses the assignment keeps - // the donation or the indexed entities that were recorded through it. Nothing throws. - public synchronized com.codename1.intents.spi.IntentBridge getIntentBridge() { - if (intentBridge == null) { - intentBridge = new com.codename1.impl.android.intents.AndroidIntentBridge(); - } - return intentBridge; - } - - private AndroidHomeBridge homeBridge; - - /// Returns the smart-home bridge. Always returned rather than - /// conditionally null: the bridge answers honestly through - /// {@link AndroidSmartHomeSupport}, which is empty unless the builder - /// injected a delegate, so {@code SmartHome} reports NOT_SUPPORTED - /// without this getter needing to know how the app was built. - /// - /// Note that a delegate being present does not mean the graph is - /// readable. The ordinary Android answer is - /// {@code HomeAvailability.COMMISSIONING_ONLY}: Play services can add a - /// Matter accessory with no setup at all, while reading or controlling - /// one needs the Google Home APIs and a Google Cloud project only the - /// app's developer can create. - @Override - public com.codename1.home.spi.HomeBridge getHomeBridge() { - if (homeBridge == null) { - homeBridge = new AndroidHomeBridge(); - } - return homeBridge; - } - - /// Invoked once the app has started (from the generated stub, next to - /// `deliverPendingSharedContent`) to flush surface actions that arrived through the - /// `CN1SurfaceActionActivity` trampoline before the app instance existed. - public static void deliverPendingSurfaceActions() { - com.codename1.impl.android.surfaces.AndroidSurfaceBridge.deliverPendingActions(); - } - - /// Invoked once the app has started (from the generated stub, beside - /// `deliverPendingSurfaceActions`) to run intent requests the trampoline parked rather than - /// dispatched. - /// - /// A non-headless handler is allowed to touch a `Form`, so the launcher tap can only ask for - /// the app to be brought forward; running the handler has to wait until it is. - public static void deliverPendingIntentRequests() { - // Order matters. The generated bootstrap installs the dispatcher before startContext - // has produced a bridge, so publication is deferred -- and until it happens the bridge - // never sees registerIntents, which is what judges a request the trampoline parked at a - // cold start. Draining the foreground queue alone left such a shortcut opening the app - // and running nothing. - com.codename1.intents.Intents.publishPendingDeclarations(); - com.codename1.impl.android.intents.AndroidIntentBridge.deliverPendingForegroundRequests(); - } - - /** - * Executes r on the UI thread and blocks the EDT to completion - * @param r runnable to execute - */ - public static void runOnUiThreadAndBlock(final Runnable r) { - if (getActivity() == null) { - throw new RuntimeException("Cannot run on UI thread because getActivity() is null. This generally means we are running inside a service in the background so UI access is disabled."); - } - - final boolean[] completed = new boolean[1]; - getActivity().runOnUiThread(new Runnable() { - @Override - public void run() { - try { - r.run(); - } catch(Throwable t) { - com.codename1.io.Log.e(t); - } - synchronized(completed) { - completed[0] = true; - completed.notify(); - } - } - }); - Display.getInstance().invokeAndBlock(new Runnable() { - @Override - public void run() { - synchronized(completed) { - while(!completed[0]) { - try { - completed.wait(); - } catch(InterruptedException err) {} - } - } - } - }); - } - - public static void runOnUiThreadSync(final Runnable r) { - if (getActivity() == null) { - throw new RuntimeException("Cannot run on UI thread because getActivity() is null. This generally means we are running inside a service in the background so UI access is disabled."); - } - - final boolean[] completed = new boolean[1]; - getActivity().runOnUiThread(new Runnable() { - @Override - public void run() { - try { - r.run(); - } catch(Throwable t) { - com.codename1.io.Log.e(t); - } - synchronized(completed) { - completed[0] = true; - completed.notify(); - } - } - }); - synchronized(completed) { - while(!completed[0]) { - try { - completed.wait(); - } catch(InterruptedException err) {} - } - } - } - - - public int convertToPixels(int dipCount, boolean horizontal) { - DisplayMetrics dm = getContext().getResources().getDisplayMetrics(); - float ppi = dm.density * 160f; - return (int) (((float) dipCount) / 25.4f * ppi); - } - - public boolean isPortrait() { - int orientation = getContext().getResources().getConfiguration().orientation; - if (orientation == Configuration.ORIENTATION_UNDEFINED - || orientation == Configuration.ORIENTATION_SQUARE) { - return super.isPortrait(); - } - return orientation == Configuration.ORIENTATION_PORTRAIT; - } - - /** - * Checks if this platform supports sharing cookies between Native components (e.g. BrowserComponent) - * and ConnectionRequests. Currently only Android and iOS ports support this. - * @return - */ - @Override - public boolean isNativeCookieSharingSupported() { - return true; - } - - @Override - public void clearNativeCookies() { - CookieManager mgr = getCookieManager(); - mgr.removeAllCookie(); - } - private static CookieManager cookieManager; - private static synchronized CookieManager getCookieManager() { - if (android.os.Build.VERSION.SDK_INT > 28) { - return CookieManager.getInstance(); - } - if (cookieManager == null) { - CookieSyncManager.createInstance(getContext()); // Fixes a crash on Android 4.3 - // https://stackoverflow.com/a/20552998/2935174 - cookieManager = CookieManager.getInstance(); - } - return CookieManager.getInstance(); - } - - @Override - public Vector getCookiesForURL(String url) { - if (isUseNativeCookieStore()) { - try { - URI uri = new URI(url); - - - CookieManager mgr = getCookieManager(); - mgr.removeExpiredCookie(); - String domain = uri.getHost(); - String cookieStr = mgr.getCookie(url); - if (cookieStr != null) { - String[] cookies = cookieStr.split(";"); - int len = cookies.length; - Vector out = new Vector(); - for (int i = 0; i < len; i++) { - Cookie c = new Cookie(); - String[] parts = cookies[i].split("="); - c.setName(parts[0].trim()); - if (parts.length > 1) { - c.setValue(parts[1].trim()); - } else { - c.setValue(""); - } - c.setDomain(domain); - out.add(c); - } - return out; - } - } catch (Exception ex) { - com.codename1.io.Log.e(ex); - } - return new Vector(); - } - return super.getCookiesForURL(url); - } - - public class WebAppInterface { - BrowserComponent bc; - /** Instantiate the interface and set the context */ - WebAppInterface(BrowserComponent bc) { - this.bc = bc; - } - - @JavascriptInterface // must be added for API 17 or higher - public boolean shouldNavigate(String url) { - return bc.fireBrowserNavigationCallbacks(url); - } - } - - class AndroidBrowserComponent extends AndroidImplementation.AndroidPeer { - - private Activity act; - private WebView web; - private BrowserComponent parent; - private boolean scrollingEnabled = true; - protected AndroidBrowserComponentCallback jsCallback; - private boolean lightweightMode = false; - private ProgressDialog progressBar; - private boolean hideProgress; - private int layerType; - - - public AndroidBrowserComponent(final WebView web, Activity act, Object p) { - super(web); - if(!superPeerMode) { - doSetVisibility(false); - } - parent = (BrowserComponent) p; - this.web = web; - layerType = web.getLayerType(); - web.getSettings().setJavaScriptEnabled(true); - web.getSettings().setSupportZoom(parent.isPinchToZoomEnabled()); - this.act = act; - jsCallback = new AndroidBrowserComponentCallback(); - hideProgress = Display.getInstance().getProperty("WebLoadingHidden", "false").equals("true"); - - web.addJavascriptInterface(jsCallback, AndroidBrowserComponentCallback.JS_VAR_NAME); - web.addJavascriptInterface(new WebAppInterface(parent), "cn1application"); - if (android.os.Build.VERSION.SDK_INT >= 21) { - CookieManager.getInstance().setAcceptThirdPartyCookies(web, true); - } - - web.setWebViewClient(new WebViewClient() { - - - - public void onLoadResource(WebView view, String url) { - if (Display.getInstance().getProperty("syncNativeCookies", "false").equals("true")) { - try { - URI uri = new URI(url); - CookieManager mgr = getCookieManager(); - mgr.removeExpiredCookie(); - String domain = uri.getHost(); - removeCookiesForDomain(domain); - String cookieStr = mgr.getCookie(url); - if (cookieStr != null) { - String[] cookies = cookieStr.split(";"); - int len = cookies.length; - ArrayList out = new ArrayList(); - for (int i = 0; i < len; i++) { - Cookie c = new Cookie(); - String[] parts = cookies[i].split("="); - c.setName(parts[0].trim()); - if (parts.length > 1) { - c.setValue(parts[1].trim()); - } else { - c.setValue(""); - } - c.setDomain(domain); - out.add(c); - } - Cookie[] cookiesArr = new Cookie[out.size()]; - out.toArray(cookiesArr); - AndroidImplementation.this.addCookie(cookiesArr, false); - } - - } catch (URISyntaxException ex) { - - } - } - parent.fireWebEvent("onLoadResource", new ActionEvent(url)); - super.onLoadResource(view, url); - setShouldCalcPreferredSize(true); - } - - @Override - public void onPageStarted(WebView view, String url, Bitmap favicon) { - if (getActivity() == null) { - return; - } - - parent.fireWebEvent("onStart", new ActionEvent(url)); - super.onPageStarted(view, url, favicon); - dismissProgress(); - //show the progress only if there is no ActionBar - if(!hideProgress && !isNativeTitle()){ - progressBar = ProgressDialog.show(getActivity(), null, "Loading..."); - //if the page hasn't finished for more the 10 sec, dismiss - //the dialog - Timer t= new Timer(); - t.schedule(new TimerTask() { - @Override - public void run() { - dismissProgress(); - } - }, 10000); - } - } - - public void onPageFinished(WebView view, String url) { - parent.fireWebEvent("onLoad", new ActionEvent(url)); - super.onPageFinished(view, url); - setShouldCalcPreferredSize(true); - dismissProgress(); - } - - private void dismissProgress() { - if (progressBar != null && progressBar.isShowing()) { - progressBar.dismiss(); - Display.getInstance().callSerially(new Runnable() { - - public void run() { - setVisible(true); - repaint(); - } - }); - } - } - - public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) { - parent.fireWebEvent("onError", new ActionEvent(description, errorCode)); - super.onReceivedError(view, errorCode, description, failingUrl); - super.shouldOverrideKeyEvent(view, null); - dismissProgress(); - } - - public boolean shouldOverrideKeyEvent(WebView view, KeyEvent event) { - int keyCode = event.getKeyCode(); - if (keyCode == KeyEvent.KEYCODE_BACK || keyCode == KeyEvent.KEYCODE_MENU) { - return true; - } - - return super.shouldOverrideKeyEvent(view, event); - } - - public boolean shouldOverrideUrlLoading(WebView view, String url) { - if (url.startsWith("jar:")) { - setURL(url, null); - return true; - } - - // this will fail if dial permission isn't declared - if(url.startsWith("tel:")) { - if(parent.fireBrowserNavigationCallbacks(url)) { - try { - Intent dialer = new Intent(android.content.Intent.ACTION_DIAL, Uri.parse(url)); - getContext().startActivity(dialer); - } catch(Throwable t) {} - } - return true; - } - // this will fail if dial permission isn't declared - if(url.startsWith("mailto:")) { - if(parent.fireBrowserNavigationCallbacks(url)) { - try { - Intent emailIntent = new Intent(Intent.ACTION_SENDTO, Uri.parse(url)); - getContext().startActivity(emailIntent); - } catch(Throwable t) {} - } - return true; - } - return !parent.fireBrowserNavigationCallbacks(url); - } - - - }); - - web.setWebChromeClient(new WebChromeClient(){ - // For 3.0+ Devices (Start) - // onActivityResult attached before constructor - protected void openFileChooser(ValueCallback uploadMsg, String acceptType) - { - mUploadMessage = uploadMsg; - Intent i = new Intent(Intent.ACTION_GET_CONTENT); - i.addCategory(Intent.CATEGORY_OPENABLE); - i.setType(acceptType); - AndroidNativeUtil.getActivity().startActivityForResult(Intent.createChooser(i, "File Browser"), FILECHOOSER_RESULTCODE); - } - - - // For Lollipop 5.0+ Devices - public boolean onShowFileChooser(WebView mWebView, ValueCallback filePathCallback, WebChromeClient.FileChooserParams fileChooserParams) - { - if (uploadMessage != null) { - uploadMessage.onReceiveValue(null); - uploadMessage = null; - } - - uploadMessage = filePathCallback; - - Intent intent = fileChooserParams.createIntent(); - try - { - AndroidNativeUtil.getActivity().startActivityForResult(intent, REQUEST_SELECT_FILE); - } catch (ActivityNotFoundException e) - { - uploadMessage = null; - Toast.makeText(getActivity().getApplicationContext(), "Cannot Open File Chooser", Toast.LENGTH_LONG).show(); - return false; - } - return true; - } - - //For Android 4.1 only - protected void openFileChooser(ValueCallback uploadMsg, String acceptType, String capture) - { - mUploadMessage = uploadMsg; - Intent intent = new Intent(Intent.ACTION_GET_CONTENT); - intent.addCategory(Intent.CATEGORY_OPENABLE); - intent.setType(acceptType); - - AndroidNativeUtil.getActivity().startActivityForResult(Intent.createChooser(intent, "File Browser"), FILECHOOSER_RESULTCODE); - } - - protected void openFileChooser(ValueCallback uploadMsg) - { - mUploadMessage = uploadMsg; - Intent i = new Intent(Intent.ACTION_GET_CONTENT); - i.addCategory(Intent.CATEGORY_OPENABLE); - i.setType("image/*"); - AndroidNativeUtil.getActivity().startActivityForResult(Intent.createChooser(i, "File Chooser"), FILECHOOSER_RESULTCODE); - } - - - @Override - public boolean onConsoleMessage(ConsoleMessage consoleMessage) { - com.codename1.io.Log.p("["+consoleMessage.messageLevel()+"] "+consoleMessage.message()+" On line "+consoleMessage.lineNumber()+" of "+consoleMessage.sourceId()); - return true; - } - - @Override - public void onProgressChanged(WebView view, int newProgress) { - parent.fireWebEvent("Progress", new ActionEvent(parent, ActionEvent.Type.Progress, newProgress)); - if(!hideProgress && isNativeTitle() && getCurrentForm() != null && getCurrentForm().getTitle() != null && getCurrentForm().getTitle().length() > 0 ){ - if(getActivity() != null){ - try{ - getActivity().setProgressBarVisibility(true); - getActivity().setProgress(newProgress * 100); - if(newProgress == 100){ - getActivity().setProgressBarVisibility(false); - } - }catch(Throwable t){ - } - } - } - } - - @Override - public void onGeolocationPermissionsShowPrompt(String origin, - GeolocationPermissions.Callback callback) { - // Always grant permission since the app itself requires location - // permission and the user has therefore already granted it - callback.invoke(origin, true, false); - } - - @Override - public void onPermissionRequest(final PermissionRequest request) { - - Log.d("Codename One", "onPermissionRequest"); - getActivity().runOnUiThread(new Runnable() { - @TargetApi(Build.VERSION_CODES.LOLLIPOP) - @Override - public void run() { - String allowedOrigins = Display.getInstance().getProperty("android.WebView.grantPermissionsFrom", null); - if (allowedOrigins != null) { - String[] origins = Util.split(allowedOrigins, " "); - boolean allowed = false; - for (String origin : origins) { - if (request.getOrigin().toString().equals(origin)) { - allowed = true; - break; - } - } - if (allowed) { - Log.d("Codename One", "Allowing permission for "+Arrays.toString(request.getResources())+" in web view for origin "+request.getOrigin()); - request.grant(request.getResources()); - } else { - Log.d("Codename One", "Denying permission for "+Arrays.toString(request.getResources())+" in web view for origin "+request.getOrigin()); - request.deny(); - } - } - - } - }); - } - }); - } - - @Override - protected void initComponent() { - if(android.os.Build.VERSION.SDK_INT == 21 && web.getLayerType() != layerType){ - act.runOnUiThread(new Runnable() { - @Override - public void run() { - web.setLayerType(layerType, null); //setting layer type to original state - } - }); - } - super.initComponent(); - blockNativeFocus(false); - setPeerImage(null); - } - - - @Override - protected Image generatePeerImage() { - try { - final Bitmap nativeBuffer = Bitmap.createBitmap( - getWidth(), getHeight(), Bitmap.Config.ARGB_8888); - Image image = new AndroidImplementation.NativeImage(nativeBuffer); - getActivity().runOnUiThread(new Runnable() { - @Override - public void run() { - try { - Canvas canvas = new Canvas(nativeBuffer); - web.draw(canvas); - } catch(Throwable t) { - t.printStackTrace(); - } - } - }); - return image; - } catch(Throwable t) { - t.printStackTrace(); - return Image.createImage(5, 5); - } - } - - protected boolean shouldRenderPeerImage() { - return lightweightMode || !isInitialized(); - } - - protected void setLightweightMode(boolean l) { - doSetVisibility(!l); - if (lightweightMode == l) { - return; - } - lightweightMode = l; - } - - - - public void setScrollingEnabled(final boolean enabled){ - this.scrollingEnabled = enabled; - act.runOnUiThread(new Runnable() { - public void run() { - web.setHorizontalScrollBarEnabled(enabled); - web.setVerticalScrollBarEnabled(enabled); - if ( !enabled ){ - web.setOnTouchListener(new View.OnTouchListener(){ - - @Override - public boolean onTouch(View view, MotionEvent me) { - return (me.getAction() == MotionEvent.ACTION_MOVE); - } - - }); - } else { - web.setOnTouchListener(null); - } - } - }); - - } - - public boolean isScrollingEnabled(){ - return scrollingEnabled; - } - - public void setProperty(final String key, final Object value) { - act.runOnUiThread(new Runnable() { - public void run() { - WebSettings s = web.getSettings(); - if(key.equalsIgnoreCase("useragent")) { - s.setUserAgentString((String)value); - return; - } - try { - s.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW); - } catch(Throwable t) { - // the method isn't available in Android 4.x - } - String methodName = "set" + key; - for (Method m : s.getClass().getMethods()) { - if (m.getName().equalsIgnoreCase(methodName) && m.getParameterTypes().length == 1) { - try { - m.invoke(s, value); - } catch (Exception ex) { - ex.printStackTrace(); - } - return; - } - } - } - }); - } - - public String getTitle() { - final String[] retVal = new String[1]; - final boolean[] complete = new boolean[1]; - act.runOnUiThread(new Runnable() { - public void run() { - try { - - retVal[0] = web.getTitle(); - } finally { - complete[0] = true; - } - } - }); - while (!complete[0]) { - Display.getInstance().invokeAndBlock(new Runnable() { - @Override - public void run() { - if (!complete[0]) { - try { - Thread.sleep(20); - } catch (InterruptedException ex) { - } - } - } - }); - } - return retVal[0]; - } - - public String getURL() { - final String[] retVal = new String[1]; - final boolean[] complete = new boolean[1]; - act.runOnUiThread(new Runnable() { - public void run() { - try { - retVal[0] = web.getUrl(); - } finally { - complete[0] = true; - } - } - }); - while (!complete[0]) { - Display.getInstance().invokeAndBlock(new Runnable() { - @Override - public void run() { - if (!complete[0]) { - try { - Thread.sleep(20); - } catch (InterruptedException ex) { - } - } - } - }); - } - return retVal[0]; - } - - public void setURL(final String url, final Map headers) { - act.runOnUiThread(new Runnable() { - public void run() { - if(headers != null) { - web.loadUrl(url, headers); - } else { - web.loadUrl(url); - } - } - }); - } - - public void reload() { - act.runOnUiThread(new Runnable() { - public void run() { - web.reload(); - } - }); - } - - public boolean hasBack() { - final Boolean [] retVal = new Boolean[1]; - final boolean[] complete = new boolean[1]; - - act.runOnUiThread(new Runnable() { - public void run() { - try { - retVal[0] = web.canGoBack(); - } finally { - complete[0] = true; - } - } - }); - while (!complete[0]) { - Display.getInstance().invokeAndBlock(new Runnable() { - @Override - public void run() { - if (!complete[0]) { - try { - Thread.sleep(20); - } catch (InterruptedException ex) { - } - } - } - }); - } - return retVal[0].booleanValue(); - } - - public boolean hasForward() { - final Boolean [] retVal = new Boolean[1]; - final boolean[] complete = new boolean[1]; - - act.runOnUiThread(new Runnable() { - public void run() { - try { - retVal[0] = web.canGoForward(); - } finally { - complete[0] = true; - } - } - }); - - while (!complete[0]) { - Display.getInstance().invokeAndBlock(new Runnable() { - @Override - public void run() { - if (!complete[0]) { - try { - Thread.sleep(20); - } catch (InterruptedException ex) { - } - } - } - }); - } - return retVal[0].booleanValue(); - } - - public void back() { - act.runOnUiThread(new Runnable() { - public void run() { - web.goBack(); - } - }); - } - - public void forward() { - act.runOnUiThread(new Runnable() { - public void run() { - web.goForward(); - } - }); - } - - public void clearHistory() { - act.runOnUiThread(new Runnable() { - public void run() { - web.clearHistory(); - } - }); - } - - public void stop() { - act.runOnUiThread(new Runnable() { - public void run() { - web.stopLoading(); - } - }); - } - - public void destroy() { - act.runOnUiThread(new Runnable() { - public void run() { - web.destroy(); - } - }); - } - - public void setPage(final String html, final String baseUrl) { - act.runOnUiThread(new Runnable() { - public void run() { - web.loadDataWithBaseURL(baseUrl, html, "text/html", "UTF-8", null); - } - }); - } - - public void exposeInJavaScript(final Object o, final String name) { - act.runOnUiThread(new Runnable() { - public void run() { - web.addJavascriptInterface(o, name); - } - }); - } - - public void setPinchZoomEnabled(final boolean e) { - act.runOnUiThread(new Runnable() { - public void run() { - web.getSettings().setSupportZoom(e); - web.getSettings().setBuiltInZoomControls(e); - } - }); - } - - @Override - protected void deinitialize() { - act.runOnUiThread(new Runnable() { - @Override - public void run() { - if(android.os.Build.VERSION.SDK_INT == 21) { // bugfix for Android 5.0.x - web.setLayerType(View.LAYER_TYPE_SOFTWARE, null); //setting layer type to software to prevent the sigseg 11 crash - } - } - }); - super.deinitialize(); - } - } - - - - public Object connect(String url, boolean read, boolean write, int timeout) throws IOException { - URL u = new URL(url); - CookieHandler.setDefault(null); - URLConnection con = u.openConnection(); - if (con instanceof HttpURLConnection) { - HttpURLConnection c = (HttpURLConnection) con; - c.setUseCaches(false); - c.setDefaultUseCaches(false); - c.setInstanceFollowRedirects(false); - if(timeout > -1) { - c.setConnectTimeout(timeout); - } - - if (android.os.Build.VERSION.SDK_INT > 13) { - c.setRequestProperty("Connection", "close"); - } - } - con.setDoInput(read); - con.setDoOutput(write); - return con; - } - - @Override - public void setReadTimeout(Object connection, int readTimeout) { - if (connection instanceof URLConnection) { - ((URLConnection)connection).setReadTimeout(readTimeout); - } - } - - - - @Override - public boolean isReadTimeoutSupported() { - return true; - } - - @Override - public void setInsecure(Object connection, boolean insecure) { - if (insecure) { - if (connection instanceof HttpsURLConnection) { - HttpsURLConnection conn = (HttpsURLConnection)connection; - try { - TrustModifier.relaxHostChecking(conn); - } catch (Exception ex) { - com.codename1.io.Log.e(ex); - } - } - } - } - - - /** - * @inheritDoc - */ - public Object connect(String url, boolean read, boolean write) throws IOException { - return connect(url, read, write, timeout); - } - - - private static final char[] HEX_CHARS = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'}; - - private static String dumpHex(byte[] data) { - final int n = data.length; - final StringBuilder sb = new StringBuilder(n * 3 - 1); - for (int i = 0; i < n; i++) { - if (i > 0) { - sb.append(' '); - } - sb.append(HEX_CHARS[(data[i] >> 4) & 0x0F]); - sb.append(HEX_CHARS[data[i] & 0x0F]); - } - return sb.toString(); - } - - @Override - public String[] getSSLCertificates(Object connection, String url) throws IOException { - if (connection instanceof HttpsURLConnection) { - HttpsURLConnection conn = (HttpsURLConnection)connection; - - try { - conn.connect(); - java.security.cert.Certificate[] certs = conn.getServerCertificates(); - String[] out = new String[certs.length * 2]; - int i=0; - for (java.security.cert.Certificate cert : certs) { - { - MessageDigest md = MessageDigest.getInstance("SHA-256"); - md.update(cert.getEncoded()); - out[i++] = "SHA-256:" + dumpHex(md.digest()); - } - { - MessageDigest md = MessageDigest.getInstance("SHA1"); - md.update(cert.getEncoded()); - out[i++] = "SHA1:" + dumpHex(md.digest()); - } - - } - return out; - } catch (Exception ex) { - ex.printStackTrace(); - } - } - return new String[0]; - - } - - @Override - public boolean canGetSSLCertificates() { - return true; - } - - @Override - public boolean canGetPublicKeyDigests() { - return true; - } - - @Override - public String[] getSSLCertificatesEx(Object connection, String url) throws IOException { - if (connection instanceof HttpsURLConnection) { - HttpsURLConnection conn = (HttpsURLConnection) connection; - try { - conn.connect(); - java.security.cert.Certificate[] certs = conn.getServerCertificates(); - java.util.List out = new java.util.ArrayList(); - for (int i = 0; i < certs.length; i++) { - java.security.cert.Certificate cert = certs[i]; - out.add("CHAIN:" + i); - MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); - sha256.update(cert.getEncoded()); - out.add("SHA-256:" + dumpHex(sha256.digest())); - MessageDigest sha1 = MessageDigest.getInstance("SHA1"); - sha1.update(cert.getEncoded()); - out.add("SHA1:" + dumpHex(sha1.digest())); - // getPublicKey().getEncoded() is already the DER SubjectPublicKeyInfo, - // which is exactly what a public-key pin is computed over. - java.security.PublicKey pk = cert.getPublicKey(); - if (pk != null && pk.getEncoded() != null) { - MessageDigest spki = MessageDigest.getInstance("SHA-256"); - spki.update(pk.getEncoded()); - out.add("SPKI-SHA-256:" - + com.codename1.util.Base64.encodeNoNewline(spki.digest())); - } - } - return out.toArray(new String[out.size()]); - } catch (Exception ex) { - ex.printStackTrace(); - } - } - return new String[0]; - } - - /** - * @inheritDoc - */ - public void setHeader(Object connection, String key, String val) { - ((URLConnection) connection).setRequestProperty(key, val); - } - - @Override - public void setChunkedStreamingMode(Object connection, int bufferLen){ - HttpURLConnection con = ((HttpURLConnection) connection); - con.setChunkedStreamingMode(bufferLen); - } - - - - /** - * @inheritDoc - */ - public OutputStream openOutputStream(Object connection) throws IOException { - if (connection instanceof String) { - String con = (String)connection; - if (con.startsWith("file://")) { - con = con.substring(7); - } - - OutputStream fc = createFileOuputStream((String) con); - BufferedOutputStream o = new BufferedOutputStream(fc, (String) con); - return o; - } - return new BufferedOutputStream(((URLConnection) connection).getOutputStream(), connection.toString()); - } - - /** - * @inheritDoc - */ - public OutputStream openOutputStream(Object connection, int offset) throws IOException { - String con = (String) connection; - con = removeFilePrefix(con); - RandomAccessFile rf = new RandomAccessFile(con, "rw"); - rf.seek(offset); - FileOutputStream fc = new FileOutputStream(rf.getFD()); - BufferedOutputStream o = new BufferedOutputStream(fc, con); - o.setConnection(rf); - return o; - } - - /** - * @inheritDoc - */ - public void cleanup(Object o) { - try { - super.cleanup(o); - if (o != null) { - if (o instanceof RandomAccessFile) { - ((RandomAccessFile) o).close(); - } - } - } catch (Throwable ex) { - ex.printStackTrace(); - } - } - - /** - * @inheritDoc - */ - public InputStream openInputStream(Object connection) throws IOException { - if (connection instanceof String) { - String con = (String) connection; - if (con.startsWith("file://")) { - con = con.substring(7); - } - InputStream fc = createFileInputStream(con); - BufferedInputStream o = new BufferedInputStream(fc, con); - return o; - } - if(connection instanceof HttpURLConnection) { - HttpURLConnection ht = (HttpURLConnection)connection; - if(ht.getResponseCode() < 400) { - return new BufferedInputStream(ht.getInputStream()); - } - return new BufferedInputStream(ht.getErrorStream()); - } else { - return new BufferedInputStream(((URLConnection) connection).getInputStream()); - } - } - - /** - * @inheritDoc - */ - public void setHttpMethod(Object connection, String method) throws IOException { - if(method.equalsIgnoreCase("patch")) { - allowPatch((HttpURLConnection) connection); - } - ((HttpURLConnection) connection).setRequestMethod(method); - } - - // the following block is based on a few suggestions in this stack overflow - // answer https://stackoverflow.com/questions/25163131/httpurlconnection-invalid-http-method-patch - private static boolean enabledPatch; - private static boolean patchFailed; - private static void allowPatch(HttpURLConnection connection) { - if(enabledPatch) { - return; - } - if(patchFailed) { - connection.setRequestProperty("X-HTTP-Method-Override", "PATCH"); - return; - } - try { - Field methodsField = HttpURLConnection.class.getDeclaredField("methods"); - - Field modifiersField = Field.class.getDeclaredField("modifiers"); - modifiersField.setAccessible(true); - modifiersField.setInt(methodsField, methodsField.getModifiers() & ~Modifier.FINAL); - - methodsField.setAccessible(true); - - String[] oldMethods = (String[]) methodsField.get(null); - Set methodsSet = new LinkedHashSet(Arrays.asList(oldMethods)); - methodsSet.addAll(Arrays.asList("PATCH")); - String[] newMethods = methodsSet.toArray(new String[0]); - - methodsField.set(null/*static field*/, newMethods); - enabledPatch = true; - } catch (NoSuchFieldException e) { - patchFailed = true; - connection.setRequestProperty("X-HTTP-Method-Override", "PATCH"); - } catch(IllegalAccessException ee) { - patchFailed = true; - connection.setRequestProperty("X-HTTP-Method-Override", "PATCH"); - } - } - - /** - * @inheritDoc - */ - public void setPostRequest(Object connection, boolean p) { - try { - if (p) { - ((HttpURLConnection) connection).setRequestMethod("POST"); - } else { - ((HttpURLConnection) connection).setRequestMethod("GET"); - } - } catch (IOException err) { - // an exception here doesn't make sense - err.printStackTrace(); - } - } - - /** - * @inheritDoc - */ - public int getResponseCode(Object connection) throws IOException { - // workaround for Android bug discussed here: http://stackoverflow.com/questions/17638398/androids-httpurlconnection-throws-eofexception-on-head-requests - HttpURLConnection con = (HttpURLConnection) connection; - if("head".equalsIgnoreCase(con.getRequestMethod())) { - con.setDoOutput(false); - con.setRequestProperty( "Accept-Encoding", "" ); - } - return ((HttpURLConnection) connection).getResponseCode(); - } - - /** - * @inheritDoc - */ - public String getResponseMessage(Object connection) throws IOException { - return ((HttpURLConnection) connection).getResponseMessage(); - } - - /** - * @inheritDoc - */ - public int getContentLength(Object connection) { - return ((HttpURLConnection) connection).getContentLength(); - } - - /** - * @inheritDoc - */ - public String getHeaderField(String name, Object connection) throws IOException { - return ((HttpURLConnection) connection).getHeaderField(name); - } - - /** - * @inheritDoc - */ - public String[] getHeaderFieldNames(Object connection) throws IOException { - Set s = ((HttpURLConnection) connection).getHeaderFields().keySet(); - String[] resp = new String[s.size()]; - s.toArray(resp); - return resp; - } - - /** - * @inheritDoc - */ - public String[] getHeaderFields(String name, Object connection) throws IOException { - HttpURLConnection c = (HttpURLConnection) connection; - List headers = new ArrayList(); - - // we need to merge headers with differing case since this should be case insensitive - for(String key : c.getHeaderFields().keySet()) { - if(key != null && key.equalsIgnoreCase(name)) { - headers.addAll(c.getHeaderFields().get(key)); - } - } - if (headers.size() > 0) { - List v = new ArrayList(); - v.addAll(headers); - Collections.reverse(v); - String[] s = new String[v.size()]; - v.toArray(s); - return s; - } - // workaround for a bug in some android devices - String f = c.getHeaderField(name); - if(f != null && f.length() > 0) { - return new String[] {f}; - } - return null; - - - - } - - /** - * Directory holding storage writes still in progress. - * - *

A sibling of the files dir rather than something inside it. Every name is a - * legal storage key, so no name reserved inside that namespace can be kept clear - * of the application: a key called after the scratch area would either be - * unstorable or, if it already existed as a file, would stop the directory being - * created and fail every write from then on. Outside the namespace there is - * nothing to collide with. It stays on the same filesystem as the entries, which - * is what lets a write be published by renaming.

- */ - private static final String STORAGE_SCRATCH_DIR = "cn1-storage-scratch"; - - /** - * Suffix of the file each process locks for as long as it is running, so that the - * others can tell whether the writes it left behind are still being written. - * - *

This replaces judging a scratch file by its age. An application may run more - * than one process, each with its own copy of this class and so its own idea of - * what is open, and age was the only thing they all agreed on -- but - * {@code lastModified} is a wall clock reading, and a clock that jumps forward - * makes a file being written this moment look arbitrarily old. A lock says - * whether the writer is there, and the system drops it when a process ends - * however it ends, so it cannot outlive the process it stands for.

- */ - private static final String STORAGE_LIVE_SUFFIX = ".live"; - - /** - * How long to leave between sweeps. A rate limit rather than a judgement about - * any file, measured on the monotonic clock so that setting the wall clock cannot - * disturb it. - */ - private static final long STORAGE_SWEEP_INTERVAL = 5L * 60L * 1000L; - - /** - * Distinguishes the scratch files of concurrent writes. Paired with the process - * id, since a second process counts from the beginning as well. - */ - private static final AtomicLong storageScratchCounter = new AtomicLong(); - - /** - * Guards the instant at which a write is published or abandoned, and the set of - * writes that are still open. Deleting an entry and publishing one have to take - * turns: otherwise a write that renames its scratch file just after another - * thread deleted the entry brings the deleted entry back. - */ - private static final Object storagePublishLock = new Object(); - - /** - * Name of the file whose lock serializes storage writes between processes. - */ - private static final String STORAGE_LOCK_FILE = ".lock"; - - /** - * The cross process lock, and the handle it is taken on, while this process holds - * it. Guarded by {@link #storagePublishLock}, so only one thread here ever has it. - */ - private static RandomAccessFile storageLockHandle; - private static FileLock storageLockAcrossProcesses; - - /** - * The lock this process holds for as long as it runs, saying that the scratch - * files bearing its process id are still being written. Never released: the - * system takes it back when the process ends. - */ - private static RandomAccessFile storageLiveHandle; - private static FileLock storageLiveLock; - - /** - * How many nested claims this process has on the cross process lock. A - * {@code FileLock} is held by the whole VM and cannot be taken twice, and - * clearStorage claims it and then calls deleteStorageFile for every entry. - */ - private static int storageLockDepth; - - /** - * Claims the storage for this process, so that creating a scratch file, deleting - * an entry and publishing a write cannot interleave between processes. - * - *

Unlinking a writer's scratch file is what cancels it, and that only reaches - * the writes that exist when the deletion looks. Without this a second process - * could create its scratch file just after a deletion had scanned for them, and - * publish over the entry that deletion went on to remove. A lock the filesystem - * arbitrates is the only thing both processes can see; the system drops it when a - * process ends however it ends, so it cannot be left held by a crash.

- * - *

Best effort: if the lock cannot be taken the work still goes ahead, since a - * storage that stops writing would be worse than one exposed to a race that only - * an application with more than one process can reach at all.

- * - *

The caller must hold {@link #storagePublishLock}.

- */ - private static void lockStorageAcrossProcesses() { - if (storageLockDepth == 0) { - try { - File dir = storageScratchDir(); - if (dir.isDirectory() || dir.mkdirs() || dir.isDirectory()) { - // kept before the lock is attempted rather than after it succeeds, - // so that a lock which throws still leaves releaseStorageLock - // something to close. Otherwise a filesystem that refuses to lock - // leaks a descriptor on every storage operation until unrelated - // files stop opening. - storageLockHandle = - new RandomAccessFile(new File(dir, STORAGE_LOCK_FILE), "rw"); - storageLockAcrossProcesses = storageLockHandle.getChannel().lock(); - } - } catch (Throwable t) { - // android's log, not ours: the default log writer is a storage stream, - // so reporting this through it would come back through here with the - // depth still at zero and fail the same way, again and again - Log.e("CodenameOne", "Could not lock the storage", t); - releaseStorageLock(); - } - } - storageLockDepth++; - } - - /** - * Gives up this process's claim on the storage. - * - *

The caller must hold {@link #storagePublishLock}.

- */ - private static void unlockStorageAcrossProcesses() { - storageLockDepth--; - if (storageLockDepth == 0) { - releaseStorageLock(); - } - } - - /** - * Drops the cross process lock and the handle it was taken on, whichever of them - * this process actually got. - */ - private static void releaseStorageLock() { - try { - if (storageLockAcrossProcesses != null) { - storageLockAcrossProcesses.release(); - } - } catch (Throwable t) { - Log.e("CodenameOne", "Could not release the storage lock", t); - } - storageLockAcrossProcesses = null; - try { - if (storageLockHandle != null) { - storageLockHandle.close(); - } - } catch (Throwable t) { - Log.e("CodenameOne", "Could not close the storage lock", t); - } - storageLockHandle = null; - } - - /** - * The writes that are currently open, so that deleting an entry can cancel them. - * Guarded by {@link #storagePublishLock}. - */ - private static final List openStorageWrites = - new ArrayList(); - - /** - * When the scratch area is next worth looking at, on the monotonic clock. Keeps - * the sweep from running on every write without ever being the thing that decides - * whether a file is abandoned. Guarded by {@link #storagePublishLock}. - */ - private static long nextStorageScratchSweep; - - /** - * @inheritDoc - */ - public void deleteStorageFile(String name) { - synchronized (storagePublishLock) { - lockStorageAcrossProcesses(); - try { - // cancelled before the entry goes, and under the same lock the - // publishing rename takes, so a write that is already mid close - // cannot put the entry back afterwards. - for (int iter = 0; iter < openStorageWrites.size(); iter++) { - openStorageWrites.get(iter).cancel(name); - } - // the same for writes in another process, which the monitor above - // knows nothing about. Unlinking a scratch file cancels it: the - // writer keeps a working descriptor on an inode with no name, exactly - // as it used to keep one on an entry deleted underneath it, and the - // rename that would have published it can no longer find anything to - // rename. Scratch files go first, so a publish that slips through - // between the two still leaves an entry for the delete to remove. - discardScratchFilesFor(name); - getContext().deleteFile(name); - } finally { - unlockStorageAcrossProcesses(); - } - } - } - - /** - * Unlinks every scratch file being written for the given entry, in this process - * or any other, which is what cancels those writes. - * - * @param name the storage entry - */ - private static void discardScratchFilesFor(String name) { - try { - String prefix = storageScratchPrefix(name); - File[] scratch = storageScratchDir().listFiles(); - if (scratch == null) { - return; - } - for (int iter = 0; iter < scratch.length; iter++) { - if (scratch[iter].getName().startsWith(prefix) && !scratch[iter].delete()) { - com.codename1.io.Log.p("Could not cancel the storage write " - + scratch[iter]); - } - } - } catch (IOException err) { - com.codename1.io.Log.e(err); - } - } - - /** - * @inheritDoc - */ - public void clearStorage() { - synchronized (storagePublishLock) { - // every open write, not just the ones for entries that exist. A write to - // an entry that is not there yet is absent from listStorageEntries, so the - // inherited implementation never reaches it, and it would publish a new - // entry moments after the storage was supposedly emptied. - lockStorageAcrossProcesses(); - try { - for (int iter = 0; iter < openStorageWrites.size(); iter++) { - openStorageWrites.get(iter).cancel(); - } - discardAllScratchFiles(); - super.clearStorage(); - } finally { - unlockStorageAcrossProcesses(); - } - } - } - - /** - * @inheritDoc - */ - public boolean abandonStorageWrite(String name, OutputStream writing) { - // this write and no other. Every write to the entry used to be given up - // together, so a second thread writing the same entry had its value quietly - // discarded and was told the write had succeeded. - if (writing instanceof StorageOutputStream) { - synchronized (storagePublishLock) { - ((StorageOutputStream) writing).cancel(); - } - // such a write leaves the entry untouched until it is published, so - // whatever was stored is still there - return true; - } - // a stream that never opened cannot have touched anything either. Anything - // else wrote into the entry itself and the caller has to clear up after it. - return writing == null; - } - - /** - * @inheritDoc - * - *

Writes into the entry, as it always has. A caller may hold this open and - * expect what it flushes to be readable meanwhile -- the log writer keeps one for - * the life of the application and sendLog reads the entry behind its back -- so - * an entry that appeared only on close would leave the log unreadable and lose - * everything written since the process started. What can be given here without - * changing when the entry appears is the flush that Android does not do on - * close.

- */ - public OutputStream createStorageOutputStream(String name) throws IOException { - return new SyncingStorageOutputStream(getContext().openFileOutput(name, 0)); - } - - /** - * @inheritDoc - */ - public OutputStream createStorageOutputStream(String name, boolean replaceWhenClosed) - throws IOException { - if (!replaceWhenClosed) { - return createStorageOutputStream(name); - } - sweepStorageScratchFiles(); - return new StorageOutputStream(name); - } - - /** - * Forces a stream onto the device as it closes, which Android does not do by - * itself, without changing anything about when what is written becomes visible. - */ - private static final class SyncingStorageOutputStream extends OutputStream { - private final FileOutputStream out; - private boolean closed; - - SyncingStorageOutputStream(FileOutputStream out) { - this.out = out; - } - - @Override - public void write(int b) throws IOException { - out.write(b); - } - - @Override - public void write(byte[] b) throws IOException { - out.write(b); - } - - @Override - public void write(byte[] b, int off, int len) throws IOException { - out.write(b, off, len); - } - - @Override - public void flush() throws IOException { - out.flush(); - } - - @Override - public void close() throws IOException { - if (closed) { - return; - } - closed = true; - try { - out.flush(); - out.getFD().sync(); - } finally { - out.close(); - } - } - } - - /** - * @inheritDoc - */ - public InputStream createStorageInputStream(String name) throws IOException { - return getContext().openFileInput(name); - } - - /** - * @inheritDoc - */ - public boolean storageFileExists(String name) { - String[] fileList = getContext().fileList(); - for (int iter = 0; iter < fileList.length; iter++) { - if (fileList[iter].equals(name)) { - return true; - } - } - return false; - } - - /** - * @inheritDoc - */ - public String[] listStorageEntries() { - return getContext().fileList(); - } - - /** - * @inheritDoc - */ - public int getStorageEntrySize(String name) { - return (int)new File(getContext().getFilesDir(), name).length(); - } - - /** - * Removes the scratch files left behind by a run that died mid write, once they - * are old enough that nothing can still be writing them. - */ - private void sweepStorageScratchFiles() { - synchronized (storagePublishLock) { - long now = android.os.SystemClock.elapsedRealtime(); - if (now < nextStorageScratchSweep) { - return; - } - nextStorageScratchSweep = now + STORAGE_SWEEP_INTERVAL; - // under the lock the other processes take to start a write or to say they - // are running. Finding an owner gone and then deleting its files are two - // steps, and a process id is handed out again the moment its holder is - // gone: without this a process could be given the id just examined, say so - // and start writing, and have this sweep delete the write it had only just - // begun -- or the very file it had said it was alive with, after which - // every later sweep would take it for gone. - lockStorageAcrossProcesses(); - try { - File dir = storageScratchDir(); - File[] files = dir.listFiles(); - if (files == null) { - return; - } - int mine = android.os.Process.myPid(); - for (int iter = 0; iter < files.length; iter++) { - if (isStorageLockFile(files[iter])) { - continue; - } - int owner = storageScratchOwner(files[iter].getName()); - // this process knows what it is doing without asking, and never - // tries to lock its own liveness file, which it already holds - if (owner < 0 || owner == mine || isProcessWriting(dir, owner)) { - continue; - } - if (!files[iter].delete()) { - com.codename1.io.Log.p("Could not remove the abandoned storage " - + "scratch file " + files[iter]); - } - } - } catch (Throwable t) { - // a sweep that fails costs disk space, never correctness - com.codename1.io.Log.e(t); - } finally { - unlockStorageAcrossProcesses(); - } - } - } - - /** - * The process a file in the scratch directory belongs to. - * - * @param fileName the name of the file - * @return the process id, or -1 if the name does not carry one - */ - private static int storageScratchOwner(String fileName) { - String pid; - if (fileName.endsWith(STORAGE_LIVE_SUFFIX)) { - pid = fileName.substring(0, fileName.length() - STORAGE_LIVE_SUFFIX.length()); - } else { - int digest = fileName.indexOf('-'); - int counter = digest < 0 ? -1 : fileName.indexOf('-', digest + 1); - if (counter < 0) { - return -1; - } - pid = fileName.substring(digest + 1, counter); - } - try { - return Integer.parseInt(pid); - } catch (NumberFormatException err) { - return -1; - } - } - - /** - * Whether the given process is still running, and so may still be writing the - * scratch files that carry its id. - * - *

Asked of the filesystem rather than of {@code /proc}, which since Android 9 - * shows a process only itself. A lock that can be taken is one nobody is holding. - * Anything unexpected counts as running, since deleting another process's work on - * a guess is the one outcome worth avoiding here.

- * - * @param dir the scratch directory - * @param pid the process to ask about - * @return true if that process appears to be running - */ - private static boolean isProcessWriting(File dir, int pid) { - File live = new File(dir, pid + STORAGE_LIVE_SUFFIX); - if (!live.exists()) { - return false; - } - RandomAccessFile handle = null; - FileLock held = null; - try { - handle = new RandomAccessFile(live, "rw"); - held = handle.getChannel().tryLock(); - return held == null; - } catch (Throwable t) { - return true; - } finally { - try { - if (held != null) { - held.release(); - } - if (handle != null) { - handle.close(); - } - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - } - } - - /** - * Says, for as long as this process runs, that the scratch files carrying its - * process id are still being written. - * - * @param dir the scratch directory - */ - private static void claimStorageLiveness(File dir) { - synchronized (storagePublishLock) { - if (storageLiveLock != null) { - return; - } - // under the same lock the sweep takes, so that saying this process is - // running and clearing what the last holder of its id left behind cannot - // land in the middle of another process deciding that id is gone - lockStorageAcrossProcesses(); - try { - try { - storageLiveHandle = new RandomAccessFile( - new File(dir, android.os.Process.myPid() + STORAGE_LIVE_SUFFIX), "rw"); - storageLiveLock = storageLiveHandle.getChannel().lock(); - } catch (Throwable t) { - // android's log for the same reason as above - Log.e("CodenameOne", "Could not claim the storage liveness file", t); - try { - if (storageLiveHandle != null) { - storageLiveHandle.close(); - } - } catch (Throwable ignored) { - Log.e("CodenameOne", "Could not close the liveness file", ignored); - } - // the lock as well as the handle: closing the handle gives up the - // lock, and a lock this process still believed it held is one it - // would never take again, which leaves every other process reading - // it as gone and free to delete the writes it has in flight - storageLiveHandle = null; - storageLiveLock = null; - return; - } - try { - discardEarlierIncarnation(dir); - } catch (Throwable t) { - // separately, because the claim above has already succeeded and - // clearing up after whoever held this id last is not worth giving - // it up for. The leftovers keep until a later sweep. - Log.e("CodenameOne", "Could not clear the earlier incarnation", t); - } - } finally { - unlockStorageAcrossProcesses(); - } - } - } - - /** - * Unlinks every scratch file there is, cancelling every write in progress in any - * process. - */ - private static void discardAllScratchFiles() { - try { - File[] scratch = storageScratchDir().listFiles(); - if (scratch == null) { - return; - } - for (int iter = 0; iter < scratch.length; iter++) { - if (!isStorageMarkerFile(scratch[iter]) && !scratch[iter].delete()) { - com.codename1.io.Log.p("Could not cancel the storage write " - + scratch[iter]); - } - } - } catch (IOException err) { - com.codename1.io.Log.e(err); - } - } - - /** - * Whether the given file is the one whose lock serializes the processes, rather - * than a write in progress. - * - *

It has to survive both the clear and the sweep. Linux lets a locked file be - * unlinked, and the lock goes with the inode rather than the name, so a process - * that removed it while holding it would leave the next process free to create - * the name afresh and take a lock on a different inode: both would then hold - * "the" lock and neither would wait for the other. Nothing writes to it either, - * so its age says nothing about whether it is in use.

- * - * @param file a file in the scratch directory - * @return true if the file is the lock - */ - private static boolean isStorageLockFile(File file) { - return STORAGE_LOCK_FILE.equals(file.getName()); - } - - /** - * Removes whatever a previous process left behind under this process's id. - * - *

Android hands out a process id again once the process holding it is gone, so - * after a crash or a reboot the files an earlier incarnation abandoned can be - * sitting under the id this one has just been given. The sweep passes over - * anything bearing its own id, on the grounds that a process knows its own work, - * which would leave those files where they are for good.

- * - *

Usually this runs before the first write, when the process owns nothing and - * everything under its id must belong to the incarnation before it. That is not - * guaranteed: a claim that fails is retried by the next write, by which time this - * process may have writes of its own open. Those are known exactly and are left - * alone -- deleting one would fail a write that had already been serialized.

- * - *

The caller must hold {@link #storagePublishLock}.

- * - * @param dir the scratch directory - */ - private static void discardEarlierIncarnation(File dir) { - File[] files = dir.listFiles(); - if (files == null) { - return; - } - int mine = android.os.Process.myPid(); - for (int iter = 0; iter < files.length; iter++) { - if (!isStorageMarkerFile(files[iter]) - && storageScratchOwner(files[iter].getName()) == mine - && !isOpenStorageWrite(files[iter]) - && !files[iter].delete()) { - com.codename1.io.Log.p("Could not remove the abandoned storage scratch " - + "file " + files[iter]); - } - } - } - - /** - * Whether the given scratch file belongs to a write this process has open. - * - *

The caller must hold {@link #storagePublishLock}.

- * - * @param file a file in the scratch directory - * @return true if a write in this process is using it - */ - private static boolean isOpenStorageWrite(File file) { - for (int iter = 0; iter < openStorageWrites.size(); iter++) { - if (openStorageWrites.get(iter).scratch.equals(file)) { - return true; - } - } - return false; - } - - /** - * Whether the given file is one of the markers the processes keep about - * themselves, rather than a write in progress. - * - *

Clearing the storage throws away the writes, and nothing else. A process - * whose liveness file was taken from underneath it goes on holding the lock, so - * it never notices and never makes the name again, and from then on every other - * process reads it as gone and feels free to delete the writes it has in flight. - * The sweep is the one place a liveness file is removed, and only once its owner - * is known to be gone.

- * - * @param file a file in the scratch directory - * @return true if the file is a marker rather than a pending write - */ - private static boolean isStorageMarkerFile(File file) { - return isStorageLockFile(file) || file.getName().endsWith(STORAGE_LIVE_SUFFIX); - } - - /** - * The start of the name of every scratch file for the given entry. - * - *

A digest rather than the entry itself: an entry name may be as long as the - * filesystem allows on its own, so anything built by appending to one would be - * refused. Fixed width, and specific enough that one entry's deletion does not - * cancel another's write.

- * - * @param name the storage entry - * @return the prefix shared by that entry's scratch files - * @throws IOException if the digest is unavailable - */ - private static String storageScratchPrefix(String name) throws IOException { - try { - byte[] digest = java.security.MessageDigest.getInstance("SHA-256") - .digest(name.getBytes("UTF-8")); - StringBuilder b = new StringBuilder(digest.length * 2); - for (int iter = 0; iter < digest.length; iter++) { - b.append(Character.forDigit((digest[iter] >> 4) & 0xf, 16)); - b.append(Character.forDigit(digest[iter] & 0xf, 16)); - } - return b.append('-').toString(); - } catch (java.security.NoSuchAlgorithmException err) { - throw new IOException("No SHA-256 to name storage scratch files with", err); - } - } - - /** - * Resolves a storage entry to its file, refusing anything that would land outside - * the storage directory. - * - *

{@code openFileOutput} used to make this check on our behalf and reject any - * name holding a path separator. Publishing by rename does not: with name - * normalization turned off a key like {@code ../shared_prefs/settings.xml} - * reaches here as it was written, and {@code File} resolves it, which would put - * the rename anywhere in the application's private data and leave behind an entry - * that Storage itself could no longer read or delete.

- * - * @param name the storage entry - * @return the file the entry is stored in - * @throws IOException if the name does not name an entry in the storage directory - */ - private static File storageEntryFile(String name) throws IOException { - File dir = getContext().getFilesDir(); - if (name.indexOf('/') >= 0 || name.indexOf(File.separatorChar) >= 0) { - throw new IOException("Storage entry " + name + " contains a path separator"); - } - File entry = new File(dir, name); - if (!dir.equals(entry.getParentFile())) { - throw new IOException("Storage entry " + name + " resolves outside " + dir); - } - return entry; - } - - /** - * The directory holding the writes that are in progress. - * - * @return the scratch directory, which is not guaranteed to exist yet - * @throws IOException if the application has no data directory to put it in - */ - private static File storageScratchDir() throws IOException { - File files = getContext().getFilesDir(); - File data = files.getParentFile(); - if (data == null) { - throw new IOException("No application data directory above " + files); - } - return new File(data, STORAGE_SCRATCH_DIR); - } - - /** - * Writes a storage entry to a scratch file, forces the bytes onto the device and - * only then renames that file over the entry. - * - *

{@code openFileOutput} truncates the entry as it opens it, and Android does - * not flush a file on close. Writing the entry in place therefore left a window - * on every single write in which the entry was empty or half written on disk, and - * left the bytes of a completed write sitting in the page cache for as long as - * the kernel felt like holding them. An abrupt end to the process or to the - * device inside either window -- a low memory kill, a force stop, a battery pull, - * a panic -- lost the entry, and on a filesystem that journals the truncation - * ahead of the data it came back as a zero length file. How wide those windows - * are is a property of the filesystem and of how eagerly the vendor kills - * background processes, which is why this only ever showed up on some devices.

- * - *

The entry now changes in a single rename, which the filesystem cannot show - * half done, and the bytes reach the device before that rename is made.

- */ - private static final class StorageOutputStream extends OutputStream { - private final String name; - private final File target; - private final File scratch; - private final FileOutputStream out; - private boolean closed; - private boolean cancelled; - - StorageOutputStream(String name) throws IOException { - this.name = name; - this.target = storageEntryFile(name); - File dir = storageScratchDir(); - if (!dir.isDirectory() && !dir.mkdirs() && !dir.isDirectory()) { - throw new IOException("Could not create the storage scratch directory " - + dir); - } - // the write goes ahead whether or not that succeeded. A claim can only - // fail where the filesystem will not lock, and refusing to write would - // turn that into an application that cannot store anything -- far worse - // than what it costs, which is that another process sweeping at that - // moment may take this write for abandoned and unlink it. That fails the - // write, honestly, and leaves what was already stored where it is; the - // next write claims again. Same trade the cross process lock makes. - claimStorageLiveness(dir); - // the digest of the entry lets another process find and cancel this write. - // The process id separates concurrent processes, whose counters both start - // from the beginning, and the counter separates writes within one. - this.scratch = new File(dir, storageScratchPrefix(name) - + android.os.Process.myPid() + "-" - + storageScratchCounter.incrementAndGet()); - // created and registered as one step under the lock a deletion takes. - // Registering afterwards would leave a write whose scratch file already - // exists but which a concurrent deleteStorageFile cannot see to cancel, - // and that write would rename itself over the entry that was deleted. - synchronized (storagePublishLock) { - lockStorageAcrossProcesses(); - try { - this.out = new FileOutputStream(scratch); - openStorageWrites.add(this); - } finally { - unlockStorageAcrossProcesses(); - } - } - } - - /** - * Marks this write as one that must not be published, whatever entry it is - * for. Called holding {@link #storagePublishLock}. - */ - void cancel() { - cancelled = true; - } - - /** - * Marks this write as one that must not be published, because the entry it - * would publish over has been deleted since it opened. Called holding - * {@link #storagePublishLock}. - * - * @param entry the entry being deleted - */ - void cancel(String entry) { - if (name.equals(entry)) { - cancelled = true; - } - } - - @Override - public void write(int b) throws IOException { - out.write(b); - } - - @Override - public void write(byte[] b) throws IOException { - out.write(b); - } - - @Override - public void write(byte[] b, int off, int len) throws IOException { - out.write(b, off, len); - } - - @Override - public void flush() throws IOException { - out.flush(); - } - - @Override - public void close() throws IOException { - if (closed) { - return; - } - closed = true; - try { - try { - out.flush(); - out.getFD().sync(); - } finally { - out.close(); - } - publish(); - } finally { - synchronized (storagePublishLock) { - openStorageWrites.remove(this); - } - if (scratch.exists() && !scratch.delete()) { - com.codename1.io.Log.p("Could not remove the storage scratch file " - + scratch); - } - } - } - - /** - * Renames the scratch file over the entry, which is the point at which the - * write becomes visible. - * - * @throws IOException if the entry could not be replaced, so that the caller - * that wrote it hears about it rather than being told the write succeeded - */ - private void publish() throws IOException { - synchronized (storagePublishLock) { - lockStorageAcrossProcesses(); - try { - // the one case where not publishing is not a failure: this - // process cancelled the write itself, so the caller either asked - // for the entry to go or is already abandoning the write. Failing - // here would only log noise over an outcome that is already known. - if (cancelled) { - return; - } - if (scratch.renameTo(target)) { - syncStorageDirectory(target.getParentFile()); - return; - } - // A missing scratch file is not reported as a success. Another - // process unlinking it does mean this entry was deleted, and - // failing here reaches the same place -- writeObject deletes the - // entry on a failed write -- while still telling the caller that - // what it wrote did not land. Anything else that removed the file - // gets the same honest answer, where calling it a success would - // leave the caller believing in a value the storage never took. - throw new IOException("Could not store " + name); - } finally { - unlockStorageAcrossProcesses(); - } - } - } - } - - /** - * Forces a rename in the given directory onto the device, so that a completed - * write does not fall back to its previous contents after an abrupt shutdown. - * Best effort: without it a crash can still only cost the newest write, never the - * integrity of an entry. - * - * @param dir the directory holding the storage entries - */ - private static void syncStorageDirectory(File dir) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - return; - } - try { - DirectorySync.sync(dir); - } catch (Throwable t) { - // some filesystems refuse to sync a directory handle - } - } - - /** - * Isolates the API 21 syscalls, so that verifying {@code AndroidImplementation} - * on an older device never has to resolve them. - */ - private static final class DirectorySync { - private DirectorySync() { - } - - static void sync(File dir) throws android.system.ErrnoException { - java.io.FileDescriptor fd = android.system.Os.open(dir.getPath(), - android.system.OsConstants.O_RDONLY, 0); - try { - android.system.Os.fsync(fd); - } finally { - android.system.Os.close(fd); - } - } - } - - private String addFile(String s) { - // I explicitly don't create a "proper URL" since code might rely on the fact that the file isn't encoded - if(s != null && s.startsWith("/")) { - return "file://" + s; - } - return s; - } - - /** - * @inheritDoc - */ - public String[] listFilesystemRoots() { - - if(!checkForPermission(Manifest.permission.READ_EXTERNAL_STORAGE, "This is required to browse the file system")){ - return new String[]{}; - } - - String [] storageDirs = getStorageDirectories(); - if(storageDirs != null){ - String [] roots = new String[storageDirs.length + 1]; - System.arraycopy(storageDirs, 0, roots, 0, storageDirs.length); - roots[roots.length - 1] = addFile(Environment.getRootDirectory().getAbsolutePath()); - return roots; - } - return new String[]{addFile(Environment.getRootDirectory().getAbsolutePath())}; - } - - @Override - public boolean hasCachesDir() { - return true; - } - - @Override - public String getCachesDir() { - return getContext().getCacheDir().getAbsolutePath(); - } - - - - private String[] getStorageDirectories() { - String [] storageDirs = null; - - String storageDev = Environment.getExternalStorageDirectory().getPath(); - String storageRoot = storageDev.substring(0, storageDev.length() - 1); - BufferedReader bufReader = null; - - try { - bufReader = new BufferedReader(new InputStreamReader(new FileInputStream("/proc/mounts"), StandardCharsets.UTF_8)); - ArrayList list = new ArrayList(); - String line; - - while ((line = bufReader.readLine()) != null) { - if (line.contains("vfat") || line.contains("/mnt") || line.contains("/storage")) { - StringTokenizer tokens = new StringTokenizer(line, " "); - String s = tokens.nextToken(); - s = tokens.nextToken(); // Take the second token, i.e. mount point - - if (s.indexOf("secure") != -1) { - continue; - } - - if (s.startsWith(storageRoot) == true) { - list.add(s); - continue; - } - - if (line.contains("vfat") && line.contains("/mnt")) { - list.add(s); - continue; - } - } - } - - int count = list.size(); - - if (count < 2) { - storageDirs = new String[] { - storageDev - }; - } - else { - storageDirs = new String[count]; - - for (int i = 0; i < count; i++) { - storageDirs[i] = (String) list.get(i); - } - } - } - catch (FileNotFoundException e) {} - catch (IOException e) {} - finally { - if (bufReader != null) { - try { - bufReader.close(); - } - catch (IOException e) {} - } - - return storageDirs; - } - } - - /** - * @inheritDoc - */ - public String getAppHomePath() { - return addFile(getContext().getFilesDir().getAbsolutePath() + "/"); - } - - @Override - public String toNativePath(String path) { - return removeFilePrefix(path); - } - - - - /** - * @inheritDoc - */ - public String[] listFiles(String directory) throws IOException { - directory = removeFilePrefix(directory); - return new File(directory).list(); - } - - /** - * @inheritDoc - */ - public long getRootSizeBytes(String root) { - return -1; - } - - /** - * @inheritDoc - */ - public long getRootAvailableSpace(String root) { - return -1; - } - - /** - * @inheritDoc - */ - public void mkdir(String directory) { - directory = removeFilePrefix(directory); - new File(directory).mkdir(); - } - - /** - * @inheritDoc - */ - public void deleteFile(String file) { - file = removeFilePrefix(file); - File f = new File(file); - f.delete(); - } - - /** - * @inheritDoc - */ - public boolean isHidden(String file) { - file = removeFilePrefix(file); - return new File(file).isHidden(); - } - - /** - * @inheritDoc - */ - public void setHidden(String file, boolean h) { - } - - /** - * @inheritDoc - */ - public long getFileLength(String file) { - file = removeFilePrefix(file); - return new File(file).length(); - } - - /** - * @inheritDoc - */ - public long getFileLastModified(String file) { - file = removeFilePrefix(file); - return new File(file).lastModified(); - } - - /** - * @inheritDoc - */ - public boolean isDirectory(String file) { - file = removeFilePrefix(file); - return new File(file).isDirectory(); - } - - /** - * @inheritDoc - */ - public char getFileSystemSeparator() { - return File.separatorChar; - } - - /** - * @inheritDoc - */ - public OutputStream openFileOutputStream(String file) throws IOException { - file = removeFilePrefix(file); - OutputStream os = null; - try{ - os = createFileOuputStream(file); - }catch(FileNotFoundException fne){ - //It is impossible to know if a path is considered an external - //storage on the various android's versions. - //So we try to open the path and if failed due to permission we will - //ask for the permission from the user - if(fne.getMessage().contains("Permission denied")){ - - if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to access the file")){ - //The user refused to give access. - return null; - }else{ - //The user gave permission try again to access the path - return createFileOuputStream(file); - } - - }else{ - throw fne; - } - } - - return os; - } - - static String removeFilePrefix(String file) { - if (file.startsWith("file://")) { - return file.substring(7); - } - if (file.startsWith("file:/")) { - return file.substring(5); - } - return file; - } - - /** - * @inheritDoc - */ - public InputStream openFileInputStream(String file) throws IOException { - file = removeFilePrefix(file); - InputStream is = null; - try{ - is = createFileInputStream(file); - }catch(FileNotFoundException fne){ - //It is impossible to know if a path is considered an external - //storage on the various android's versions. - //So we try to open the path and if failed due to permission we will - //ask for the permission from the user - if(fne.getMessage().contains("Permission denied")){ - - if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to access the file")){ - //The user refused to give access. - return null; - }else{ - //The user gave permission try again to access the path - return openFileInputStream(file); - } - - }else{ - throw fne; - } - } - - return is; - } - - @Override - public boolean isMultiTouch() { - return true; - } - - /** - * @inheritDoc - */ - public boolean exists(String file) { - file = removeFilePrefix(file); - return new File(file).exists(); - } - - /** - * @inheritDoc - */ - public void rename(String file, String newName) { - file = removeFilePrefix(file); - new File(file).renameTo(new File(new File(file).getParentFile(), newName)); - } - - protected File createFileObject(String fileName) { - return new File(fileName); - } - - protected InputStream createFileInputStream(String fileName) throws FileNotFoundException { - return new FileInputStream(removeFilePrefix(fileName)); - } - - protected InputStream createFileInputStream(File f) throws FileNotFoundException { - return new FileInputStream(f); - } - - protected OutputStream createFileOuputStream(String fileName) throws FileNotFoundException { - return new FileOutputStream(removeFilePrefix(fileName)); - } - - protected OutputStream createFileOuputStream(java.io.File f) throws FileNotFoundException { - return new FileOutputStream(f); - } - - /** - * @inheritDoc - */ - public boolean shouldWriteUTFAsGetBytes() { - return true; - } - - - /** - * @inheritDoc - */ - public void closingOutput(OutputStream s) { - // For some reasons the Android guys chose not doing this by default: - // http://android-developers.blogspot.com/2010/12/saving-data-safely.html - // this seems to be a mistake of sacrificing stability for minor performance - // gains which will only be noticeable on a server. - if (s != null) { - if (s instanceof FileOutputStream) { - try { - FileDescriptor fd = ((FileOutputStream) s).getFD(); - if (fd != null) { - fd.sync(); - } - } catch (IOException ex) { - // this exception doesn't help us - ex.printStackTrace(); - } - } - } - } - - /** - * @inheritDoc - */ - public void printStackTraceToStream(Throwable t, Writer o) { - PrintWriter p = new PrintWriter(o); - t.printStackTrace(p); - } - - private AndroidBiometrics biometrics; - private AndroidSecureStorage secureStorage; - private AndroidNfc nfc; - private AndroidBluetooth bluetooth; - - @Override - public com.codename1.security.Biometrics getBiometrics() { - if (biometrics == null) { - biometrics = new AndroidBiometrics(); - } - return biometrics; - } - - @Override - public com.codename1.security.SecureStorage getSecureStorage() { - if (secureStorage == null) { - secureStorage = new AndroidSecureStorage(); - } - return secureStorage; - } - - @Override - public com.codename1.nfc.Nfc getNfc() { - if (nfc == null) { - nfc = new AndroidNfc(this); - } - return nfc; - } - - @Override - public com.codename1.bluetooth.Bluetooth getBluetooth() { - if (bluetooth == null) { - bluetooth = new AndroidBluetooth(); - } - return bluetooth; - } - - private com.codename1.health.Health health; - - /// Returns the Health Connect-backed health entry point. The store - /// degrades to reporting itself unsupported when no bridge has been - /// injected, which is the case for apps that never reference - /// com.codename1.health. - @Override - public com.codename1.health.Health getHealth() { - // Guarded because everything the store serializes is per-instance: - // the authorization queue, the subscription registry, drain - // coalescing and the persisted-cursor lock. Two threads racing this - // getter each got their own store, and two stores coordinate on - // nothing -- they would launch overlapping permission flows despite - // the queue inside each one being correct. - synchronized (AndroidImplementation.class) { - if (health == null) { - health = new AndroidHealth(); - } - return health; - } - } - - /** - * This method returns the platform Location Control - * - * @return LocationControl Object - */ - public LocationManager getLocationManager() { - String permissionMessage = "This is required to get the location"; - if ( - !checkForPermission( Manifest.permission.ACCESS_FINE_LOCATION, permissionMessage) - ) { - return null; - } - if ( - Build.VERSION.SDK_INT >= 29 - && "true".equals(Display.getInstance().getProperty("android.requiresBackgroundLocationPermissionForAPI29", "false")) - ) { - if ( - !checkForPermission( - "android.permission.ACCESS_BACKGROUND_LOCATION", - permissionMessage - ) - ) { - com.codename1.io.Log.e(new RuntimeException("Background location permission denied")); - } - } - - boolean includesPlayServices = Display.getInstance().getProperty("IncludeGPlayServices", "false").equals("true"); - if (includesPlayServices && hasAndroidMarket()) { - try { - Class clazz = Class.forName("com.codename1.location.AndroidLocationPlayServiceManager"); - return (com.codename1.location.LocationManager)clazz.getMethod("getInstance").invoke(null); - } catch (Exception e) { - return AndroidLocationManager.getInstance(getContext()); - } - } else { - return AndroidLocationManager.getInstance(getContext()); - } - } - - private AndroidMotionSensorManager motionSensorManager; - - @Override - public com.codename1.sensors.MotionSensorManager getMotionSensorManager() { - if (motionSensorManager == null) { - Context ctx = getContext(); - if (ctx == null) { - return null; - } - motionSensorManager = new AndroidMotionSensorManager(ctx); - } - return motionSensorManager; - } - - private String fixAttachmentPath(String attachment) { - com.codename1.io.File cn1File = new com.codename1.io.File(attachment); - File mediaStorageDir = new File(new File(getContext().getCacheDir(), "intent_files"), "Attachment"); - - // Create the storage directory if it does not exist - if (!mediaStorageDir.exists()) { - if (!mediaStorageDir.mkdirs()) { - Log.d(Display.getInstance().getProperty("AppName", "CodenameOne"), "failed to create directory"); - return null; - } - } - - File newFile = new File(mediaStorageDir.getPath() + File.separator - + cn1File.getName()); - if (newFile.exists()) { - if (Display.getInstance().getProperty("DeleteCachedFileAfterShare", "false").equals("true")) { - newFile.delete(); - } else { - // Create a media file name - String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date()); - newFile = new File(mediaStorageDir.getPath() + File.separator - + "IMG_" + timeStamp + "_" + cn1File.getName()); - } - } - - - //Uri fileUri = Uri.fromFile(newFile); - newFile.getParentFile().mkdirs(); - //Uri imageUri = Uri.fromFile(newFile); - Uri fileUri = FileProvider.getUriForFile(getContext(), getContext().getPackageName()+".provider", newFile); - - try { - InputStream is = FileSystemStorage.getInstance().openInputStream(attachment); - OutputStream os = new FileOutputStream(newFile); - byte [] buf = new byte[1024]; - int len; - while((len = is.read(buf)) > -1){ - os.write(buf, 0, len); - } - is.close(); - os.close(); - } catch (IOException ex) { - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - } - - return fileUri.toString(); - } - - /** - * @inheritDoc - */ - public void sendMessage(String[] recipients, String subject, Message msg) { - if(editInProgress()) { - stopEditing(true); - } - Intent emailIntent; - String attachment = msg.getAttachment(); - boolean hasAttachment = (attachment != null && attachment.length() > 0) || msg.getAttachments().size() > 0; - - if(msg.getMimeType().equals(Message.MIME_TEXT) && !hasAttachment){ - StringBuilder to = new StringBuilder(); - for (int i = 0; i < recipients.length; i++) { - to.append(recipients[i]); - to.append(";"); - } - emailIntent = new Intent(Intent.ACTION_SENDTO, - Uri.parse( - "mailto:" + to.toString() - + "?subject=" + Uri.encode(subject) - + "&body=" + Uri.encode(msg.getContent()))); - }else{ - if (hasAttachment) { - if(msg.getAttachments().size() > 1) { - emailIntent = new Intent(android.content.Intent.ACTION_SEND_MULTIPLE); - emailIntent.putExtra(android.content.Intent.EXTRA_EMAIL, recipients); - emailIntent.putExtra(android.content.Intent.EXTRA_SUBJECT, subject); - emailIntent.setType(msg.getMimeType()); - ArrayList uris = new ArrayList(); - - for(String path : msg.getAttachments().keySet()) { - uris.add(Uri.parse(fixAttachmentPath(path))); - } - - emailIntent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris); - } else { - emailIntent = new Intent(android.content.Intent.ACTION_SEND); - emailIntent.putExtra(android.content.Intent.EXTRA_EMAIL, recipients); - emailIntent.putExtra(android.content.Intent.EXTRA_SUBJECT, subject); - emailIntent.setType(msg.getMimeType()); - emailIntent.setType(msg.getAttachmentMimeType()); - //if the attachment is in the uder home dir we need to copy it - //to an accessible dir - attachment = fixAttachmentPath(attachment); - emailIntent.putExtra(Intent.EXTRA_STREAM, Uri.parse(attachment)); - } - } else { - emailIntent = new Intent(android.content.Intent.ACTION_SEND); - emailIntent.putExtra(android.content.Intent.EXTRA_EMAIL, recipients); - emailIntent.putExtra(android.content.Intent.EXTRA_SUBJECT, subject); - emailIntent.setType(msg.getMimeType()); - } - if (msg.getMimeType().equals(Message.MIME_HTML)) { - emailIntent.putExtra(android.content.Intent.EXTRA_TEXT, Html.fromHtml(msg.getContent())); - emailIntent.putExtra("android.intent.extra.HTML_TEXT", msg.getContent()); - }else{ - /* - // Attempted this workaround to fix the ClassCastException that occurs on android when - // there are multiple attachments. Unfortunately, this fixes the stack trace, but - // has the unwanted side-effect of producing a blank message body. - // Same workaround for HTML mimetype also fails the same way. - // Conclusion, Just live with the stack trace. It doesn't seem to affect the - // execution of the program... treat it as a warning. - // See https://github.com/codenameone/CodenameOne/issues/1782 - if (msg.getAttachments().size() > 1) { - ArrayList contentArr = new ArrayList(); - contentArr.add(msg.getContent()); - emailIntent.putStringArrayListExtra(android.content.Intent.EXTRA_TEXT, contentArr); - } else { - emailIntent.putExtra(android.content.Intent.EXTRA_TEXT, msg.getContent()); - - }*/ - emailIntent.putExtra(android.content.Intent.EXTRA_TEXT, msg.getContent()); - } - - } - final String attach = attachment; - AndroidNativeUtil.startActivityForResult(Intent.createChooser(emailIntent, "Send mail..."), new IntentResultListener() { - - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - if(attach != null && attach.length() > 0 && attach.contains("tmp")){ - FileSystemStorage.getInstance().delete(attach); - } - } - }); - } - - /** - * @inheritDoc - */ - public void dial(String phoneNumber) { - Intent dialer = new Intent(android.content.Intent.ACTION_DIAL, Uri.parse("tel:" + phoneNumber)); - getContext().startActivity(dialer); - } - - @Override - public int getSMSSupport() { - if(canDial()) { - return Display.SMS_INTERACTIVE; - } - return Display.SMS_NOT_SUPPORTED; - } - - /** - * @inheritDoc - */ - public void sendSMS(final String phoneNumber, final String message, boolean i) throws IOException { - /*if(!checkForPermission(Manifest.permission.SEND_SMS, "This is required to send a SMS")){ - return; - }*/ - if(!checkForPermission(Manifest.permission.READ_PHONE_STATE, "This is required to send a SMS")){ - return; - } - if(i) { - Intent smsIntent = null; - if(android.os.Build.VERSION.SDK_INT < 19){ - smsIntent = new Intent(Intent.ACTION_VIEW); - smsIntent.setType("vnd.android-dir/mms-sms"); - smsIntent.putExtra("address", phoneNumber); - smsIntent.putExtra("sms_body",message); - }else{ - smsIntent = new Intent(Intent.ACTION_SENDTO); - smsIntent.setData(Uri.parse("smsto:" + Uri.encode(phoneNumber))); - smsIntent.putExtra("sms_body", message); - } - getContext().startActivity(smsIntent); - - } /*else { - SmsManager sms = SmsManager.getDefault(); - ArrayList parts = sms.divideMessage(message); - sms.sendMultipartTextMessage(phoneNumber, null, parts, null, null); - }*/ - } - - @Override - public void dismissNotification(Object o) { - NotificationManager notificationManager = (NotificationManager) getContext().getSystemService(Activity.NOTIFICATION_SERVICE); - if(o != null){ - Integer n = (Integer)o; - notificationManager.cancel("CN1", n.intValue()); - }else{ - notificationManager.cancelAll(); - } - } - - @Override - public boolean isNotificationSupported() { - return true; - } - - /** - * Keys of display properties that need to be made available to Services - * i.e. must be accessible even if CN1 is not initialized. - * - * This is accomplished by setting them inside init(). Then they - * are written to file so that they can be accessed inside a service - * like push notification service. - */ - private static final String[] servicePropertyKeys = new String[]{ - "android.NotificationChannel.id", - "android.NotificationChannel.name", - "android.NotificationChannel.description", - "android.NotificationChannel.importance", - "android.NotificationChannel.enableLights", - "android.NotificationChannel.lightColor", - "android.NotificationChannel.enableVibration", - "android.NotificationChannel.vibrationPattern", - "android.NotoficationChannel.soundUri" - }; - - /** - * Flag to indicate if any of the service properties have been changed. - */ - private static boolean servicePropertiesDirty() { - for (String key : servicePropertyKeys) { - if (Display.getInstance().getProperty(key, null) != null) { - return true; - } - } - return false; - } - - /** - * Stores properties that need to be accessible to services. - * i.e. must be accessible even if CN1 is not initialized. - * - * This is accomplished by setting them inside init(). Then they - * are written to file so that they can be accessed inside a service - * like push notification service. - */ - private static Map serviceProperties; - - /** - * Gets the service properties. Will read properties from file so that - * they are available even if CN1 is not initialized. - * @param a - * @return - */ - public static Map getServiceProperties(Context a) { - if (serviceProperties == null) { - InputStream i = null; - try { - serviceProperties = new HashMap(); - try { - i = a.openFileInput("CN1$AndroidServiceProperties"); - if(i == null) { - return serviceProperties; - } - } catch (FileNotFoundException notFoundEx){ - return serviceProperties; - } - DataInputStream is = new DataInputStream(i); - int count = is.readInt(); - for (int idx=0; idx out = getServiceProperties(a); - - - for (String key : servicePropertyKeys) { - - String val = Display.getInstance().getProperty(key, null); - if (val != null) { - out.put(key, val); - } - if ("true".equals(Display.getInstance().getProperty(key+"#delete", null))) { - out.remove(key); - - } - } - - OutputStream os = null; - try { - os = a.openFileOutput("CN1$AndroidServiceProperties", 0); - if (os == null) { - System.out.println("Failed to save service properties null output stream"); - return; - } - DataOutputStream dos = new DataOutputStream(os); - dos.writeInt(out.size()); - for (String key : out.keySet()) { - dos.writeUTF(key); - dos.writeUTF((String)out.get(key)); - } - serviceProperties = null; - } catch (FileNotFoundException ex) { - System.out.println("Service properties file not found. This is normal for the first run. On subsequent runs, the file should exist."); - } catch (IOException ex) { - - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - } finally { - try { - if (os != null) os.close(); - } catch (Throwable ex) { - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - } - } - } - } - - /** - * Gets a "service" display property. This is a property that is available - * even if CN1 is not initialized. They are written to file after init() so that - * they are available thereafter to services like push notification services. - * @param key THe key - * @param defaultValue The default value - * @param context Context - * @return The value. - */ - public static String getServiceProperty(String key, String defaultValue, Context context) { - if (Display.isInitialized()) { - return Display.getInstance().getProperty(key, defaultValue); - } - String val = getServiceProperties(context).get(key); - return val == null ? defaultValue : val; - } - - /** - * Sets the notification channel on a notification builder. Uses service properties to - * set properties of channel. - * @param nm The notification manager. - * @param mNotifyBuilder The notify builder - * @param context The context - * @since 7.0 - */ - public static void setNotificationChannel(NotificationManager nm, NotificationCompat.Builder mNotifyBuilder, Context context) { - setNotificationChannel(nm, mNotifyBuilder, context, (String)null); - - } - - /** - * Sets the notification channel on a notification builder. Uses service properties to - * set properties of channel. - * @param nm The notification manager. - * @param mNotifyBuilder The notify builder - * @param context The context - * @param soundName The name of the sound to use for notifications on this channel. E.g. mysound.mp3. This feature is not yet implemented, but - * parameter is added now to scaffold compatibility with build daemon until implementation is complete. - * @since 7.0 - */ - public static void setNotificationChannel(NotificationManager nm, NotificationCompat.Builder mNotifyBuilder, Context context, String soundName) { - if (android.os.Build.VERSION.SDK_INT >= 26) { - try { - NotificationManager mNotificationManager = nm; - - String id = getServiceProperty("android.NotificationChannel.id", "cn1-channel", context); - - CharSequence name = getServiceProperty("android.NotificationChannel.name", "Notifications", context); - - String description = getServiceProperty("android.NotificationChannel.description", "Remote notifications", context); - - // NotificationManager.IMPORTANCE_LOW = 2 - // NotificationManager.IMPORTANCE_HIGH = 4 // <-- Minimum level to produce sound. - int importance = Integer.parseInt(getServiceProperty("android.NotificationChannel.importance", "4", context)); - // Note: Currently we use a single notification channel for the app, but if the app uses different kinds of - // push notifications, then this may not be sufficient. E.g. The app may send both silent push notifications - // and regular notifications - but their settings (e.g. sound) are all managed through one channel with - // same settings. - // TODO Add support for multiple channels. - // See https://github.com/codenameone/CodenameOne/issues/2583 - - Class clsNotificationChannel = Class.forName("android.app.NotificationChannel"); - //android.app.NotificationChannel mChannel = new android.app.NotificationChannel(id, name, importance); - Constructor constructor = clsNotificationChannel.getConstructor(java.lang.String.class, java.lang.CharSequence.class, int.class); - Object mChannel = constructor.newInstance(new Object[]{id, name, importance}); - - Method method = clsNotificationChannel.getMethod("setDescription", java.lang.String.class); - method.invoke(mChannel, new Object[]{description}); - //mChannel.setDescription(description); - - method = clsNotificationChannel.getMethod("enableLights", boolean.class); - method.invoke(mChannel, new Object[]{Boolean.parseBoolean(getServiceProperty("android.NotificationChannel.enableLights", "true", context))}); - //mChannel.enableLights(Boolean.parseBoolean(getServiceProperty("android.NotificationChannel.enableLights", "true", context))); - - method = clsNotificationChannel.getMethod("setLightColor", int.class); - method.invoke(mChannel, new Object[]{Integer.parseInt(getServiceProperty("android.NotificationChannel.lightColor", "" + android.graphics.Color.RED, context))}); - //mChannel.setLightColor(Integer.parseInt(getServiceProperty("android.NotificationChannel.lightColor", "" + android.graphics.Color.RED, context))); - - method = clsNotificationChannel.getMethod("enableVibration", boolean.class); - method.invoke(mChannel, new Object[]{Boolean.parseBoolean(getServiceProperty("android.NotificationChannel.enableVibration", "false", context))}); - //mChannel.enableVibration(Boolean.parseBoolean(getServiceProperty("android.NotificationChannel.enableVibration", "false", context))); - String vibrationPatternStr = getServiceProperty("android.NotificationChannel.vibrationPattern", null, context); - if (vibrationPatternStr != null) { - String[] parts = vibrationPatternStr.split(","); - int len = parts.length; - long[] pattern = new long[len]; - for (int i = 0; i < len; i++) { - pattern[i] = Long.parseLong(parts[i].trim()); - } - method = clsNotificationChannel.getMethod("setVibrationPattern", long[].class); - method.invoke(mChannel, new Object[]{pattern}); - //mChannel.setVibrationPattern(pattern); - } - - String soundUri = getServiceProperty("android.NotificationChannel.soundUri", null, context); - if (soundUri != null) { - Uri uri= android.net.Uri.parse(soundUri); - - android.media.AudioAttributes audioAttributes = new android.media.AudioAttributes.Builder() - .setContentType(android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION) - .setUsage(android.media.AudioAttributes.USAGE_NOTIFICATION) - .build(); - method = clsNotificationChannel.getMethod("setSound", android.net.Uri.class, android.media.AudioAttributes.class); - method.invoke(mChannel, new Object[]{uri, audioAttributes}); - } - - method = NotificationManager.class.getMethod("createNotificationChannel", clsNotificationChannel); - method.invoke(mNotificationManager, new Object[]{mChannel}); - //mNotificationManager.createNotificationChannel(mChannel); - try { - // For some reason I can't find the app-support-v4.jar for - // API 26 that includes this method so that I can compile in netbeans. - // So we use reflection... If someone coming after can find a newer version - // that has setChannelId(), please rip out this ugly reflection hack and - // replace it with a proper call to mNotifyBuilder.setChannelId(id) - mNotifyBuilder.getClass().getMethod("setChannelId", new Class[]{String.class}).invoke(mNotifyBuilder, new Object[]{id}); - } catch (Exception ex) { - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - } - //mNotifyBuilder.setChannelId(id); - } catch (ClassNotFoundException ex) { - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - } catch (NoSuchMethodException ex) { - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - } catch (SecurityException ex) { - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - } catch (IllegalAccessException ex) { - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - } catch (IllegalArgumentException ex) { - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - } catch (InvocationTargetException ex) { - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - } catch (InstantiationException ex) { - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - } - //mNotifyBuilder.setChannelId(id); - } - - } - - public Object notifyStatusBar(String tickerText, String contentTitle, - String contentBody, boolean vibrate, boolean flashLights, Hashtable args) { - int id = getContext().getResources().getIdentifier("icon", "drawable", getContext().getApplicationInfo().packageName); - - NotificationManager notificationManager = (NotificationManager) getContext().getSystemService(Activity.NOTIFICATION_SERVICE); - - Intent notificationIntent = new Intent(); - notificationIntent.setComponent(activityComponentName); - PendingIntent contentIntent = createPendingIntent(getContext(), 0, notificationIntent); - - - NotificationCompat.Builder builder = new NotificationCompat.Builder(getContext()) - .setContentIntent(contentIntent) - .setSmallIcon(id) - .setContentTitle(contentTitle) - .setTicker(tickerText); - if(flashLights){ - builder.setLights(0, 1000, 1000); - } - if(vibrate){ - builder.setVibrate(new long[]{0, 100, 1000}); - } - if(args != null) { - Boolean b = (Boolean)args.get("persist"); - if(b != null && b.booleanValue()) { - builder.setAutoCancel(false); - builder.setOngoing(true); - } else { - builder.setAutoCancel(false); - } - } else { - builder.setAutoCancel(true); - } - Notification notification = builder.build(); - int notifyId = 10001; - notificationManager.notify("CN1", notifyId, notification); - return new Integer(notifyId); - } - - public boolean isContactsPermissionGranted() { - if (android.os.Build.VERSION.SDK_INT < 23) { - return true; - } - - if (android.support.v4.content.ContextCompat.checkSelfPermission(getContext(), - Manifest.permission.READ_CONTACTS) - != PackageManager.PERMISSION_GRANTED) { - return false; - } - return true; - } - - - @Override - public String[] getAllContacts(boolean withNumbers) { - if(!checkForPermission(Manifest.permission.READ_CONTACTS, "This is required to get the contacts")){ - return new String[]{}; - } - return AndroidContactsManager.getInstance().getContacts(getContext(), withNumbers); - } - - @Override - public com.codename1.calendar.LocalCalendarSource getLocalCalendarSource() { - if (calendarSource == null) { - calendarSource = new AndroidCalendarSource(getContext()); - } - return calendarSource; - } - - @Override - public Contact getContactById(String id) { - if(!checkForPermission(Manifest.permission.READ_CONTACTS, "This is required to get the contacts")){ - return null; - } - return AndroidContactsManager.getInstance().getContact(getContext(), id); - } - - @Override - public Contact getContactById(String id, boolean includesFullName, boolean includesPicture, - boolean includesNumbers, boolean includesEmail, boolean includeAddress){ - if(!checkForPermission(Manifest.permission.READ_CONTACTS, "This is required to get the contacts")){ - return null; - } - return AndroidContactsManager.getInstance().getContact(getContext(), id, includesFullName, includesPicture, - includesNumbers, includesEmail, includeAddress); - } - - @Override - public Contact[] getAllContacts(boolean withNumbers, boolean includesFullName, boolean includesPicture, boolean includesNumbers, boolean includesEmail, boolean includeAddress) { - if(!checkForPermission(Manifest.permission.READ_CONTACTS, "This is required to get the contacts")){ - return new Contact[]{}; - } - return AndroidContactsManager.getInstance().getAllContacts(getContext(), withNumbers, includesFullName, includesPicture, includesNumbers, includesEmail, includeAddress); - } - - @Override - public boolean isGetAllContactsFast() { - return true; - } - - @Override - public boolean isContactPickerSupported() { - // Both paths behind AndroidContactPicker exist on every version this - // port runs on: the system picker from Android 17, ACTION_PICK - // against the contacts provider before that. A device with no - // contacts app answers with ActivityNotFoundException, which the - // picker reports as an empty selection -- the same thing a cancelled - // pick reports, so callers need no separate case for it. - // - // Deliberately NOT PackageManager.resolveActivity. Review asked for - // it, to catch the kiosk device that has no contacts app at all, and - // it would answer the wrong question on every ordinary one: from - // Android 11 a resolve query is filtered by package visibility, so an - // app without a matching entry is told nothing handles the - // intent even where the picker works perfectly. LAUNCHING an implicit - // intent is not filtered, which is why the picker itself needs no - // and works regardless. Trading a false yes on a stripped - // device -- whose cost is a pick that reports empty, exactly as a - // cancelled one does -- for a false no on every modern device, whose - // cost is a working feature hidden with no way to find out why, is a - // bad trade. - return getActivity() != null; - } - - @Override - public void pickContacts(int requestedFields, boolean multiSelect, - int selectionLimit, boolean requireAllRequestedFields, - ActionListener response) { - if (getActivity() == null) { - fireContactPickerResult(response, new Contact[0]); - return; - } - if (editInProgress()) { - stopEditing(true); - } - // Deliberately no checkForPermission call. The whole point of the - // picker is that neither path needs READ_CONTACTS, and asking for it - // here would put the permission back into the manifest and in front - // of the user for a flow that does not need it. - AndroidContactPicker.pick(getContext(), requestedFields, multiSelect, - selectionLimit, requireAllRequestedFields, - new ContactPickerResult(response)); - } - - /** - * Hands a picker selection back to the listener that asked for it. - */ - private final class ContactPickerResult implements AndroidContactPicker.Result { - private final ActionListener response; - - ContactPickerResult(ActionListener response) { - this.response = response; - } - - @Override - public void picked(Contact[] picked) { - fireContactPickerResult(response, picked); - } - } - - public String createContact(String firstName, String surname, String officePhone, String homePhone, String cellPhone, String email) { - if(!checkForPermission(Manifest.permission.WRITE_CONTACTS, "This is required to create a contact")){ - return null; - } - return AndroidContactsManager.getInstance().createContact(getContext(), firstName, surname, officePhone, homePhone, cellPhone, email); - } - - public boolean deleteContact(String id) { - if(!checkForPermission(Manifest.permission.WRITE_CONTACTS, "This is required to delete a contact")){ - return false; - } - return AndroidContactsManager.getInstance().deleteContact(getContext(), id); - } - - @Override - public boolean isNativeShareSupported() { - return true; - } - - @Override - public boolean isNativeInAppReviewSupported() { - // True only when the Play In-App Review library was bundled, which the - // AndroidGradleBuilder does when the app references the app-review API. - return getActivity() != null && AppReviewSupport.isSupported(); - } - - @Override - public void requestNativeInAppReview(final SuccessCallback done) { - final CodenameOneActivity activity = getActivity(); - if (activity == null || !AppReviewSupport.isSupported()) { - if (done != null) { - done.onSucess(Boolean.FALSE); - } - return; - } - activity.runOnUiThread(new Runnable() { - public void run() { - AppReviewSupport.requestReview(activity, done); - } - }); - } - - @Override - public void share(String text, String image, String mimeType, Rectangle sourceRect){ - share(text, image, mimeType, sourceRect, null); - } - - @Override - public void share(String text, String image, String mimeType, Rectangle sourceRect, final com.codename1.share.ShareResultListener listener) { - /*if(!checkForPermission(Manifest.permission.READ_PHONE_STATE, "This is required to perform share")){ - return; - }*/ - Intent shareIntent = new Intent(android.content.Intent.ACTION_SEND); - if(image == null){ - if (text.startsWith("file:") && mimeType != null && new com.codename1.io.File(text).exists()) { - shareIntent.setType(mimeType); - shareIntent.putExtra(Intent.EXTRA_STREAM, Uri.parse(fixAttachmentPath(text))); - } else { - shareIntent.setType("text/plain"); - shareIntent.putExtra(android.content.Intent.EXTRA_TEXT, text); - } - }else{ - shareIntent.setType(mimeType); - shareIntent.putExtra(Intent.EXTRA_STREAM, Uri.parse(fixAttachmentPath(image))); - shareIntent.putExtra(Intent.EXTRA_TEXT, text); - } - - Intent chooser; - try { - if (listener != null && android.os.Build.VERSION.SDK_INT >= 22) { - chooser = buildShareChooserWithCallback(shareIntent, listener); - } else { - chooser = Intent.createChooser(shareIntent, "Share with..."); - } - } catch (Throwable t) { - // Fall back to the plain chooser, then synthesize a listener - // result so the app doesn't hang on an unfulfilled callback. - chooser = Intent.createChooser(shareIntent, "Share with..."); - if (listener != null) { - listener.onResult(com.codename1.share.ShareResult.sharedTo(null)); - } - } - getContext().startActivity(chooser); - } - - private static int nextShareReceiverId = 1; - - @TargetApi(22) - private Intent buildShareChooserWithCallback(Intent shareIntent, final com.codename1.share.ShareResultListener listener) { - final Context appCtx = getContext().getApplicationContext(); - final String action = appCtx.getPackageName() + ".CN1_SHARE_CHOSEN." + (nextShareReceiverId++); - // The receiver fires once when the user picks a target. Android - // does not expose a dismissal signal for the chooser, so the - // listener simply does not fire on user-cancel (see comment - // further down). - final boolean[] delivered = new boolean[1]; - BroadcastReceiver receiver = new BroadcastReceiver() { - @Override - public void onReceive(Context ctx, Intent intent) { - if (delivered[0]) return; - delivered[0] = true; - try { appCtx.unregisterReceiver(this); } catch (Throwable ignore) {} - String pkg = null; - try { - android.content.ComponentName cn = intent.getParcelableExtra(Intent.EXTRA_CHOSEN_COMPONENT); - if (cn != null) pkg = cn.getPackageName(); - } catch (Throwable ignore) {} - listener.onResult(com.codename1.share.ShareResult.sharedTo(pkg)); - } - }; - IntentFilter filter = new IntentFilter(action); - boolean registered = false; - if (android.os.Build.VERSION.SDK_INT >= 33) { - // RECEIVER_EXPORTED = 0x2 -- constant exists at runtime on - // API 33+ but is not present in older android.jar build deps, - // so call the 3-arg overload via reflection to stay source- - // compatible. - try { - java.lang.reflect.Method m = Context.class.getMethod( - "registerReceiver", BroadcastReceiver.class, IntentFilter.class, int.class); - m.invoke(appCtx, receiver, filter, Integer.valueOf(0x2)); - registered = true; - } catch (Throwable ignore) {} - } - if (!registered) { - appCtx.registerReceiver(receiver, filter); - } - // Android's chooser IntentSender callback never fires on - // dismissal: there is no public API to observe a user-cancel. - // Apps that need a dismissal signal must use Activity-resume. - - Intent pi = new Intent(action).setPackage(appCtx.getPackageName()); - int piFlags = PendingIntent.FLAG_UPDATE_CURRENT; - if (android.os.Build.VERSION.SDK_INT >= 31) { - // FLAG_MUTABLE was introduced in API 31; its numeric value - // (0x02000000) is referenced here directly so the source - // still compiles against pre-31 android.jar build deps. - piFlags |= 0x02000000; - } - PendingIntent pendingIntent = PendingIntent.getBroadcast(appCtx, 0, pi, piFlags); - return Intent.createChooser(shareIntent, "Share with...", pendingIntent.getIntentSender()); - } - - /// Printing uses the Android print framework which requires API 19 - /// and a foreground activity to host the print dialog. - @Override - public boolean isPrintingSupported() { - return android.os.Build.VERSION.SDK_INT >= 19 && getActivity() != null; - } - - /// Print through the Android print framework. PDF files are streamed - /// verbatim into a `android.print.PrintDocumentAdapter`; images go - /// through the support library `PrintHelper` which scales them to the - /// page. - /// - /// Outcome reporting is best effort: the PDF path polls the returned - /// `android.print.PrintJob` and treats a queued/started job as - /// completed since Android offers no callback for the terminal job - /// state once it was handed to the print service. The image path - /// reports completed when `PrintHelper` finishes because it can't - /// distinguish a dismissed dialog from a printed page. - @Override - public void print(final String filePath, final String mimeType, final com.codename1.printing.PrintResultListener listener) { - final PrintResultDispatcher dispatcher = new PrintResultDispatcher(listener); - if (!isPrintingSupported()) { - dispatcher.fire(com.codename1.printing.PrintResult.failed( - "Printing requires Android 4.4 or newer and a foreground activity")); - return; - } - if (filePath == null) { - dispatcher.fire(com.codename1.printing.PrintResult.failed("No file to print")); - return; - } - final File file = new File(removeFilePrefix(filePath)); - if (!file.exists()) { - dispatcher.fire(com.codename1.printing.PrintResult.failed("File not found: " + filePath)); - return; - } - getActivity().runOnUiThread(new Runnable() { - @Override - public void run() { - try { - // PrintSupport touches android.print which only exists - // on API 19+; the isPrintingSupported() gate above keeps - // the class from loading on older devices. - PrintSupport.startPrint(getActivity(), file, mimeType, dispatcher); - } catch (Throwable t) { - dispatcher.fire(com.codename1.printing.PrintResult.failed( - "Failed to start print job: " + t)); - } - } - }); - } - - /// Delivers a [com.codename1.printing.PrintResult] to the listener at - /// most once. The listener may be null and results may arrive from any - /// thread; `Display` moves the callback onto the EDT. - private static final class PrintResultDispatcher { - private final com.codename1.printing.PrintResultListener listener; - private boolean fired; - - PrintResultDispatcher(com.codename1.printing.PrintResultListener listener) { - this.listener = listener; - } - - void fire(com.codename1.printing.PrintResult result) { - synchronized (this) { - if (fired) { - return; - } - fired = true; - } - if (listener != null) { - listener.onResult(result); - } - } - } - - /// All android.print framework access lives in this class so the - /// classes it references are only loaded behind the API 19 check in - /// [#print]. - @TargetApi(19) - private static final class PrintSupport { - - private static final int JOB_PENDING = 0; - private static final int JOB_COMPLETED = 1; - private static final int JOB_CANCELLED = 2; - private static final int JOB_FAILED = 3; - - /// How long the poller waits for the print dialog/job to reach a - /// terminal state before giving up. - private static final long POLL_TIMEOUT = 15 * 60 * 1000L; - private static final long POLL_INTERVAL = 500; - - /// Must run on the UI thread: `PrintManager.print` and - /// `PrintHelper.printBitmap` both require it. - static void startPrint(Activity activity, File file, String mimeType, PrintResultDispatcher dispatcher) { - String jobName = file.getName(); - if ("application/pdf".equalsIgnoreCase(mimeType)) { - android.print.PrintManager printManager = - (android.print.PrintManager) activity.getSystemService(Context.PRINT_SERVICE); - if (printManager == null) { - dispatcher.fire(com.codename1.printing.PrintResult.failed("Print service unavailable")); - return; - } - android.print.PrintJob job = printManager.print(jobName, - new PdfFilePrintAdapter(jobName, file), null); - pollPrintJob(activity, job, dispatcher); - } else if (mimeType != null && mimeType.startsWith("image/")) { - printImage(activity, file, jobName, dispatcher); - } else { - dispatcher.fire(com.codename1.printing.PrintResult.failed( - "Unsupported print document type: " + mimeType)); - } - } - - private static void printImage(Activity activity, File file, String jobName, - final PrintResultDispatcher dispatcher) { - Bitmap bitmap = BitmapFactory.decodeFile(file.getAbsolutePath()); - if (bitmap == null) { - dispatcher.fire(com.codename1.printing.PrintResult.failed( - "Unable to decode image for printing")); - return; - } - android.support.v4.print.PrintHelper helper = new android.support.v4.print.PrintHelper(activity); - helper.setScaleMode(android.support.v4.print.PrintHelper.SCALE_MODE_FIT); - helper.printBitmap(jobName, bitmap, new android.support.v4.print.PrintHelper.OnPrintFinishCallback() { - @Override - public void onFinish() { - // PrintHelper fires onFinish when the print flow ends - // without exposing whether the user printed or - // dismissed the dialog; report completed best effort. - dispatcher.fire(com.codename1.printing.PrintResult.completed()); - } - }); - } - - /// Watches the print job from a background thread and reports the - /// first terminal state. The job object must only be queried on - /// the UI thread, so every tick bounces through `runOnUiThread`. - private static void pollPrintJob(final Activity activity, final android.print.PrintJob job, - final PrintResultDispatcher dispatcher) { - Thread poller = new Thread(new Runnable() { - @Override - public void run() { - long deadline = System.currentTimeMillis() + POLL_TIMEOUT; - while (System.currentTimeMillis() < deadline) { - try { - Thread.sleep(POLL_INTERVAL); - } catch (InterruptedException ignore) { - } - final int[] state = new int[]{JOB_PENDING}; - final boolean[] done = new boolean[1]; - final Object lock = new Object(); - activity.runOnUiThread(new Runnable() { - @Override - public void run() { - int s = JOB_PENDING; - try { - if (job.isCancelled()) { - s = JOB_CANCELLED; - } else if (job.isFailed()) { - s = JOB_FAILED; - } else if (job.isCompleted()) { - s = JOB_COMPLETED; - } else if (job.isQueued() || job.isStarted() || job.isBlocked()) { - // The dialog phase is over and the - // job belongs to the print service; - // that is as "completed" as Android - // lets us observe reliably. - s = JOB_COMPLETED; - } - } catch (Throwable t) { - s = JOB_FAILED; - } - synchronized (lock) { - state[0] = s; - done[0] = true; - lock.notifyAll(); - } - } - }); - synchronized (lock) { - long waitUntil = System.currentTimeMillis() + 5000; - while (!done[0] && System.currentTimeMillis() < waitUntil) { - try { - lock.wait(POLL_INTERVAL); - } catch (InterruptedException ignore) { - } - } - if (!done[0]) { - // UI thread didn't get to us; try again on - // the next tick until the deadline passes. - continue; - } - } - switch (state[0]) { - case JOB_COMPLETED: - dispatcher.fire(com.codename1.printing.PrintResult.completed()); - return; - case JOB_CANCELLED: - dispatcher.fire(com.codename1.printing.PrintResult.cancelled()); - return; - case JOB_FAILED: - dispatcher.fire(com.codename1.printing.PrintResult.failed("Print job failed")); - return; - default: - // still in the dialog phase, keep polling - } - } - dispatcher.fire(com.codename1.printing.PrintResult.failed( - "Timed out waiting for the print job status")); - } - }, "CN1PrintJobPoller"); - poller.setDaemon(true); - poller.start(); - } - - /// Streams an existing PDF file into the print system unchanged. - /// Layout/write failures are routed through the framework - /// callbacks which fail the print job; the poller in - /// [#pollPrintJob] then reports the failure to the listener, so - /// the dispatcher still fires exactly once. - private static final class PdfFilePrintAdapter extends android.print.PrintDocumentAdapter { - private final String jobName; - private final File file; - - PdfFilePrintAdapter(String jobName, File file) { - this.jobName = jobName; - this.file = file; - } - - @Override - public void onLayout(android.print.PrintAttributes oldAttributes, - android.print.PrintAttributes newAttributes, - android.os.CancellationSignal cancellationSignal, - LayoutResultCallback callback, Bundle extras) { - if (cancellationSignal != null && cancellationSignal.isCanceled()) { - callback.onLayoutCancelled(); - return; - } - try { - android.print.PrintDocumentInfo info = new android.print.PrintDocumentInfo.Builder(jobName) - .setContentType(android.print.PrintDocumentInfo.CONTENT_TYPE_DOCUMENT) - .setPageCount(android.print.PrintDocumentInfo.PAGE_COUNT_UNKNOWN) - .build(); - callback.onLayoutFinished(info, !newAttributes.equals(oldAttributes)); - } catch (Throwable t) { - callback.onLayoutFailed(t.toString()); - } - } - - @Override - public void onWrite(android.print.PageRange[] pages, - android.os.ParcelFileDescriptor destination, - android.os.CancellationSignal cancellationSignal, - WriteResultCallback callback) { - FileInputStream in = null; - FileOutputStream out = null; - try { - in = new FileInputStream(file); - out = new FileOutputStream(destination.getFileDescriptor()); - byte[] buffer = new byte[8192]; - int count; - while ((count = in.read(buffer)) > -1) { - if (cancellationSignal != null && cancellationSignal.isCanceled()) { - callback.onWriteCancelled(); - return; - } - out.write(buffer, 0, count); - } - callback.onWriteFinished(new android.print.PageRange[]{android.print.PageRange.ALL_PAGES}); - } catch (Throwable t) { - callback.onWriteFailed(t.toString()); - } finally { - if (in != null) { - try { - in.close(); - } catch (Throwable ignore) { - } - } - if (out != null) { - try { - out.close(); - } catch (Throwable ignore) { - } - } - } - } - } - } - - /** - * @inheritDoc - */ - public String getPlatformName() { - return "and"; - } - - /** - * Snapshot of the recent process logcat for crash protection. Since - * Android 4.1 (API 16) apps can only read their own process log - * without the READ_LOGS permission, which is exactly what we want. - * Returns the last ~200 lines (capped at 32 KB). - */ - @Override - public String getNativeLogSnapshot() { - java.io.BufferedReader reader = null; - Process proc = null; - try { - proc = Runtime.getRuntime().exec(new String[]{ - "logcat", "-d", "-t", "200", "-v", "threadtime"}); - reader = new java.io.BufferedReader( - new java.io.InputStreamReader(proc.getInputStream(), "UTF-8")); - StringBuilder sb = new StringBuilder(8192); - String line; - while ((line = reader.readLine()) != null) { - sb.append(line).append('\n'); - if (sb.length() > 32 * 1024) { - break; - } - } - return sb.length() == 0 ? null : sb.toString(); - } catch (Throwable ignored) { - // logcat unavailable (very old Android, locked-down ROM, - // etc.) -- crash protection still works, just without the - // device log context. - return null; - } finally { - if (reader != null) { - try { reader.close(); } catch (java.io.IOException ignored) { } - } - if (proc != null) { - try { proc.destroy(); } catch (Throwable ignored) { } - } - } - } - - /** - * @inheritDoc - */ - public String[] getPlatformOverrides() { - if (isWatch()) { - return new String[]{"watch", "android", "android-watch"}; - } - if (isTV()) { - return new String[]{"tv", "android", "android-tv"}; - } - if (isTablet()) { - return new String[]{"tablet", "android", "android-tab"}; - } else { - return new String[]{"phone", "android", "android-phone"}; - } - } - - /** - * @inheritDoc - */ - public void copyToClipboard(final Object obj) { - super.copyToClipboard(obj); - if (getActivity() == null) { - return; - } - getActivity().runOnUiThread(new Runnable() { - @Override - public void run() { - int sdk = android.os.Build.VERSION.SDK_INT; - if (sdk < 11) { - android.text.ClipboardManager clipboard = (android.text.ClipboardManager) getActivity().getSystemService(Context.CLIPBOARD_SERVICE); - clipboard.setText(obj.toString()); - // Afterwards, as in the branch below: a clip that was never published has - // not replaced the one the system is still holding, and unpinning that one - // first left its files reclaimable while it was still there to be pasted. - clipboardHolds(0); - } else { - android.content.ClipboardManager clipboard = (android.content.ClipboardManager) getActivity().getSystemService(Context.CLIPBOARD_SERVICE); - android.content.ClipData clip; - long staged = 0; - boolean assembled = false; - if (obj instanceof ClipboardContent) { - AssembledClip built = clipDataFor((ClipboardContent) obj); - clip = built == null ? null : built.getData(); - staged = built == null ? 0 : built.getClip(); - assembled = true; - if (clip == null) { - // A copy of nothing is an empty clipboard, which is a thing the user - // asked for and can paste. A *drag* of nothing is not: there the null - // refuses to start, because a drag that carries nothing still lands - // somewhere and tells that receiver it succeeded. - clip = ClipData.newPlainText("Codename One", ""); - } - } else { - // Nothing of ours is staged for a plain text clip. - clip = ClipData.newPlainText("Codename One", obj.toString()); - } - watchPrimaryClip(clipboard); - // Pinned for the length of the call, held only if it returns. setPrimaryClip - // can throw -- a payload past the Binder transaction limit is the usual way - // -- and switching the hold beforehand handed the *old* clip's files to - // reclamation while the system was still holding that clip, pinned the ones - // that never reached the clipboard in their place, and left a callback - // counted that would never arrive. The pin in between is what keeps the new - // clip's own files from being reclaimed in the window this opens. - clipboardPublishing(staged); - boolean published = false; - try { - clipboard.setPrimaryClip(clip); - published = true; - } finally { - clipboardPublished(staged, published); - if (assembled) { - // Taken over by the clipboard, or given up on. Either way this - // assembly is no longer one nothing has claimed. - endStagingClip(staged); - } - } - } - } - }); - } - - /// Builds the Android clip that publishes a `ClipboardContent`, for a clipboard copy and - /// for a native drag alike -- both hand another application the same thing, so both go - /// through the same conversion, including the file provider URIs that let the receiving - /// application read generated image bytes. - /// - /// #### Parameters - /// - /// - `content`: the representations to publish - /// - /// #### Returns - /// - /// the clip, or null when the content produced no representation at all - AssembledClip clipDataFor(ClipboardContent content) { - // Held here and handed down, never read back off the field. A clipboard copy runs - // on the Android UI thread and a drag on the Codename One event dispatch thread, so - // two assemblies can overlap -- and one reading the field mid-way filed its - // remaining files under the other's id, which split one clip across two and left - // the half nobody pinned free to be deleted while the clip still referenced it. - final long clip = beginStagingClip(); - // Every read this assembly makes goes through here; see Assembly for why it is not the - // content's own memory of what its providers produced. - Assembly assembly = new Assembly(content); - int sdk = android.os.Build.VERSION.SDK_INT; - List mimeTypes = new ArrayList(); - List items = new ArrayList(); - String plain = assembly.text(ClipboardContent.MIME_TEXT); - String html = assembly.text(ClipboardContent.MIME_HTML); - // A clip carries one text payload. Where the content has no text/plain but does have - // some other text representation -- markdown, AsciiDoc, a URI list -- that one is the - // payload, since publishing an empty clip instead would lose it outright. - String primaryTextMime = plain != null ? ClipboardContent.MIME_TEXT : null; - // Not when there is HTML: that is already the payload, and the plain text beside it is - // derived from the markup below rather than searched for among the other - // representations, which would put an unrelated one under the HTML. - if (plain == null && html == null) { - String[] advertised = content.getMimeTypes(); - for (int iter = 0; iter < advertised.length && plain == null; iter++) { - if (!advertised[iter].startsWith("text/")) { - // Text types only, however the value happens to be carried. A String under - // application/json -- or under an application's own type -- is that type's - // encoding and not a reading the source offered as text, and publishing it - // as the clip's text let a text-only application paste a representation - // nobody advertised to it. Nothing is lost by refusing: a String under a - // type that is not text travels as a typed content URI like any other - // representation, under its own name. The file list is covered by the same - // test, since that is not a text type either. - // - // The types getMimeTypes answers with are normalized to lower case, so this - // is an ASCII comparison against an ASCII constant and no locale enters it. - continue; - } - String value = assembly.text(advertised[iter]); - if (value != null) { - plain = value; - primaryTextMime = advertised[iter]; - } - } - } - // The types are recorded here, but the text does not become an item of its own yet. A - // clip item is a dragged *object*, so a text item beside a file item is two things - // being dragged at once, and a receiver that imports everything takes the document - // *and* a stray piece of text instead of choosing the best form of one thing. Where - // the clip carries a URI, the text rides on it -- see attachCarriedText below. - boolean carriesHtml = sdk >= 16 && html != null; - if (carriesHtml && plain == null) { - // Android *requires* it: ClipData.Item refuses HTML with no plain text beside it, - // and threw IllegalArgumentException out of the thread that was building the clip - // -- so content offering nothing but MIME_HTML crashed a copy and silently failed - // a drag. Rendered from the markup rather than being the markup, which would show - // every receiver the tags. - plain = htmlToPlainText(html); - } - if (carriesHtml) { - mimeTypes.add(ClipboardContent.MIME_TEXT); - mimeTypes.add(ClipboardContent.MIME_HTML); - } else if (plain != null) { - mimeTypes.add(ClipboardContent.MIME_TEXT); - if (primaryTextMime != null && !mimeTypes.contains(primaryTextMime)) { - mimeTypes.add(primaryTextMime); - } - } - // One pass at a time. Together under a single catch, a failure in the first abandoned - // the two after it as well, so a clip whose image could not be written went out - // without the document and the typed representations it also had. - try { - addBinaryContent(assembly, mimeTypes, items, clip); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - try { - addPublishedUris(assembly, mimeTypes, items, clip); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - try { - addRemainingRepresentations(assembly, plain, mimeTypes, items, clip); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - if (carriesHtml || plain != null) { - attachCarriedText(items, plain, carriesHtml ? html : null); - } - if (items.isEmpty()) { - // Nothing was produced. Every representation this content offered is a provider that - // answered null or threw, which ClipboardDataProvider explicitly permits -- so there - // is no clip, and the callers decide what that means. Answering with empty text - // instead replaced the payload with a different one: a drag offering only - // application/pdf reported success and let another application accept blank text. - return new AssembledClip(null, clip); - } - // Built from the union of the types, not by appending to a text clip. ClipData.addItem - // does not add the item's type to the description, so a clip assembled that way - // describes itself as text only -- and both a Codename One drop target filtering on - // MIME_FILE and an external receiver choosing a representation read the description. - ClipData data = new ClipData("Codename One", - mimeTypes.toArray(new String[mimeTypes.size()]), items.get(0)); - for (int iter = 1; iter < items.size(); iter++) { - data.addItem(items.get(iter)); - } - return new AssembledClip(data, clip); - } - - /// A clip and the assembly that built it. - /// - /// The id travels with the clip because that is the only way its caller can say which - /// assembly the clipboard or the drag now holds: a field read afterwards answers about - /// whichever assembly began most recently, and two of them can be in flight at once. - static final class AssembledClip { - /// The clip, or null when the content produced nothing that could be published. - private final ClipData data; - private final long clip; - - AssembledClip(ClipData data, long clip) { - this.data = data; - this.clip = clip; - } - - ClipData getData() { - return data; - } - - long getClip() { - return clip; - } - } - - // ------------------------------------------------------------------------------------ - // Native drag and drop. See AndroidNativeDragAndDrop; the payload is the same ClipData a - // copy publishes, which is why a drag out of the application lands in another application - // exactly as a paste would. - // ------------------------------------------------------------------------------------ - - @Override - public boolean isNativeDragAndDropSupported() { - return AndroidNativeDragAndDrop.isSupported(); - } - - @Override - public boolean isNativeDragOutsideApplicationSupported() { - return AndroidNativeDragAndDrop.isOutsideApplicationSupported(); - } - - @Override - public boolean startNativeDrag(com.codename1.ui.NativeDragOperation op) { - return AndroidNativeDragAndDrop.startDrag(this, op); - } - - @Override - public void cancelNativeDrag() { - AndroidNativeDragAndDrop.cancelDrag(); - } - - /** - * Collects the image bytes and file references carried by the ClipboardContent as items and - * MIME types, exposing binary content as FileProvider content:// URIs. The caller assembles - * the ClipData from the union of everything collected here and the text types, because - * ClipData.addItem cannot widen a description that already exists. - */ - private void addBinaryContent(Assembly assembly, List mimeTypes, - List items, long clip) throws IOException { - String authority = getContext().getPackageName() + ".provider"; - - // The files first, then the byte-backed representations. Android's ClipData.Item holds - // exactly one Uri, so two representations that are both bytes cannot be one item -- the - // platform has no way to say "another reading of the same object" for them, only for - // the text and markup that attachCarriedText rides on the item below. Publishing them - // is still right: they are what the description advertises, and dropping them would - // refuse the very target that accepted the hover on one. What order fixes is which - // object a receiver reading only the first item takes -- the document, not its - // thumbnail. - // - // It is also what puts the carried text on the document rather than on the thumbnail. - - // File references: MIME_FILE may be a single String or a String[] - Object fileData = assembly.value(ClipboardContent.MIME_FILE); - if (fileData != null) { - String[] paths; - if (fileData instanceof String[]) { - paths = (String[]) fileData; - } else { - paths = new String[]{ fileData.toString() }; - } - for (int i = 0; i < paths.length; i++) { - String pathOrUri = paths[i]; - if (pathOrUri == null || pathOrUri.length() == 0) { - continue; - } - // Each file on its own. A path outside the roots the file provider was - // configured with throws, and one throwing on the second of three used to - // abandon the third as well *and* skip every representation after the file - // loop -- so the clip went out holding one file, silently, and the drag - // reported success. - try { - Uri u; - if (hasScheme(pathOrUri, "content:")) { - u = Uri.parse(pathOrUri); - } else { - File file = hasScheme(pathOrUri, "file:") - ? new File(Uri.parse(pathOrUri).getPath()) - : new File(pathOrUri); - u = shareableUriFor(file, authority, clip); - } - if (!mimeTypes.contains("text/uri-list")) { - mimeTypes.add("text/uri-list"); - } - // And whatever the document actually is. A receiver in another application - // reads the description and nothing else while the drag hovers, so a PDF - // dragged out of here described only as a URI list was refused by every - // target that filters on application/pdf -- the type was there for the - // asking on the URI, and only this side can ask it in time. The alias the - // hover adds locally cannot help them; it never leaves this process. - // - // Only a type the resolver actually knows. octet-stream is what a provider - // answers when it has nothing to say, and advertising that would tell a - // receiver the clip holds a type it cannot use. - String resolved = bareMimeType( - getContext().getContentResolver().getType(u)); - if (resolved != null && resolved.length() > 0 - && !"application/octet-stream".equals(resolved) - && !mimeTypes.contains(resolved)) { - mimeTypes.add(resolved); - } - items.add(new ClipData.Item(u)); - } catch (Throwable t) { - // Absent rather than advertised: nothing named it a type of its own, so - // no receiver is told the clip holds a file it does not. - com.codename1.io.Log.e(t); - } - } - } - - // Image bytes: prefer PNG, then JPEG, then GIF - String imageMime = null; - byte[] imageBytes = null; - String imageExt = null; - imageBytes = assembly.bytes(ClipboardContent.MIME_PNG); - if (imageBytes != null) { - imageMime = ClipboardContent.MIME_PNG; - imageExt = "png"; - } else { - imageBytes = assembly.bytes(ClipboardContent.MIME_JPEG); - if (imageBytes != null) { - imageMime = ClipboardContent.MIME_JPEG; - imageExt = "jpg"; - } else { - imageBytes = assembly.bytes(ClipboardContent.MIME_GIF); - if (imageBytes != null) { - imageMime = ClipboardContent.MIME_GIF; - imageExt = "gif"; - } - } - } - if (imageBytes != null) { - try { - Uri imageUri = writeAsProviderUri(imageBytes, imageExt, imageMime, clip); - if (imageUri != null) { - if (!mimeTypes.contains(imageMime)) { - mimeTypes.add(imageMime); - } - items.add(new ClipData.Item(imageUri)); - } - } catch (Throwable t) { - // On its own, so a picture that cannot be written does not take the files - // and the other representations with it. - com.codename1.io.Log.e(t); - } - } - } - - /// The text of an HTML fragment, for the plain text Android requires beside it. - /// - /// Empty rather than null when the markup renders to nothing: an item may carry empty text - /// with its HTML, and may not carry none. - private static String htmlToPlainText(String html) { - try { - CharSequence text = android.os.Build.VERSION.SDK_INT >= 24 - ? android.text.Html.fromHtml(html, android.text.Html.FROM_HTML_MODE_LEGACY) - : android.text.Html.fromHtml(html); - return text == null ? "" : text.toString(); - } catch (Throwable t) { - // Markup this platform will not parse still has to travel; the HTML is the payload - // and the text beside it is what Android asks for, not what the clip is for. - com.codename1.io.Log.e(t); - return ""; - } - } - - /// Puts the URIs a text/uri-list names on the clip as URIs. - /// - /// A URI is what an Android receiver reads off `ClipData.Item#getUri()`, and a link has - /// nothing else to be read off. Left to the passes around this one a uri-list became - /// carried text, or -- where the clip had text already -- a content URI holding the list - /// as a document; either way a receiver that took the clip because it advertised - /// text/uri-list found no URI on it at all. - /// - /// One item per URI, because an item is a dragged object and a list of three links is - /// three of them. The clip's text still rides on the first, as it does on a file. - private void addPublishedUris(Assembly assembly, List mimeTypes, - List items, long clip) { - String list = assembly.text(ClipboardContent.MIME_URI_LIST); - if (list == null) { - return; - } - // The files the source published, which the clip is already carrying: each went onto - // it as a content URI this application minted, so the list's own spelling of the same - // document -- a path, or a file: URI of it -- would drag that document a second time. - // - // Compared against those paths rather than against the minted URIs, which are not - // equal to anything the source wrote. Entry by entry, too: returning on the first file - // threw away every *other* line, so a document published beside its own web address - // advertised text/uri-list and delivered the document alone. - List alreadyCarried = new ArrayList(); - Object files = assembly.value(ClipboardContent.MIME_FILE); - if (files instanceof String[]) { - String[] paths = (String[]) files; - for (int iter = 0; iter < paths.length; iter++) { - if (paths[iter] != null) { - alreadyCarried.add(publishedUriKey(paths[iter])); - } - } - } else if (files instanceof String) { - alreadyCarried.add(publishedUriKey((String) files)); - } - boolean carriesPublishedFile = false; - for (int iter = 0; iter < items.size(); iter++) { - Uri carried = items.get(iter).getUri(); - // A *generated* URI is not one of the source's. It carries a representation's - // bytes -- an image, a document this application encoded -- and a reader filters - // it out precisely because the source never published it as a URI. - if (carried != null && !isGeneratedClipFile(carried)) { - carriesPublishedFile = true; - break; - } - } - boolean any = false; - String[] lines = list.split("\n"); - for (int iter = 0; iter < lines.length; iter++) { - String line = lines[iter].trim(); - // RFC 2483: a line opening with a hash is a comment, not a URI. - if (line.length() == 0 || line.charAt(0) == '#') { - continue; - } - if (alreadyCarried.contains(publishedUriKey(line))) { - continue; - } - Uri published = publishableUri(line, clip); - if (published == null) { - continue; - } - items.add(new ClipData.Item(published)); - any = true; - } - // Declared when the clip can produce one: the entries just added, the published files - // a reader builds the list back out of, or both. - if (any || carriesPublishedFile) { - declareUriList(mimeTypes); - } - } - - /// One entry of a URI list, in a form the clip may leave this process with, or null when - /// it cannot be published at all. - /// - /// A file: URI is the case that needs the work. Android refuses to let a clip carrying one - /// cross the application boundary -- prepareToLeaveProcess throws FileUriExposedException - /// from API 24 -- so a copy of a list naming a local document threw out of the UI thread it - /// was made on, and a global drag of one never started. It goes through the file provider - /// exactly as the file representation does, which is also what makes it *readable* by the - /// receiver rather than merely legal. - /// - /// Anything else -- an http address, a mailto:, another application's content URI -- is - /// already publishable and travels as it was written. - private Uri publishableUri(String line, long clip) { - if (!hasScheme(line, "file:")) { - return Uri.parse(line); - } - String path = Uri.parse(line).getPath(); - if (path == null || path.length() == 0) { - return null; - } - try { - return shareableUriFor(new File(path), - getContext().getPackageName() + ".provider", clip); - } catch (Throwable t) { - // Absent rather than advertised, as the file representation does it: a document - // outside the roots the provider was configured with cannot be handed over, and - // naming it anyway tells the receiver the clip holds something it will not get. - com.codename1.io.Log.e(t); - return null; - } - } - - /// What two spellings of one file have in common. - /// - /// ClipboardContent's file representation permits a raw path, and a URI list beside it - /// commonly names the same document as a file: URI -- percent encoded, as a URI is. They - /// are one document, and putting both on the clip drags it twice. - private static String publishedUriKey(String value) { - if (hasScheme(value, "file:")) { - String path = Uri.parse(value).getPath(); - return path == null ? value : path; - } - return value; - } - - private static void declareUriList(List mimeTypes) { - if (!mimeTypes.contains(ClipboardContent.MIME_URI_LIST)) { - mimeTypes.add(ClipboardContent.MIME_URI_LIST); - } - } - - /// Puts the clip's text on the first item that carries a URI, or makes an item of it when - /// there is none. - /// - /// Android has no notion of "an alternative reading of this object": every item is another - /// thing being dragged. A file and its text fallback therefore have to be one item, or a - /// receiver importing the clip gets two objects where the source published one. The same - /// mistake on the iOS side made a receiver import a document and a stray piece of text. - private static void attachCarriedText(List items, String plain, String html) { - for (int iter = 0; iter < items.size(); iter++) { - Uri uri = items.get(iter).getUri(); - if (uri != null) { - items.set(iter, html != null - ? new ClipData.Item(plain, html, null, uri) - : new ClipData.Item(plain, null, uri)); - return; - } - } - // Nothing to ride on, so the text is the object. First, as it was before there was - // anything else in the clip at all. - items.add(0, html != null ? new ClipData.Item(plain, html) : new ClipData.Item(plain)); - } - - /// Adds the representations neither the text nor the binary pass above has taken. - /// - /// Byte-backed types -- a PDF, an archive, an application's own format -- become typed - /// content URIs, which is the only labelled way an Android clip carries bytes. Text types - /// are advertised only when their value *is* the text the clip already carries: a clip has - /// one text payload, so advertising a second, different reading of it would tell a receiver - /// the clip holds something it cannot then produce, and a Codename One target would accept - /// the hover and be refused at the drop. - private void addRemainingRepresentations(Assembly assembly, String carriedText, - List mimeTypes, List items, long clip) throws IOException { - String[] advertised = assembly.content().getMimeTypes(); - for (int iter = 0; iter < advertised.length; iter++) { - String mime = advertised[iter]; - if (mimeTypes.contains(mime) || ClipboardContent.MIME_FILE.equals(mime)) { - continue; - } - // Each representation on its own: a provider that throws is one type absent, not - // every type after it. ClipboardDataProvider permits it to fail. - Object value = assembly.value(mime); - byte[] bytes = null; - if (value instanceof String) { - if (carriedText != null && carriedText.equals(value)) { - // The same text the clip already carries, so naming the type is enough. - mimeTypes.add(mime); - continue; - } - // A *different* reading -- Markdown source beside its plain rendering, say. - // A clip carries one text payload, so this one travels as a typed content URI - // the way binary does. Dropping it instead, which is what this did, lost a - // representation the application deliberately published. - bytes = ((String) value).getBytes("UTF-8"); - } else if (value instanceof byte[]) { - bytes = (byte[]) value; - } - if (bytes != null) { - try { - Uri uri = writeAsProviderUri(bytes, extensionForMime(mime), mime, clip); - if (uri != null) { - mimeTypes.add(mime); - items.add(new ClipData.Item(uri)); - } - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - } - } - } - - /// A content URI another application can read for this file. - /// - /// The file provider is configured with a fixed set of roots -- the application's files - /// directory and cache/intent_files -- and getUriForFile throws for anything outside them. - /// Plenty of perfectly good paths are outside them: FileSystemStorage lists external - /// storage roots, and a file there used to throw, be logged, and be left out of the clip - /// entirely -- taking the whole drag with it when it was the only thing being dragged. - /// - /// So it is copied where the provider can reach, under its own name, which is what a - /// receiver sees. Not through writeAsProviderUri: that names and records what it mints as - /// transport for a representation's bytes, and this is a file the source published. - private static final long MAX_STAGED_SHARE_BYTES = 8L * 1024 * 1024; - private static final String SHARED_COPY_PREFIX = "cn1-shared-"; - - private Uri shareableUriFor(File file, String authority, long clip) throws IOException { - try { - Uri direct = FileProvider.getUriForFile(getContext(), authority, file); - getContext().grantUriPermission("android", direct, - Intent.FLAG_GRANT_READ_URI_PERMISSION); - return direct; - } catch (Throwable outsideTheRoots) { - com.codename1.io.Log.e(outsideTheRoots); - } - // The copy runs on the thread that started the drag, which is the event dispatch - // thread, and a drag has to begin while the finger is still down -- so this cannot be - // moved off it and cannot be allowed to take long. Android stops waiting for input after - // five seconds; a few megabytes is far below that on any storage, and a file bigger than - // this has no business being copied at all. It belongs under a provider root, which is - // where the roots above now put the external storage such files actually live on. - if (file.length() > MAX_STAGED_SHARE_BYTES) { - throw new IOException("refusing to copy " + file.length() + " bytes on the event " - + "dispatch thread to share " + file); - } - File dir = new File(getContext().getCacheDir(), "intent_files"); - dir.mkdirs(); - // Its own directory, so the copy keeps the original name without colliding with - // another file of the same name in the same drag. - File holder = File.createTempFile(SHARED_COPY_PREFIX, "", dir); - if (!holder.delete() || !holder.mkdirs()) { - throw new IOException("could not stage " + file + " for sharing"); - } - File copy = new File(holder, file.getName()); - boolean registered = false; - try { - InputStream in = new FileInputStream(file); - try { - OutputStream os = new FileOutputStream(copy); - try { - byte[] buffer = new byte[8192]; - int read; - while ((read = in.read(buffer)) > 0) { - os.write(buffer, 0, read); - } - } finally { - os.close(); - } - } finally { - in.close(); - } - Uri shared = FileProvider.getUriForFile(getContext(), authority, copy); - getContext().grantUriPermission("android", shared, - Intent.FLAG_GRANT_READ_URI_PERMISSION); - // Remembered so it is cleaned up, but not as transport: this is a file the source - // published, and it has to read back as one. - rememberStagedClipFile(shared, copy, false, clip); - registered = true; - return shared; - } finally { - if (!registered) { - // A source that vanished, a read that failed, a disk that filled: the holder - // and whatever was written into it exist by now, and nothing has registered - // them for reclamation -- so every failed export left its partial copy in the - // cache for good. - // - // Registration, not the copy, is what ends the window. Naming the file to the - // provider can fail on its own -- a path the manifest's roots do not cover is - // refused there and nowhere else -- and with the flag set at the end of the - // copy, that failure leaked exactly what this was written to prevent. - copy.delete(); - holder.delete(); - } - } - } - - /// One clip assembly's reading of a content, kept to itself. - /// - /// A representation registered as a provider is resolved once per transfer, and the memory - /// of that lives on the ClipboardContent -- which is fine for a transfer that owns it and - /// wrong for two that overlap. A copy assembles on Android's UI thread and a drag on the - /// event dispatch thread, so one could reset the shared memo halfway through the other and - /// hand it a value produced for a different transfer: a clip built from two generations of - /// a payload that changes. - /// - /// So an assembly reads through this instead. The provider is asked at most once per type - /// *per assembly*, which is what the promise actually is, and neither assembly can disturb - /// the other because neither touches the content's own memory. - private static final class Assembly { - private final ClipboardContent content; - private final Map produced = new HashMap(); - - Assembly(ClipboardContent content) { - this.content = content; - } - - ClipboardContent content() { - return content; - } - - Object value(String mimeType) { - if (content == null || mimeType == null) { - return null; - } - if (produced.containsKey(mimeType)) { - return produced.get(mimeType); - } - Object value = null; - try { - value = com.codename1.ui.NativeDragAndDrop.produceTransferValue(content, mimeType); - } catch (Throwable err) { - // A provider that fails is one type absent, not a clip abandoned -- and the - // failure is remembered like any other answer, so a second read of the same - // type does not run it again. Same rule as clipboardValue. - com.codename1.io.Log.e(err); - } - produced.put(mimeType, value); - return value; - } - - String text(String mimeType) { - Object value = value(mimeType); - return value instanceof String ? (String) value : null; - } - - byte[] bytes(String mimeType) { - Object value = value(mimeType); - return value instanceof byte[] ? (byte[]) value : null; - } - } - - /// Writes bytes somewhere the application's file provider can serve them from and returns - /// the content URI, which is how an Android clip carries anything that is not text. - /// - /// AndroidGradleBuilder exposes cache/intent_files through the app's FileProvider, so - /// generated payloads stay inside that root and FileProvider can safely name them. - /// - /// The name carries `mime` so the read back is an answer rather than a guess -- see - /// `#decodeMimeFromFileName(java.lang.String)`. - private Uri writeAsProviderUri(byte[] bytes, String extension, String mime, long clip) - throws IOException { - if (bytes == null) { - return null; - } - // A zero length payload is still a payload: refusing it would leave the clip without a - // type it had advertised, and a target filtering on that type would accept the hover - // and be refused the drop. - File dir = new File(getContext().getCacheDir(), "intent_files"); - dir.mkdirs(); - // A name built from the clock and the payload's length collided: two representations of - // one payload that share an extension and a byte length are written within the same - // millisecond, and the second overwrote the first -- leaving both clip items pointing at - // the second one's bytes. createTempFile is the guarantee rather than a longer guess. - String encoded = encodeMimeForFileName(mime); - File file = File.createTempFile( - encoded == null ? CLIP_FILE_PREFIX : CLIP_FILE_PREFIX + encoded + "-", - "." + extension, dir); - boolean registered = false; - try { - OutputStream os = new FileOutputStream(file); - try { - os.write(bytes); - } finally { - os.close(); - } - Uri uri = FileProvider.getUriForFile(getContext(), - getContext().getPackageName() + ".provider", file); - // Grant broadly so any paste or drop target can read the content:// URI - getContext().grantUriPermission("android", uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); - rememberStagedClipFile(uri, file, true, clip); - registered = true; - return uri; - } finally { - if (!registered) { - // The file exists from createTempFile onwards, and reclamation only ever sees - // what was registered -- so a cache that fills mid-write, or a provider that - // refuses to name the file, left a partial cn1-clip- file behind that nothing - // would ever collect. The same window the published-file copy above closes. - file.delete(); - } - } - } - - /// The name every generated clip file starts with, and the alphabet - /// `#encodeMimeForFileName(java.lang.String)` writes the type in. - private static final String CLIP_FILE_PREFIX = "cn1-clip-"; - private static final String CLIP_MIME_HEX = "0123456789abcdef"; - - /// Writes a MIME type into something that is legal in a file name and reads back as itself. - /// - /// The extension cannot do this job. It is derived from the type and the derivation is - /// lossy -- `application/x-foo` and `application/x-foo+json` both reduce to `xfoo` -- so two - /// representations of one payload can produce URIs no reader can tell apart, and both are - /// then dropped rather than mispaired. Hex is unlovely for a file name nobody reads, and it - /// is exact: every byte of the type survives, and no character it produces means anything to - /// a file system, a URI or `#decodeMimeFromFileName(java.lang.String)`. - /// - /// Answers null for a type this cannot carry, and the file is then named without one. - private static String encodeMimeForFileName(String mime) { - if (mime == null || mime.length() == 0 || mime.length() > 60) { - return null; - } - StringBuilder out = new StringBuilder(); - for (int iter = 0; iter < mime.length(); iter++) { - int c = mime.charAt(iter); - if (c > 0xff) { - return null; - } - out.append(CLIP_MIME_HEX.charAt((c >> 4) & 0xf)).append(CLIP_MIME_HEX.charAt(c & 0xf)); - } - return out.toString(); - } - - /// The MIME type `#encodeMimeForFileName(java.lang.String)` wrote into this name, or null - /// when the name did not come from there -- a clip another application published, or one - /// whose type was too long to carry. - private static String decodeMimeFromFileName(String name) { - if (name == null || !name.startsWith(CLIP_FILE_PREFIX)) { - return null; - } - int end = name.indexOf('-', CLIP_FILE_PREFIX.length()); - if (end < 0) { - return null; - } - String hex = name.substring(CLIP_FILE_PREFIX.length(), end); - if (hex.length() == 0 || (hex.length() & 1) != 0) { - return null; - } - StringBuilder out = new StringBuilder(); - for (int iter = 0; iter < hex.length(); iter += 2) { - int hi = Character.digit(hex.charAt(iter), 16); - int lo = Character.digit(hex.charAt(iter + 1), 16); - if (hi < 0 || lo < 0) { - return null; - } - out.append((char) ((hi << 4) | lo)); - } - return asciiLower(out.toString()); - } - - /// A file extension for a MIME type, used to name the temporary file a content URI is - /// served from. - /// - /// Android's own table first, because a FileProvider derives the URI's type from the - /// extension: a synthesized one it does not recognize makes ContentResolver.getType answer - /// application/octet-stream, and the type the clip advertised is then unrecoverable when - /// the clip is read back. - private static String extensionForMime(String mime) { - try { - String known = android.webkit.MimeTypeMap.getSingleton().getExtensionFromMimeType(mime); - if (known != null && known.length() > 0) { - return known; - } - } catch (Throwable t) { - // Fall through to the synthesized extension below. - } - int slash = mime.indexOf('/'); - String sub = slash < 0 ? mime : mime.substring(slash + 1); - int plus = sub.indexOf('+'); - if (plus > 0) { - sub = sub.substring(0, plus); - } - StringBuilder out = new StringBuilder(); - for (int iter = 0; iter < sub.length(); iter++) { - char c = sub.charAt(iter); - if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) { - out.append(c); - } - } - return out.length() == 0 ? "bin" : out.toString(); - } - - /// The MIME type to file an incoming image's bytes under: the framework's constant for the - /// three formats it names, and the type the content resolver reported for anything else. - /// - /// `#mimeForImageType(java.lang.String)` answers PNG for everything it does not recognize, - /// which for a WebP meant filing WebP bytes as a PNG -- undecodable by anything that - /// believed the label, and invisible to a target filtering on the type the drag advertised, - /// so the hover was accepted and the drop refused. - private static String imageMimeFor(String type) { - String lower = asciiLower(type); - if (lower.startsWith(ClipboardContent.MIME_PNG) - || lower.startsWith(ClipboardContent.MIME_JPEG) - || lower.startsWith(ClipboardContent.MIME_GIF)) { - return mimeForImageType(lower); - } - return lower; - } - - /** - * Maps a content resolver image MIME type to the corresponding ClipboardContent MIME constant, - * defaulting to PNG for unrecognized image types. - */ - private static String mimeForImageType(String type) { - if (type == null) { - return ClipboardContent.MIME_PNG; - } - if (type.startsWith(ClipboardContent.MIME_JPEG)) { - return ClipboardContent.MIME_JPEG; - } - if (type.startsWith(ClipboardContent.MIME_GIF)) { - return ClipboardContent.MIME_GIF; - } - return ClipboardContent.MIME_PNG; - } - - /** - * @inheritDoc - */ - public Object getPasteDataFromClipboard() { - if (getContext() == null) { - return null; - } - final Object[] response = new Object[1]; - runOnUiThreadAndBlock(new Runnable() { - @Override - public void run() { - int sdk = android.os.Build.VERSION.SDK_INT; - if (sdk < 11) { - android.text.ClipboardManager clipboard = (android.text.ClipboardManager) getActivity().getSystemService(Context.CLIPBOARD_SERVICE); - response[0] = clipboard.getText().toString(); - } else { - android.content.ClipboardManager clipboard = (android.content.ClipboardManager) getActivity().getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clip = clipboard.getPrimaryClip(); - if (clip == null || clip.getItemCount() == 0) { - return; - } - // With the description, exactly as a drop is read. Without it the only - // types a paste could report were the ones an item produced by itself, - // so another application's text published under a type of its own -- - // text/markdown, an application's own format -- arrived as nothing but - // text/plain and the type it was published under was gone. - ClipboardContent content = contentFromClip(clip, clip.getDescription()); - String plain = content.getText(ClipboardContent.MIME_TEXT); - // What the clip actually holds, not how many types it happens to name. - // Counting worked only because every clip used to acquire a text/plain of - // its own, empty or not: with that padding gone an image-only clip counted - // as one type, fell through to the plain-text answer, and a paste that had - // a perfectly good PNG in it returned null. - String[] types = content.getMimeTypes(); - boolean textOnly = types.length == 0 - || (types.length == 1 && ClipboardContent.MIME_TEXT.equals(types[0])); - if (!textOnly) { - response[0] = content; - } else { - response[0] = plain != null && plain.length() > 0 ? plain : null; - } - } - } - }); - return response[0]; - } - - /// Reads an Android `android.content.ClipData` into the framework's `ClipboardContent`. - /// - /// Shared by paste and by a native drop, because Android describes both the same way: a - /// list of items that are each text, HTML or a URI, and a URI is either an image to be read - /// or a file reference to be passed along. The plain text representation is always present, - /// even when empty, so a caller can tell "nothing but text" from "something richer" by the - /// number of MIME types. - /// - /// #### Parameters - /// - /// - `clip`: the clip data, which may be null - /// - /// #### Returns - /// - /// the content, never null - ClipboardContent contentFromClip(ClipData clip) { - return contentFromClip(clip, clip == null ? null : clip.getDescription()); - } - - /// Reads a clip, and where a description is given also honours the MIME types it - /// advertises. - /// - /// A drag is filtered twice: once against the description while it hovers, and again - /// against the materialized content when it is dropped. If the second view is narrower than - /// the first, a target accepts the hover and is then refused the drop -- which is what - /// happened to a component filtering on `ClipboardContent#MIME_URI_LIST`, because a URI - /// item materializes as `MIME_FILE` alone. Nothing is invented here: an advertised type is - /// only filled from a value the clip actually produced. - /// - /// A paste is read the same way, from the primary clip's own description. It used to pass - /// none, on the reasoning that a paste should report only what the clip produced -- but - /// the description *is* what the clip says it holds, and without it a type another - /// application published its text under was simply lost. What is filled from it is still - /// only ever a value the clip produced. - /// - /// #### Parameters - /// - /// - `clip`: the clip data, which may be null - /// - /// - `description`: what the source advertised, or null to report only what was read -- - /// which no caller does any more, though a port that has no description to offer - /// still may - /// - /// #### Returns - /// - /// the content, never null - ClipboardContent contentFromClip(ClipData clip, ClipDescription description) { - ClipboardContent content = new ClipboardContent(); - if (clip == null) { - content.setData(ClipboardContent.MIME_TEXT, ""); - return content; - } - int sdk = android.os.Build.VERSION.SDK_INT; - String plain = null; - String html = null; - List fileUris = new ArrayList(); - // Every URI the clip carried that the source published, files or not. A link dragged out - // of a browser belongs here and not in fileUris: it is a URI, and it is not a document on - // disk. The two lists differ only by that, and by the transport URIs this exporter mints, - // which are in neither because the source never published them as URIs at all. - List publishedUris = new ArrayList(); - // URIs the content resolver could not name. An application defined type has no entry in - // Android's table, so a FileProvider serving it reports octet-stream or nothing at all. - List unnamedUris = new ArrayList(); - for (int i = 0; i < clip.getItemCount(); i++) { - ClipData.Item item = clip.getItemAt(i); - try { - Uri uri = item.getUri(); - if (uri != null) { - // Without the parameters, because a bare MIME type is what everything here - // compares against: a provider answering "text/plain; charset=utf-8" would - // file the document under a type no target asks for, and would slip past - // the MIME_TEXT check below that stops the synthesized empty text from - // overwriting it. - String type = bareMimeType(getContext().getContentResolver().getType(uri)); - if (type != null && type.startsWith("image/")) { - // Promised, not read. Reading it here opened the URI and pulled the - // whole image across on Android's own UI thread, before the drop was - // even queued -- so a photo dropped on a target that wanted nothing - // but getFiles() stalled the application, or ran it out of memory, - // for bytes nobody asked for. The same promise the typed branch below - // makes, and safe for the same reason: the grant this drop was given - // lasts as long as the activity, so a read a moment later on the - // event dispatch thread still succeeds. See uriBytesProvider. - String imageMime = imageMimeFor(type); - if (!content.hasMimeType(imageMime)) { - content.setDataProvider(imageMime, uriBytesProvider(uri)); - } - } else if (type != null && type.length() > 0 - && !"application/octet-stream".equals(type)) { - // A typed URI is a file reference *and* that type. Reducing it to a file - // alone let a target filtering on, say, application/pdf accept the hover - // -- the description advertised the type -- and then be refused the - // drop, because the content it is filtered against a second time no - // longer had it. The bytes are promised rather than read: a target that - // only wants the path should not pay for a document it never opens. - if (!content.hasMimeType(type)) { - content.setDataProvider(type, uriBytesProvider(uri)); - } - } else { - unnamedUris.add(uri); - } - // A URI item is a file reference as well as whatever its type made of it -- - // unless it is one this exporter minted to carry bytes. The image branch - // used to return before reaching this at all, so dragging a PNG *file* - // produced image bytes and no file, and a target filtering on MIME_FILE - // accepted the hover -- the description still advertised text/uri-list -- - // and was refused the drop. Adding every URI unconditionally is the other - // error: a payload of nothing but application/pdf bytes travels as a - // content URI without text/uri-list ever being advertised, and calling that - // a file both invents a representation the source never published and lets - // a nested file-only target take a drop the PDF-capable one was chosen for - // while it hovered. - // - // The two are told apart by the exporter's own record of what it minted, - // not by anything about the URI or its name -- an application may publish a - // file called anything at all. - if (!isGeneratedClipFile(uri) && mayCarryAcrossApplications(uri)) { - publishedUris.add(uri.toString()); - if (namesALocalFile(uri)) { - fileUris.add(uri.toString()); - } - } - // No continue: an item carrying a URI carries the clip's text too, because - // that is where this exporter puts it -- a text item of its own would be a - // second object being dragged. Returning here dropped the fallback the - // source published on its own round trip. - } - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - if (html == null && sdk >= 16) { - // Empty markup is a value, not an absence: getHtmlText answers null when the - // item carries no HTML at all, so anything else is what the source published. - // Discarding it left fillAdvertisedTypes to rebuild the advertised text/html - // from the plain text, handing the target something the source never wrote -- - // and this exporter publishes exactly that item for content whose HTML is empty. - html = item.getHtmlText(); - } - if (plain == null) { - // What the item literally carries first, and empty counts: getText answers - // null when the item holds no text at all, so anything else is what the - // source published -- the same reading getHtmlText gets above. Discarding an - // empty one left an advertised text/markdown with nothing to restore it - // from, and a target that took the hover on that type was refused the drop. - CharSequence literal = item.getText(); - if (literal != null) { - plain = literal.toString(); - } else if (item.getUri() == null) { - // Nothing literal, so it is derived -- and only for an item with no URI. - // coerceToText on one of those goes and reads the document behind it, - // which is a different value altogether and none of this branch's - // business. An empty derivation means the item had nothing to give - // rather than that the source published nothing, so it does not stop - // the search. - CharSequence derived = item.coerceToText(getContext()); - if (derived != null && derived.length() > 0) { - plain = derived.toString(); - } - } - } - } - if (html != null) { - // A value the clip's own item published, so it wins over a URI the resolver happened - // to type text/html -- an .html file being dragged. Same rule as the text below, - // and the reason that one needs a guard and this one does not: there is no - // synthesized empty HTML to write over a representation that already answered. - content.setData(ClipboardContent.MIME_HTML, html); - } - if (!fileUris.isEmpty()) { - content.setFiles(fileUris.toArray(new String[fileUris.size()])); - } - // Not when the clip named exactly one type and it is not text/plain. That type is what - // the text *is*: another application publishing a direct item of its own format -- - // application/json, say -- carries the value as the item's text, because an Android - // item has nowhere else to put a string. Calling it text/plain lost the name the clip - // gave it, and a target filtered to that name accepted the hover and was refused the - // drop; fillAdvertisedTypes below hands the value to the type instead. - if (plain != null && soleAdvertisedType(description) == null) { - content.setData(ClipboardContent.MIME_TEXT, plain); - } else if (plain == null && !content.hasMimeType(ClipboardContent.MIME_TEXT) - && description != null && description.hasMimeType(ClipboardContent.MIME_TEXT)) { - // The clip promised text and no item produced it, so the empty string keeps that - // promise: a target that accepted the hover on text/plain would otherwise be - // refused the drop it was told it could have. Only then, though -- a clip that - // never mentioned text does not acquire it here. findTarget runs again against the - // materialized content, so inventing text/plain let a nested text-only component - // take a drop the type-capable ancestor had been chosen for while it hovered, and - // that component never saw an enter event at all. - // - // Nor over a representation that answered: a URI the resolver typed text/plain, - // which is what a dragged .txt is, has already registered the document's own - // contents, and writing over that handed the target an empty document. - content.setData(ClipboardContent.MIME_TEXT, ""); - } - if (description != null) { - fillAdvertisedTypes(content, description, plain, publishedUris, unnamedUris); - } else if (!publishedUris.isEmpty() && !content.hasMimeType(ClipboardContent.MIME_URI_LIST)) { - // A paste is told nothing about what the clip advertises, so what it reports can - // only come from what the clip carried -- and what this one carried is URIs. - // Another application copying a link publishes exactly that, one item with a URI - // and no text at all: nothing above it produces a representation, so without this - // the read answered with an empty content and the paste with null. - // - // Nothing is invented by it either. These are the URIs the clip itself carried, - // minus the ones this exporter minted as transport, which is what a URI list is. - content.setData(ClipboardContent.MIME_URI_LIST, uriListOf(publishedUris)); - } - return content; - } - - /// The content URIs this exporter minted to carry bytes, oldest first. - /// - /// Remembered, not recognized. The file name cannot answer the question: an application may - /// publish a file of its own by any name it likes, and one called cn1-clip-roundtrip.txt is - /// exactly what the clipboard round trip publishes -- which a prefix test then threw away - /// as one of ours, losing the file reference it had just copied. The type cannot answer it - /// either, since a PDF published as bytes and a PDF published as a file both arrive as - /// application/pdf. Only the exporter knows, so the exporter records it. - /// - /// Bounded: a clip that has been replaced on the clipboard can no longer be pasted, so the - /// oldest entries are of no further use. A clip that outlives the process falls back to - /// being read as a file, which is what it was read as before any of this existed. - /// It also names the file, because every one of these is a file this application wrote - /// into its own cache and nothing else will ever come back for it. A clip that has been - /// replaced cannot be pasted, so when one falls off the end its file goes with it -- - /// otherwise copying documents or images repeatedly leaves every one of them on disk for - /// the life of the installation. - /// - /// Kept by the clip rather than one file at a time. A single payload can stage more files - /// than any per-file bound, and counting them individually deleted the earliest ones while - /// clipDataFor was still building the very clip that referenced them -- so the clip went - /// out pointing at files that were already gone. Whole clips are what is forgotten, never - /// the one being assembled. - /// - /// Bounded by bytes rather than by a count of clips. A receiver may hold a content URI - /// this application handed it and read it much later -- a queued upload does exactly that, - /// and the grant stays valid -- so counting clips deleted a file somebody was still - /// entitled to as soon as eight more copies had been made, however small. What can - /// actually fill a device is bytes: a hundred staged text fragments cost nothing and all - /// survive, while a few videos are reclaimed as soon as they add up. - /// - /// There is no signal that says a receiver is finished with one, and inventing one would - /// be a new public API every application had to adopt to keep behaving as it does today. - /// The same reasoning, and the same budget, as the dropped copies on iOS. - private static final long GENERATED_CLIP_BUDGET = 64L * 1024 * 1024; - private static final java.util.LinkedHashMap STAGED_CLIP_FILES = - new java.util.LinkedHashMap(); - - /// One file staged for a clip: where it is, and whether it carries a representation's - /// bytes rather than being a file the source published. - private static final class StagedClipFile { - private final String path; - private final boolean transport; - private final long clip; - /// What it occupies, for the budget above. Taken when it is staged, because by the - /// time it is reclaimed the file may be gone and a size of zero would make a large - /// clip look free. - private final long bytes; - - StagedClipFile(String path, boolean transport, long clip, long bytes) { - this.path = path; - this.transport = transport; - this.clip = clip; - this.bytes = bytes; - } - } - - /// The clip being assembled. Incremented as each one starts, so everything staged for it - /// is recognisable as belonging together. - private static long stagingClip; - - /// The clip the system clipboard is holding, and the clip a running drag is carrying. - /// - /// Neither is superseded by anything newer, which is what a window of recent clips would - /// otherwise assume. A clipboard holds its clip until something replaces it, and every - /// drag in between advances the count -- so nine drags after a copy deleted the files the - /// clipboard was still pointing at, and the paste the user eventually made produced a - /// content URI nothing could read. - private static long clipboardClip; - private static long draggingClip; - - /// The assembly a publication in progress is about to put on the clipboard, exempt from - /// reclamation until the attempt is over. Nothing holds it yet -- the clipboard has not - /// taken it -- and without this the window between assembling a clip and the system - /// accepting it was one in which its own files could be deleted. - private static long publishingClip; - - /// Changes to the primary clip this application is about to make itself, which the watcher - /// below hears about like any other and must not read as somebody else's copy. - /// - /// A count rather than a flag: a copy can be made while an earlier one's callback is still - /// queued, and a flag cleared by the first would have made the second look foreign. - private static int expectedClipChanges; - - /// True once the primary clip watcher is installed, which happens the first time this - /// application puts anything on the clipboard. - private static boolean clipboardWatched; - - /// The assemblies that have begun and whose caller has not yet taken them over. - /// - /// An assembly is exempt from reclamation while it is being built -- its files are being - /// referenced by a clip that does not exist yet -- and stays exempt until whoever asked for - /// it has put it on the clipboard or handed it to a drag. Exempting only the clip currently - /// growing was not enough: a copy assembles on Android's UI thread while a drag assembles - /// on the event dispatch thread, so one could finish and be waiting for its caller to claim - /// it while the other's staging triggered a reclamation that deleted its files. The caller - /// then published, or dragged, a clip of dead URIs. - private static final java.util.Set ASSEMBLING_CLIPS = new java.util.HashSet(); - - private static long beginStagingClip() { - synchronized (STAGED_CLIP_FILES) { - long clip = ++stagingClip; - ASSEMBLING_CLIPS.add(Long.valueOf(clip)); - return clip; - } - } - - /// Ends an assembly's exemption, because its caller has taken it over -- or has given up on - /// it, which is the same thing as far as its files are concerned. - /// - /// #### Parameters - /// - /// - `clip`: the assembly, or zero when there was none - static void endStagingClip(long clip) { - if (clip == 0) { - return; - } - synchronized (STAGED_CLIP_FILES) { - ASSEMBLING_CLIPS.remove(Long.valueOf(clip)); - reclaimStagedClipFiles(); - } - } - - /// Starts listening for the primary clip being replaced, once. - /// - /// A clip this application published is exempt from reclamation for as long as the - /// clipboard holds it, and nothing but another copy of our own used to end that -- so a - /// copy made in *another* application left ours pinned for good, and an oversized one then - /// sat in the cache above the budget with nothing able to reclaim it. - /// - /// Called on the Android UI thread, from the copy that is about to pin something. - /// - /// Android only delivers these callbacks to an application that has focus, so a copy made - /// elsewhere while this one is in the background is still missed. That leaves the hold in - /// place until the next copy either application makes, which is the behaviour this - /// replaces rather than a new failure -- and the files are in the cache directory, which - /// the system reclaims under pressure whatever this bookkeeping believes. - private static void watchPrimaryClip(android.content.ClipboardManager clipboard) { - synchronized (STAGED_CLIP_FILES) { - if (clipboardWatched) { - return; - } - clipboardWatched = true; - } - try { - clipboard.addPrimaryClipChangedListener( - new android.content.ClipboardManager.OnPrimaryClipChangedListener() { - @Override - public void onPrimaryClipChanged() { - synchronized (STAGED_CLIP_FILES) { - if (expectedClipChanges > 0) { - // Our own copy, which has already said what it holds. - expectedClipChanges--; - return; - } - } - // A clip somebody else published replaced ours, so what ours was carrying - // is nobody's to paste any more. - clipboardHolds(0); - } - }); - } catch (Throwable t) { - // A device that will not register the listener keeps the old behaviour, which is - // a hold that outlives the clip rather than a crash on copy. - com.codename1.io.Log.e(t); - synchronized (STAGED_CLIP_FILES) { - clipboardWatched = false; - // Nothing will consume what was counted for the copy this call belongs to. - expectedClipChanges = 0; - } - } - } - - /// Records that this application is about to replace the primary clip, so the watcher does - /// not mistake its own callback for another application's copy, and pins what the clip is - /// about to carry for the length of the attempt. - /// - /// #### Parameters - /// - /// - `clip`: the assembly being published, or zero for a clip with nothing staged - private static void clipboardPublishing(long clip) { - synchronized (STAGED_CLIP_FILES) { - if (clipboardWatched) { - expectedClipChanges++; - } - // Only while something is listening. Counting a copy no callback will ever arrive - // for -- a device that refused the listener -- left the count standing, and if a - // later copy did install the watcher, that phantom swallowed the first genuinely - // foreign clipboard change: the clip stayed pinned and its files stayed out of - // reach of the budget. - publishingClip = clip; - } - } - - /// Ends a publication, either committing it or putting back what it had provisionally - /// taken. - /// - /// #### Parameters - /// - /// - `clip`: the assembly that was being published - /// - /// - `published`: true when setPrimaryClip returned - private static void clipboardPublished(long clip, boolean published) { - synchronized (STAGED_CLIP_FILES) { - publishingClip = 0; - if (!published && expectedClipChanges > 0) { - // No callback is coming for a clip that never reached the clipboard. - expectedClipChanges--; - } - } - if (published) { - // Now, and only now, is the clip the clipboard's -- which is also what stops the - // one it replaced from being pinned. - clipboardHolds(clip); - } - } - - /// Records which clip the system clipboard now holds, or zero for a clip with nothing - /// staged for it. - /// - /// Called for every clip put on the clipboard, plain text included: what matters as much - /// is that the clip it held *before* is not the clipboard's any more, so its files may go - /// when they age out. - static void clipboardHolds(long clip) { - synchronized (STAGED_CLIP_FILES) { - clipboardClip = clip; - // Letting go is as good a moment to reconsider as staging is: a clip that was - // over the budget on its own could not be reclaimed while it was held, and - // nothing else would have looked at it again until some later transfer staged - // a file -- which for an application that drags one large payload and then - // stops is never. - reclaimStagedClipFiles(); - } - } - - /// The clip a drag is carrying right now, so a release queued for one drag can tell - /// whether it is still the drag whose hold it is about to end. - static long draggingClip() { - synchronized (STAGED_CLIP_FILES) { - return draggingClip; - } - } - - /// Ends the hold on one drag's clip, and only that one. - /// - /// A drop's release is queued onto the event dispatch thread, and a callback that enters a - /// nested event loop can let another drag start before it runs. Clearing the shared slot - /// unconditionally then let go of the *new* drag's clip, whose files a cache over budget - /// could delete while the receiving application was still to read them. - /// - /// #### Parameters - /// - /// - `clip`: the clip whose drag has finished, or zero to release whatever is held - static void releaseDragHold(long clip) { - synchronized (STAGED_CLIP_FILES) { - if (clip != 0 && draggingClip != clip) { - return; - } - // Compared and cleared without letting go of the lock in between. A completion - // listener on the event dispatch thread can start the next drag at any moment, and - // it claims this slot: reading it, releasing the lock and then clearing it let go - // of a drag that had begun after the comparison said it was safe. The body is - // dragHolds(0) written out for that reason and nothing else. - draggingClip = 0; - reclaimStagedClipFiles(); - } - } - - /// Records the clip a drag is carrying, or zero once it has ended. - static void dragHolds(long clip) { - synchronized (STAGED_CLIP_FILES) { - draggingClip = clip; - reclaimStagedClipFiles(); - } - } - - private static void rememberStagedClipFile(Uri uri, File file, boolean transport, - long clip) { - synchronized (STAGED_CLIP_FILES) { - STAGED_CLIP_FILES.remove(uri.toString()); - STAGED_CLIP_FILES.put(uri.toString(), - new StagedClipFile(file.getAbsolutePath(), transport, clip, file.length())); - reclaimStagedClipFiles(); - } - } - - /// Reclaims staged files, oldest first, until what is left fits the budget. - /// - /// Never an assembly whose caller has yet to take it over -- it is still growing, or - /// waiting to be handed to a clipboard or a drag -- and never the one the clipboard, a - /// running drag or a publication in progress is carrying, none of which are superseded by - /// anything however old they are. Called when a file is staged and again when any of those - /// is released, because a clip too large for the budget on its own can only be reclaimed - /// once nothing holds it any more. - private static void reclaimStagedClipFiles() { - synchronized (STAGED_CLIP_FILES) { - long held = 0; - for (StagedClipFile staged : STAGED_CLIP_FILES.values()) { - held += staged.bytes; - } - java.util.Iterator> entries = - STAGED_CLIP_FILES.entrySet().iterator(); - while (held > GENERATED_CLIP_BUDGET && entries.hasNext()) { - StagedClipFile staged = entries.next().getValue(); - if (ASSEMBLING_CLIPS.contains(Long.valueOf(staged.clip)) - || staged.clip == clipboardClip || staged.clip == draggingClip - || staged.clip == publishingClip) { - continue; - } - held -= staged.bytes; - entries.remove(); - deleteStagedClipFile(staged); - } - } - } - - /// Removes a staged file, and the directory it was given to itself when it had one. - /// - /// Best effort by design: a file that will not delete is one the cache directory will - /// eventually reclaim, which is what a cache directory is for -- and is also what bounds - /// the files left behind by a process that ended before it could let go of them. - private static void deleteStagedClipFile(StagedClipFile staged) { - try { - File file = new File(staged.path); - File holder = file.getParentFile(); - if (file.delete() && holder != null - && holder.getName().startsWith(SHARED_COPY_PREFIX)) { - holder.delete(); - } - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - } - - /// True when this content URI is one `#writeAsProviderUri(byte[], java.lang.String, - /// java.lang.String)` minted to carry a representation's bytes, rather than a file the - /// source published. - private static boolean isGeneratedClipFile(Uri uri) { - synchronized (STAGED_CLIP_FILES) { - StagedClipFile staged = STAGED_CLIP_FILES.get(uri.toString()); - return staged != null && staged.transport; - } - } - - /// True when a URI another application put on a clip is one this application may carry. - /// - /// A file: URI, or a bare path, is not. Android has refused to let a clip carrying one - /// cross an application boundary since API 24 -- prepareToLeaveProcess throws for exactly - /// that -- so one arriving here was never published by a well behaved application, and it - /// comes with no grant that would make it readable in the first place. Taking it at its - /// word is worse than useless: the path is read with *this* application's permissions, and - /// republishing it -- a copy, a drag onward -- would hand somebody else a file the sender - /// could not open, named by the sender. A content: URI carries a grant and is the only - /// spelling a clip is entitled to use for a document; everything remote is carried as a - /// URI and never opened as a path. - /// - /// This is about what *arrives*. What the application itself publishes through - /// `ClipboardContent#setFiles(java.lang.String...)` is its own file and is unaffected. - private static boolean mayCarryAcrossApplications(Uri uri) { - String scheme = uri.getScheme(); - if (scheme == null) { - return false; - } - return !"file".equalsIgnoreCase(scheme); - } - - /// True when this URI names something on this device rather than somewhere on the web. - /// - /// A link dragged out of a browser arrives as a text/uri-list item whose URI is https, - /// and calling that a file handed a file-only target a URL through getFiles() as though - /// it were a document on disk. It is still carried, under MIME_URI_LIST, which is what - /// it actually is. - private static boolean namesALocalFile(Uri uri) { - String scheme = uri.getScheme(); - if (scheme == null) { - // A bare path, which is a local file by construction. - return true; - } - // equalsIgnoreCase rather than a fold: it compares character by character and is - // locale independent, which String.toLowerCase() is not. - return "content".equalsIgnoreCase(scheme) || "file".equalsIgnoreCase(scheme); - } - - /// Lowercases ASCII letters only, so the result never depends on the device locale. - /// - /// String.toLowerCase() is locale sensitive, and a Turkish or Azerbaijani default turns - /// I into a dotless i: IMAGE/PNG normalized under one of those locales stopped being - /// equal to image/png, so every check against the framework's own constants failed and - /// a port no longer recognized the representation at all. MIME types, schemes and file - /// extensions are ASCII by definition, which is what makes folding only ASCII correct - /// rather than merely safe. Codename One has no java.util.Locale to ask for the root - /// locale instead. - /// True when this value opens with that scheme, whatever case it was written in. - /// - /// A URI scheme is case insensitive by specification, and a case-sensitive prefix test - /// read FILE:///sdcard/report.pdf as a literal path -- a file that does not exist, so - /// the only representation a file-only clip had was quietly dropped. - /// - /// #### Parameters - /// - /// - `value`: the path or URI - /// - /// - `scheme`: the scheme to test for, colon included, in lower case - private static boolean hasScheme(String value, String scheme) { - return value.length() >= scheme.length() - && value.regionMatches(true, 0, scheme, 0, scheme.length()); - } - - static String asciiLower(String s) { - StringBuilder out = new StringBuilder(s.length()); - for (int iter = 0; iter < s.length(); iter++) { - char c = s.charAt(iter); - out.append(c >= 'A' && c <= 'Z' ? (char) (c + 32) : c); - } - return out.toString(); - } - - /// A MIME type without its parameters, lower case, or null when there is none. - private static String bareMimeType(String type) { - if (type == null) { - return null; - } - int semicolon = type.indexOf(';'); - String bare = asciiLower((semicolon < 0 ? type : type.substring(0, semicolon)).trim()); - return bare.length() == 0 ? null : bare; - } - - /// Reads a content URI's bytes when something actually asks for them. - /// - /// The drag-and-drop permission this drop was granted lasts for the life of the activity -- - /// nothing calls release() on it -- so a read that happens a moment later on the event - /// dispatch thread still succeeds. Once read the value is kept, so a target that reads - /// during the drop may hold the result for as long as it likes. - /// - /// What it does not survive is the activity: a representation *first* asked for after the - /// activity that received the drop has been destroyed reads through a grant that no - /// longer exists, and answers null. Copying every representation into this application's - /// own storage at drop time is the only way round that, and it is the wrong trade -- it - /// is the eager read that stalls the platform's thread with a document nobody asked for, - /// which is why this is a promise in the first place. Component.nativeDrop says so where - /// an application will read it. - private ClipboardDataProvider uriBytesProvider(final Uri uri) { - return new ClipboardDataProvider() { - @Override - public Object getClipboardData(String mimeType) { - try { - InputStream in = getContext().getContentResolver().openInputStream(uri); - if (in == null) { - return null; - } - byte[] bytes; - try { - bytes = Util.readInputStream(in); - } finally { - in.close(); - } - // A text type reads back as text: the framework's getText() answers null - // for a byte array, so a Markdown representation that went out as a typed - // URI would come back unreadable to the very API that asked for it. - if (bytes != null && mimeType != null && mimeType.startsWith("text/")) { - return new String(bytes, "UTF-8"); - } - return bytes; - } catch (Throwable t) { - com.codename1.io.Log.e(t); - return null; - } - } - }; - } - - /// The `text/uri-list` spelling of the URIs a clip carried: one per line, CRLF separated - /// as RFC 2483 has it. - private static String uriListOf(List uris) { - StringBuilder out = new StringBuilder(); - for (int iter = 0; iter < uris.size(); iter++) { - if (iter > 0) { - out.append("\r\n"); - } - out.append(uris.get(iter)); - } - return out.toString(); - } - - /// Fills the MIME types the drag advertised but the read did not produce, from what it did. - /// - /// An Android clip carries a single text payload and the description says what that text - /// is, so a type the description names and the clip did not otherwise yield is that text -- - /// `text/uri-list` excepted, which is the list of URIs the clip carried. A type with no - /// value to give it is left absent rather than advertised empty. - private void fillAdvertisedTypes(ClipboardContent content, ClipDescription description, - String plain, List publishedUris, List unnamedUris) { - List unsatisfiedBinary = new ArrayList(); - List unsatisfiedText = new ArrayList(); - for (int iter = 0; iter < description.getMimeTypeCount(); iter++) { - String mime = description.getMimeType(iter); - if (mime == null) { - continue; - } - mime = asciiLower(mime); - if (content.hasMimeType(mime)) { - continue; - } - if ("text/uri-list".equals(mime)) { - // Every URI, not only the ones that name files: a URI list is a URI list, and a - // link the source published belongs in it even though it is not a document. - if (!publishedUris.isEmpty()) { - content.setData(ClipboardContent.MIME_URI_LIST, uriListOf(publishedUris)); - } - continue; - } - // A text type is *not* assumed to be the carried text here. The exporter writes a - // text representation whose value differs from that text into a content URI exactly - // as it writes binary, so assuming made a target asking for an application's own - // text format receive the plain fallback instead of the value it published. - if (mime.startsWith("text/")) { - unsatisfiedText.add(mime); - } else { - unsatisfiedBinary.add(mime); - } - } - List unclaimed = new ArrayList(unnamedUris); - for (int iter = unclaimed.size() - 1; iter >= 0; iter--) { - Uri uri = unclaimed.get(iter); - String named = mimeForUnnamedUri(uri, unsatisfiedBinary, unsatisfiedText); - if (named != null) { - content.setDataProvider(named, uriBytesProvider(uri)); - unsatisfiedBinary.remove(named); - unsatisfiedText.remove(named); - unclaimed.remove(iter); - } - } - if (unclaimed.size() == 1) { - // One representation the clip promised and could not produce, and one URI whose - // type Android could not name: the pairing cannot be anything else. A byte backed - // type is taken first because bytes can only have come from a URI, where a text one - // may also be another reading of the text the clip carries. With more of either it - // could be, and inventing an association would tell a target it has something it - // may not -- which is the failure this whole path exists to avoid -- so those are - // left absent and the target correctly refuses. - String only = null; - if (unsatisfiedBinary.size() == 1) { - only = unsatisfiedBinary.remove(0); - } else if (unsatisfiedBinary.isEmpty() && unsatisfiedText.size() == 1) { - only = unsatisfiedText.remove(0); - } - if (only != null) { - content.setDataProvider(only, uriBytesProvider(unclaimed.get(0))); - } - } - if (plain != null) { - for (int iter = 0; iter < unsatisfiedText.size(); iter++) { - // What is left: an Android clip carries a single text payload, and a text type - // no URI accounted for is another name for that payload -- which is exactly how - // the exporter advertises a reading whose value *is* the carried text. - content.setData(unsatisfiedText.get(iter), plain); - } - if (unsatisfiedText.isEmpty() && unsatisfiedBinary.size() == 1 && unclaimed.isEmpty() - && !content.hasMimeType(ClipboardContent.MIME_TEXT)) { - // And a type that is not text, when it is the only thing left unaccounted for - // and the carried text was not published as text either -- which is the clip - // that named one format of its own and put the value in the item, and only - // that clip. The pairing cannot be anything else, the same reasoning the one - // unclaimed URI above is matched by. - content.setData(unsatisfiedBinary.get(0), plain); - } - } - } - - /// The one type a clip advertises when that is all it advertises and it is not plain - /// text, or null. - /// - /// A clip that names a single format of its own is the case where the item's text is that - /// format rather than a plain reading of it; anything advertising text/plain, or more than - /// one type, is read the way it always was. - private static String soleAdvertisedType(ClipDescription description) { - if (description == null || description.getMimeTypeCount() != 1) { - return null; - } - String mime = description.getMimeType(0); - if (mime == null) { - return null; - } - mime = asciiLower(mime); - return ClipboardContent.MIME_TEXT.equals(mime) ? null : mime; - } - - /// The type an untyped content URI was published as, recovered from the name of the file it - /// serves. - /// - /// ContentResolver could not name it -- MimeTypeMap has no entry for an application defined - /// type, so the FileProvider serving it reports octet-stream. What this application wrote - /// still says so in its own name, exactly, which is the answer; a clip from elsewhere gets - /// the extension read as a type, which is a good guess and is treated as one -- an extension - /// two advertised types share answers nothing. - private String mimeForUnnamedUri(Uri uri, List binary, List text) { - String name = displayNameFor(uri); - if (name == null) { - return null; - } - String declared = decodeMimeFromFileName(name); - if (declared != null) { - // Written by this application, which named the type outright. It answers even when - // it names a type that is not among the candidates -- that means the type is already - // satisfied, or was never advertised, and either way this URI is not the missing - // one. Guessing past an exact answer would be strictly worse. - return binary.contains(declared) || text.contains(declared) ? declared : null; - } - int dot = name.lastIndexOf('.'); - if (dot < 0 || dot == name.length() - 1) { - return null; - } - String extension = asciiLower(name.substring(dot + 1)); - String match = null; - for (int pass = 0; pass < 2; pass++) { - List candidates = pass == 0 ? binary : text; - for (int iter = 0; iter < candidates.size(); iter++) { - String candidate = candidates.get(iter); - if (extension.equals(extensionForMime(candidate))) { - if (match != null) { - return null; - } - match = candidate; - } - } - } - return match; - } - - /// The file name behind a content URI, which is where the extension an exporter chose - /// survives. A provider that will not answer OpenableColumns still has the name in its path. - private String displayNameFor(Uri uri) { - Cursor cursor = null; - try { - cursor = getContext().getContentResolver().query(uri, - new String[]{android.provider.OpenableColumns.DISPLAY_NAME}, - null, null, null); - if (cursor != null && cursor.moveToFirst()) { - int column = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME); - if (column >= 0) { - String name = cursor.getString(column); - if (name != null && name.length() > 0) { - return name; - } - } - } - } catch (Throwable t) { - // Fall through to the path below. - } finally { - if (cursor != null) { - cursor.close(); - } - } - return uri.getLastPathSegment(); - } - - public static MediaException createMediaException(int extra) { - MediaErrorType type; - String message; - switch (extra) { - - case MediaPlayer.MEDIA_ERROR_IO: - type = MediaErrorType.Network; - message = "IO error"; - break; - case MediaPlayer.MEDIA_ERROR_MALFORMED: - type = MediaErrorType.Decode; - message = "Media was malformed"; - break; - case MediaPlayer.MEDIA_ERROR_NOT_VALID_FOR_PROGRESSIVE_PLAYBACK: - type = MediaErrorType.SrcNotSupported; - message = "Not valie for progressive playback"; - break; - case MediaPlayer.MEDIA_ERROR_SERVER_DIED: - type = MediaErrorType.Network; - message = "Server died"; - break; - case MediaPlayer.MEDIA_ERROR_TIMED_OUT: - type = MediaErrorType.Network; - message = "Timed out"; - break; - - case MediaPlayer.MEDIA_ERROR_UNKNOWN: - type = MediaErrorType.Network; - message = "Unknown error"; - break; - case MediaPlayer.MEDIA_ERROR_UNSUPPORTED: - type = MediaErrorType.SrcNotSupported; - message = "Unsupported media"; - break; - default: - type = MediaErrorType.Network; - message = "Unknown error"; - } - return new MediaException(type, message); - } - - - public class Video extends AndroidImplementation.AndroidPeer implements AsyncMedia { - - private VideoView nativeVideo; - private Activity activity; - private boolean fullScreen = false; - private Rectangle bounds; - private boolean nativeController = true; - private boolean nativePlayer; - private Form curentForm; - private List completionHandlers; - private final EventDispatcher errorListeners = new EventDispatcher(); - - private final EventDispatcher stateChangeListeners = new EventDispatcher(); - private PlayRequest pendingPlayRequest; - private PauseRequest pendingPauseRequest; - private boolean androidSeekPreviewWorkaroundEnabled; - - @Override - public State getState() { - if (isPlaying()) { - return State.Playing; - } else { - return State.Paused; - } - } - - protected void fireMediaStateChange(State newState) { - if (stateChangeListeners.hasListeners() && newState != getState()) { - stateChangeListeners.fireActionEvent(new MediaStateChangeEvent(this, getState(), newState)); - } - } - - @Override - public void addMediaStateChangeListener(ActionListener l) { - - stateChangeListeners.addListener(l); - } - - @Override - public void removeMediaStateChangeListener(ActionListener l) { - - stateChangeListeners.removeListener(l); - } - - @Override - public void addMediaErrorListener(ActionListener l) { - errorListeners.addListener(l); - } - - @Override - public void removeMediaErrorListener(ActionListener l) { - errorListeners.removeListener(l); - } - - @Override - public PlayRequest playAsync() { - final PlayRequest out = new PlayRequest(); - out.ready(new SuccessCallback() { - @Override - public void onSucess(AsyncMedia value) { - if (out == pendingPlayRequest) { - pendingPlayRequest = null; - } - } - }).except(new SuccessCallback() { - @Override - public void onSucess(Throwable value) { - if (out == pendingPlayRequest) { - pendingPlayRequest = null; - } - } - }); - ; - if (pendingPlayRequest != null) { - pendingPlayRequest.ready(new SuccessCallback() { - @Override - public void onSucess(AsyncMedia value) { - if (!out.isDone()) { - out.complete(value); - } - } - }).except(new SuccessCallback() { - @Override - public void onSucess(Throwable value) { - if (!out.isDone()) { - out.error(value); - } - } - }); - return out; - } else { - pendingPlayRequest = out; - } - - ActionListener onStateChange = new ActionListener() { - @Override - public void actionPerformed(MediaStateChangeEvent evt) { - stateChangeListeners.removeListener(this); - if (!out.isDone()) { - if (evt.getNewState() == State.Playing) { - out.complete(Video.this); - } - } - - } - - }; - - stateChangeListeners.addListener(onStateChange); - play(); - - return out; - - } - - @Override - public PauseRequest pauseAsync() { - final PauseRequest out = new PauseRequest(); - out.ready(new SuccessCallback() { - @Override - public void onSucess(AsyncMedia value) { - if (out == pendingPauseRequest) { - pendingPauseRequest = null; - } - } - }).except(new SuccessCallback() { - @Override - public void onSucess(Throwable value) { - if (out == pendingPauseRequest) { - pendingPauseRequest = null; - } - } - }); - ; - if (pendingPauseRequest != null) { - pendingPauseRequest.ready(new SuccessCallback() { - @Override - public void onSucess(AsyncMedia value) { - if (!out.isDone()) { - out.complete(value); - } - } - }).except(new SuccessCallback() { - @Override - public void onSucess(Throwable value) { - if (!out.isDone()) { - out.error(value); - } - } - }); - return out; - } else { - pendingPauseRequest = out; - } - - ActionListener onStateChange = new ActionListener() { - @Override - public void actionPerformed(MediaStateChangeEvent evt) { - stateChangeListeners.removeListener(this); - if (!out.isDone()) { - if (evt.getNewState() == State.Paused) { - out.complete(Video.this); - } - } - - } - - }; - - stateChangeListeners.addListener(onStateChange); - play(); - - return out; - } - - - public Video(final VideoView nativeVideo, final Activity activity, final Runnable onCompletion) { - super(new RelativeLayout(activity)); - this.nativeVideo = nativeVideo; - RelativeLayout rl = (RelativeLayout)getNativePeer(); - - rl.addView(nativeVideo); - RelativeLayout.LayoutParams layout = new RelativeLayout.LayoutParams(getWidth(), getHeight()); - layout.addRule(RelativeLayout.CENTER_HORIZONTAL); - layout.addRule(RelativeLayout.CENTER_VERTICAL); - rl.setLayoutParams(layout); - rl.requestLayout(); - - this.activity = activity; - if (nativeController) { - MediaController mc = new AndroidImplementation.CN1MediaController(); - nativeVideo.setMediaController(mc); - } - - nativeVideo.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { - @Override - public void onCompletion(MediaPlayer arg0) { - fireMediaStateChange(State.Paused); - - fireCompletionHandlers(); - } - }); - if (onCompletion != null) { - addCompletionHandler(onCompletion); - } - - nativeVideo.setOnErrorListener(new MediaPlayer.OnErrorListener() { - @Override - public boolean onError(MediaPlayer mp, int what, int extra) { - com.codename1.io.Log.p("Media player error: " + mp + " what: " + what + " extra: " + extra); - errorListeners.fireActionEvent(new MediaErrorEvent(Video.this, createMediaException(extra))); - fireMediaStateChange(State.Paused); - fireCompletionHandlers(); - return true; - } - }); - - } - - - - private void fireCompletionHandlers() { - if (completionHandlers != null && !completionHandlers.isEmpty()) { - Display.getInstance().callSerially(new Runnable() { - public void run() { - if (completionHandlers != null && !completionHandlers.isEmpty()) { - ArrayList toRun; - synchronized(Video.this) { - toRun = new ArrayList(completionHandlers); - } - for (Runnable r : toRun) { - r.run(); - } - } - } - }); - } - } - private void setNativeController(final boolean nativeController) { - if (nativeController != this.nativeController) { - this.nativeController = nativeController; - if (nativeVideo != null) { - Activity activity = getActivity(); - if (activity != null) { - activity.runOnUiThread(new Runnable() { - - @Override - public void run() { - if (nativeVideo != null) { - MediaController mc = new AndroidImplementation.CN1MediaController(); - nativeVideo.setMediaController(mc); - if (!nativeController) mc.setVisibility(View.GONE); - else mc.setVisibility(View.VISIBLE); - - } - } - - }); - } - - } - } - } - - @Override - public void init() { - super.init(); - setVisible(true); - } - - public void prepare() { - } - - @Override - public void play() { - Component cmp = getVideoComponent(); - if (cmp.getParent() == null && nativePlayer && curentForm == null) { - curentForm = Display.getInstance().getCurrent(); - Form f = new Form(); - f.setBackCommand(new Command("") { - @Override - public void actionPerformed(ActionEvent evt) { - Component cmp = getVideoComponent(); - if(cmp != null) { - cmp.remove(); - pause(); - } - curentForm.showBack(); - curentForm = null; - } - }); - f.setLayout(new BorderLayout()); - - if(cmp.getParent() != null) { - cmp.getParent().removeComponent(cmp); - } - f.addComponent(BorderLayout.CENTER, cmp); - f.show(); - } - nativeVideo.start(); - fireMediaStateChange(State.Playing); - } - - @Override - public void pause() { - if(nativeVideo != null && nativeVideo.canPause()){ - nativeVideo.pause(); - fireMediaStateChange(State.Paused); - } - } - - @Override - public void cleanup() { - if(nativeVideo != null) { - nativeVideo.stopPlayback(); - fireMediaStateChange(State.Paused); - } - nativeVideo = null; - if (nativePlayer && curentForm != null) { - curentForm.showBack(); - curentForm = null; - } - } - - @Override - public int getTime() { - if(nativeVideo != null){ - return nativeVideo.getCurrentPosition(); - } - return -1; - } - - @Override - public void setTime(int time) { - if(nativeVideo != null){ - final int seekTime = time; - activity.runOnUiThread(new Runnable() { - @Override - public void run() { - if (nativeVideo == null) { - return; - } - nativeVideo.seekTo(seekTime); - if (androidSeekPreviewWorkaroundEnabled && !nativeVideo.isPlaying()) { - final int refreshSeekTime = Math.max(0, seekTime - 1); - nativeVideo.postDelayed(new Runnable() { - @Override - public void run() { - if (nativeVideo != null && !nativeVideo.isPlaying()) { - nativeVideo.seekTo(refreshSeekTime); - nativeVideo.seekTo(seekTime); - nativeVideo.invalidate(); - } - } - }, 60); - } - } - }); - } - } - - @Override - public int getDuration() { - if(nativeVideo != null){ - return nativeVideo.getDuration(); - } - return -1; - } - - @Override - public void setVolume(int vol) { - // float v = ((float) vol) / 100.0F; - AudioManager am = (AudioManager) activity.getSystemService(Context.AUDIO_SERVICE); - int max = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC); - am.setStreamVolume(AudioManager.STREAM_MUSIC, vol, 0); - } - - @Override - public int getVolume() { - AudioManager am = (AudioManager) activity.getSystemService(Context.AUDIO_SERVICE); - return am.getStreamVolume(AudioManager.STREAM_MUSIC); - } - - @Override - public boolean isVideo() { - return true; - } - - @Override - public boolean isFullScreen() { - return fullScreen || nativePlayer; - } - - @Override - public void setFullScreen(boolean fullScreen) { - this.fullScreen = fullScreen; - if (fullScreen) { - bounds = new Rectangle(getBounds()); - setX(0); - setY(0); - setWidth(Display.getInstance().getDisplayWidth()); - setHeight(Display.getInstance().getDisplayHeight()); - } else { - if (bounds != null) { - setX(bounds.getX()); - setY(bounds.getY()); - setWidth(bounds.getSize().getWidth()); - setHeight(bounds.getSize().getHeight()); - } - } - repaint(); - } - - @Override - public Component getVideoComponent() { - return this; - } - - @Override - protected Dimension calcPreferredSize() { - if(nativeVideo != null){ - return new Dimension(nativeVideo.getWidth(), nativeVideo.getHeight()); - } - return new Dimension(); - } - - @Override - public void setWidth(final int width) { - super.setWidth(width); - final int currH = getHeight(); - if(nativeVideo != null){ - activity.runOnUiThread(new Runnable() { - - public void run() { - float nh = nativeVideo.getHeight(); - float nw = nativeVideo.getWidth(); - float w = width; - float h = currH; - if (nh != 0 && nw != 0) { - h = width * nh / nw; - if (h > getHeight()) { - h = getHeight(); - w = h * nw / nh; - } - if (w > getWidth()) { - w = getWidth(); - h = w * nh / nw; - } - } - RelativeLayout.LayoutParams layout = new RelativeLayout.LayoutParams((int)w, (int)h); - layout.addRule(RelativeLayout.CENTER_HORIZONTAL); - layout.addRule(RelativeLayout.CENTER_VERTICAL); - nativeVideo.setLayoutParams(layout); - nativeVideo.requestLayout(); - nativeVideo.getHolder().setSizeFromLayout(); - } - }); - } - } - - @Override - public void setHeight(final int height) { - super.setHeight(height); - final int currW = getWidth(); - if(nativeVideo != null){ - activity.runOnUiThread(new Runnable() { - - public void run() { - float nh = nativeVideo.getHeight(); - float nw = nativeVideo.getWidth(); - float h = height; - float w = currW; - if (nh != 0 && nw != 0) { - w = h * nw / nh; - if (h > getHeight()) { - h = getHeight(); - w = h * nw / nh; - } - if (w > getWidth()) { - w = getWidth(); - h = w * nh / nw; - } - } - RelativeLayout.LayoutParams layout = new RelativeLayout.LayoutParams((int)w, (int)h); - layout.addRule(RelativeLayout.CENTER_HORIZONTAL); - layout.addRule(RelativeLayout.CENTER_VERTICAL); - nativeVideo.setLayoutParams(layout); - nativeVideo.requestLayout(); - nativeVideo.getHolder().setSizeFromLayout(); - } - }); - } - } - - @Override - public void setNativePlayerMode(boolean nativePlayer) { - this.nativePlayer = nativePlayer; - } - - @Override - public boolean isNativePlayerMode() { - return nativePlayer; - } - - @Override - public boolean isPlaying() { - if(nativeVideo != null){ - return nativeVideo.isPlaying(); - } - return false; - } - - public void setVariable(String key, Object value) { - if (nativeVideo != null && Media.VARIABLE_NATIVE_CONTRLOLS_EMBEDDED.equals(key) && value instanceof Boolean) { - setNativeController((Boolean)value); - return; - } - if (Media.VARIABLE_ANDROID_SEEK_PREVIEW_WORKAROUND.equals(key) && value instanceof Boolean) { - androidSeekPreviewWorkaroundEnabled = ((Boolean)value).booleanValue(); - } - } - - public Object getVariable(String key) { - return null; - } - - @Override - public void addMediaCompletionHandler(Runnable onComplete) { - addCompletionHandler(onComplete); - } - - - - private void addCompletionHandler(Runnable onCompletion) { - synchronized(this) { - if (completionHandlers == null) { - completionHandlers = new ArrayList(); - } - completionHandlers.add(onCompletion); - } - } - - private void removeCompletionHandler(Runnable onCompletion) { - synchronized(this) { - if (completionHandlers != null) { - completionHandlers.remove(onCompletion); - } - } - } - - - } - - - private String getImageFilePath(Uri uri) { - String scheme = uri.getScheme(); - String[] filePathColumn = {MediaStore.Images.Media.DATA}; - Cursor cursor = getContext().getContentResolver().query( - android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - new String[]{ MediaStore.Images.Media.DATA}, - null, - null, - null - ); - // Some gallery providers may return an empty cursor on modern Android builds. - String filePath = null; - if (cursor != null) { - try { - int columnIndex = cursor.getColumnIndex(filePathColumn[0]); - if (columnIndex >= 0 && cursor.moveToFirst()) { - filePath = cursor.getString(columnIndex); - } - } finally { - cursor.close(); - } - } - - if (filePath == null || "content".equals(scheme)) { - //if the file is not on the filesystem download it and save it - //locally - InputStream inputStream = null; - OutputStream tmp = null; - try { - inputStream = getContext().getContentResolver().openInputStream(uri); - if (inputStream != null) { - String name = new File(uri.toString()).getName();//getContentName(getContext().getContentResolver(), uri); - if (name != null) { - String homePath = getAppHomePath(); - if (homePath.endsWith("/")) { - homePath = homePath.substring(0, homePath.length()-1); - } - filePath = homePath - + getFileSystemSeparator() + name; - File f = new File(removeFilePrefix(filePath)); - tmp = createFileOuputStream(f); - Util.copy(inputStream, tmp); - } - } - } catch (Exception e) { - com.codename1.io.Log.e(e); - } finally { - Util.cleanup(tmp); - Util.cleanup(inputStream); - } - } - return filePath; - } - - @Override - public void onActivityResult(int requestCode, int resultCode, Intent intent) { - - if (requestCode == ZOOZ_PAYMENT) { - ((IntentResultListener) pur).onActivityResult(requestCode, resultCode, intent); - return; - } - - takePersistablePermissionsFromIntent(intent); - - if (requestCode == REQUEST_SELECT_FILE || requestCode == FILECHOOSER_RESULTCODE) { - if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - if (requestCode == REQUEST_SELECT_FILE) { - if (uploadMessage == null) return; - Uri[] results = null; - - // Check that the response is a good one - if (resultCode == Activity.RESULT_OK) { - if (intent != null) { - // If there is not data, then we may have taken a photo - String dataString = intent.getDataString(); - ClipData clipData = intent.getClipData(); - - if (clipData != null) { - results = new Uri[clipData.getItemCount()]; - for (int i = 0; i < clipData.getItemCount(); i++) { - ClipData.Item item = clipData.getItemAt(i); - results[i] = item.getUri(); - } - } else if (dataString != null) { - results = new Uri[]{Uri.parse(dataString)}; - } - } - } - - uploadMessage.onReceiveValue(results); - uploadMessage = null; - } - } - else if (requestCode == FILECHOOSER_RESULTCODE) { - if (null == mUploadMessage) { - return; - } - // Use MainActivity.RESULT_OK if you're implementing WebView inside Fragment - // Use RESULT_OK only if you're implementing WebView inside an Activity - Uri result = intent == null || resultCode != Activity.RESULT_OK ? null : intent.getData(); - mUploadMessage.onReceiveValue(result); - mUploadMessage = null; - } - else { - - Toast.makeText(getActivity().getApplicationContext(), "Failed to Upload File", Toast.LENGTH_LONG).show(); - } - return; - } - - - if (resultCode == Activity.RESULT_OK) { - if (requestCode == CAPTURE_IMAGE) { - try { - String imageUri = (String) Storage.getInstance().readObject("imageUri"); - Vector pathandId = StringUtil.tokenizeString(imageUri, ";"); - String path = (String)pathandId.get(0); - String lastId = (String)pathandId.get(1); - Storage.getInstance().deleteStorageFile("imageUri"); - clearMediaDB(lastId, path); - callback.fireActionEvent(new ActionEvent(addFile(path))); - return; - } catch (Exception e) { - e.printStackTrace(); - } - } else if (requestCode == CAPTURE_VIDEO) { - String path = (String) Storage.getInstance().readObject("videoUri"); - Storage.getInstance().deleteStorageFile("videoUri"); - callback.fireActionEvent(new ActionEvent(addFile(path))); - return; - } else if (requestCode == CAPTURE_AUDIO) { - Uri data = intent.getData(); - String path = convertImageUriToFilePath(data, getContext()); - callback.fireActionEvent(new ActionEvent(addFile(path))); - return; - - } else if (requestCode == OPEN_GALLERY_MULTI) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { - if(intent.getClipData() != null){ - // If it was a multi-request - ArrayList selectedPaths = new ArrayList(); - int count = intent.getClipData().getItemCount(); - for (int i=0; i= 0 && cursor.moveToFirst()) { - filePath = cursor.getString(columnIndex); - } - } finally { - cursor.close(); - } - } - boolean fileExists = false; - if (filePath != null) { - File file = new File(filePath); - fileExists = file.exists() && file.canRead(); - } - - if (!fileExists && "content".equals(scheme)) { - //if the file is not on the filesystem download it and save it - //locally - try { - InputStream inputStream = getContext().getContentResolver().openInputStream(selectedImage); - if (inputStream != null) { - String name = getContentName(getContext().getContentResolver(), selectedImage); - if (name != null) { - filePath = getAppHomePath() - + getFileSystemSeparator() + name; - File f = new File(removeFilePrefix(filePath)); - OutputStream tmp = createFileOuputStream(f); - byte[] buffer = new byte[1024]; - int read = -1; - while ((read = inputStream.read(buffer)) > -1) { - tmp.write(buffer, 0, read); - } - tmp.close(); - inputStream.close(); - } - } - } catch (Exception e) { - e.printStackTrace(); - } - } - - if (filePath == null) { - callback.fireActionEvent(null); - return; - } - - callback.fireActionEvent(new ActionEvent(new String[]{filePath})); - return; - } else if (requestCode == OPEN_GALLERY) { - - Uri selectedImage = intent.getData(); - String scheme = intent.getScheme(); - - String[] filePathColumn = {MediaStore.Images.Media.DATA}; - Cursor cursor = getContext().getContentResolver().query(selectedImage, filePathColumn, null, null, null); - - // Some gallery providers may return an empty cursor on modern Android builds. - String filePath = null; - if (cursor != null) { - try { - int columnIndex = cursor.getColumnIndex(filePathColumn[0]); - if (columnIndex >= 0 && cursor.moveToFirst()) { - filePath = cursor.getString(columnIndex); - } - } finally { - cursor.close(); - } - } - boolean fileExists = false; - if (filePath != null) { - File file = new File(filePath); - fileExists = file.exists() && file.canRead(); - } - - if (!fileExists && "content".equals(scheme)) { - //if the file is not on the filesystem download it and save it - //locally - try { - InputStream inputStream = getContext().getContentResolver().openInputStream(selectedImage); - if (inputStream != null) { - String name = getContentName(getContext().getContentResolver(), selectedImage); - if (name != null) { - filePath = getAppHomePath() - + getFileSystemSeparator() + name; - File f = new File(removeFilePrefix(filePath)); - OutputStream tmp = createFileOuputStream(f); - byte[] buffer = new byte[1024]; - int read = -1; - while ((read = inputStream.read(buffer)) > -1) { - tmp.write(buffer, 0, read); - } - tmp.close(); - inputStream.close(); - } - } - } catch (Exception e) { - e.printStackTrace(); - } - } - - if (filePath == null) { - callback.fireActionEvent(null); - return; - } - - callback.fireActionEvent(new ActionEvent(filePath)); - return; - } else { - if(callback != null) { - callback.fireActionEvent(new ActionEvent("ok")); - } - return; - } - } - //clean imageUri - String imageUri = (String) Storage.getInstance().readObject("imageUri"); - if(imageUri != null){ - Storage.getInstance().deleteStorageFile("imageUri"); - } - - if(callback != null) { - callback.fireActionEvent(null); - } - } - - - - @Override - public void capturePhoto(ActionListener response) { - if (getActivity() == null) { - throw new RuntimeException("Cannot capture photo in background mode"); - } - if (PermissionsHelper.requiresExternalStoragePermissionForMediaAccess()) { - if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to take a picture")){ - return; - } - } - - if (getRequestedPermissions().contains(Manifest.permission.CAMERA)) { - // Normally we don't need to request the CAMERA permission since we use - // the ACTION_IMAGE_CAPTURE intent, which handles permissions itself. - // BUT: If the camera permission is included in the Manifest file, the - // intent will defer to the app's permissions, and on Android 6, - // the permission is denied unless we do the runtime check for permission. - // See https://github.com/codenameone/CodenameOne/issues/2409#issuecomment-391696058 - if(!checkForPermission(Manifest.permission.CAMERA, "This is required to take a picture")){ - return; - } - } - callback = new EventDispatcher(); - callback.addListener(response); - Intent intent = new Intent(android.provider.MediaStore.ACTION_IMAGE_CAPTURE); - - File newFile = getOutputMediaFile(false); - newFile.getParentFile().mkdirs(); - newFile.getParentFile().setWritable(true, false); - //Uri imageUri = Uri.fromFile(newFile); - Uri imageUri = FileProvider.getUriForFile(getContext(), getContext().getPackageName()+".provider", newFile); - intent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, imageUri); - - String lastImageID = getLastImageId(); - Storage.getInstance().writeObject("imageUri", newFile.getAbsolutePath() + ";" + lastImageID); - - intent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, imageUri); - intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); - - if (Build.VERSION.SDK_INT < 21) { - List resInfoList = getContext().getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); - for (ResolveInfo resolveInfo : resInfoList) { - String packageName = resolveInfo.activityInfo.packageName; - getContext().grantUriPermission(packageName, imageUri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); - } - } - - getActivity().startActivityForResult(intent, CAPTURE_IMAGE); - } - - @Override - public void captureVideo(ActionListener response) { - captureVideo(null, response); - } - - @Override - public void captureVideo(VideoCaptureConstraints cnst, ActionListener response) { - if (getActivity() == null) { - throw new RuntimeException("Cannot capture video in background mode"); - } - if (PermissionsHelper.requiresExternalStoragePermissionForMediaAccess()) { - if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to take a video")){ - return; - } - } - - if (getRequestedPermissions().contains(Manifest.permission.CAMERA)) { - // Normally we don't need to request the CAMERA permission since we use - // the ACTION_VIDEO_CAPTURE intent, which handles permissions itself. - // BUT: If the camera permission is included in the Manifest file, the - // intent will defer to the app's permissions, and on Android 6, - // the permission is denied unless we do the runtime check for permission. - // See https://github.com/codenameone/CodenameOne/issues/2409#issuecomment-391696058 - if(!checkForPermission(Manifest.permission.CAMERA, "This is required to take a video")){ - return; - } - } - callback = new EventDispatcher(); - callback.addListener(response); - Intent intent = new Intent(android.provider.MediaStore.ACTION_VIDEO_CAPTURE); - if (cnst != null) { - switch (cnst.getQuality()) { - case VideoCaptureConstraints.QUALITY_LOW: - intent.putExtra(MediaStore.EXTRA_VIDEO_QUALITY, 0); - break; - case VideoCaptureConstraints.QUALITY_HIGH: - intent.putExtra(MediaStore.EXTRA_VIDEO_QUALITY, 1); - break; - } - - if (cnst.getMaxFileSize() > 0) { - intent.putExtra(MediaStore.EXTRA_SIZE_LIMIT, cnst.getMaxFileSize()); - } - if (cnst.getMaxLength() > 0) { - intent.putExtra(MediaStore.EXTRA_DURATION_LIMIT, cnst.getMaxLength()); - } - } - - - File newFile = getOutputMediaFile(true); - newFile.getParentFile().mkdirs(); - newFile.getParentFile().setWritable(true, false); - Uri videoUri = FileProvider.getUriForFile(getContext(), getContext().getPackageName()+".provider", newFile); - - Storage.getInstance().writeObject("videoUri", newFile.getAbsolutePath()); - - intent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, videoUri); - intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); - if (Build.VERSION.SDK_INT < 21) { - List resInfoList = getContext().getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); - for (ResolveInfo resolveInfo : resInfoList) { - String packageName = resolveInfo.activityInfo.packageName; - getContext().grantUriPermission(packageName, videoUri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); - } - } - - this.getActivity().startActivityForResult(intent, CAPTURE_VIDEO); - } - - public void captureAudio(final ActionListener response) { - - if(!checkForPermission(Manifest.permission.RECORD_AUDIO, "This is required to record the audio")){ - return; - } - - try { - final Form current = Display.getInstance().getCurrent(); - - final File temp = File.createTempFile("mtmp", ".3gpp"); - temp.deleteOnExit(); - - if (recorder != null) { - recorder.release(); - } - recorder = new MediaRecorder(); - recorder.setAudioSource(MediaRecorder.AudioSource.MIC); - recorder.setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP); - recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AMR_WB); - recorder.setOutputFile(temp.getAbsolutePath()); - - final Form recording = new Form("Recording"); - recording.setTransitionInAnimator(CommonTransitions.createEmpty()); - recording.setTransitionOutAnimator(CommonTransitions.createEmpty()); - recording.setLayout(new BorderLayout()); - - recorder.prepare(); - recorder.start(); - - final Label time = new Label("00:00"); - time.getAllStyles().setAlignment(Component.CENTER); - Font f = Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_LARGE); - f = f.derive(getDisplayHeight() / 10, Font.STYLE_PLAIN); - time.getAllStyles().setFont(f); - recording.addComponent(BorderLayout.CENTER, time); - - recording.registerAnimated(new Animation() { - - long current = System.currentTimeMillis(); - long zero = current; - int sec = 0; - - public boolean animate() { - long now = System.currentTimeMillis(); - if (now - current > 1000) { - current = now; - sec++; - return true; - } - return false; - } - - public void paint(Graphics g) { - int seconds = sec % 60; - int minutes = sec / 60; - - String secStr = seconds < 10 ? "0" + seconds : "" + seconds; - String minStr = minutes < 10 ? "0" + minutes : "" + minutes; - - String txt = minStr + ":" + secStr; - time.setText(txt); - } - }); - - Container south = new Container(new com.codename1.ui.layouts.GridLayout(1, 2)); - Command cancel = new Command("Cancel") { - - @Override - public void actionPerformed(ActionEvent evt) { - if (recorder != null) { - recorder.stop(); - recorder.release(); - recorder = null; - } - current.showBack(); - response.actionPerformed(null); - } - - }; - recording.setBackCommand(cancel); - south.add(new com.codename1.ui.Button(cancel)); - south.add(new com.codename1.ui.Button(new Command("Save") { - - @Override - public void actionPerformed(ActionEvent evt) { - if (recorder != null) { - recorder.stop(); - recorder.release(); - recorder = null; - } - current.showBack(); - response.actionPerformed(new ActionEvent(temp.getAbsolutePath())); - } - - })); - recording.addComponent(BorderLayout.SOUTH, south); - recording.show(); - - } catch (IOException ex) { - ex.printStackTrace(); - throw new RuntimeException("failed to start audio recording"); - } - - } - - /** - * Opens the device image gallery - * - * @param response callback for the resulting image - * - * - * DISABLING: openGallery() should take care of this - public void openImageGallery(ActionListener response) { - if (getActivity() == null) { - throw new RuntimeException("Cannot open image gallery in background mode"); - } - if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to browse the photos")){ - return; - } - - if(editInProgress()) { - stopEditing(true); - } - - callback = new EventDispatcher(); - callback.addListener(response); - Intent galleryIntent = new Intent(Intent.ACTION_PICK, android.provider.MediaStore.Images.Media.INTERNAL_CONTENT_URI); - this.getActivity().startActivityForResult(galleryIntent, OPEN_GALLERY); - } - * */ - - @Override - public boolean isGalleryTypeSupported(int type) { - if (super.isGalleryTypeSupported(type)) { - return true; - } - if (type == -9999 || type == -9998) { - return true; - } - if (android.os.Build.VERSION.SDK_INT >= 16) { - switch (type) { - - case Display.GALLERY_ALL_MULTI: - case Display.GALLERY_VIDEO_MULTI: - case Display.GALLERY_IMAGE_MULTI: - return true; - } - } - return false; - } - - - - public void openGallery(final ActionListener response, int type){ - if (!isGalleryTypeSupported(type)) { - throw new IllegalArgumentException("Gallery type "+type+" not supported on this platform."); - } - if (getActivity() == null) { - throw new RuntimeException("Cannot open galery in background mode"); - } - if (PermissionsHelper.requiresExternalStoragePermissionForMediaAccess()) { - if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to browse the photos")){ - return; - } - } - if(editInProgress()) { - stopEditing(true); - } - final boolean multi; - switch (type) { - case Display.GALLERY_ALL_MULTI: - multi=true; - type = Display.GALLERY_ALL; - break; - case Display.GALLERY_VIDEO_MULTI: - multi=true; - type = Display.GALLERY_VIDEO; - break; - case Display.GALLERY_IMAGE_MULTI: - multi = true; - type = Display.GALLERY_IMAGE; - break; - case -9998: - multi = true; - type = -9999; - break; - default: - multi = false; - } - - callback = new EventDispatcher(); - callback.addListener(response); - Intent galleryIntent = new Intent(Intent.ACTION_PICK, android.provider.MediaStore.Images.Media.INTERNAL_CONTENT_URI); - galleryIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - if (multi) { - galleryIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); - } - if(type == Display.GALLERY_VIDEO){ - galleryIntent.setType("video/*"); - }else if(type == Display.GALLERY_IMAGE){ - galleryIntent.setType("image/*"); - }else if(type == Display.GALLERY_ALL){ - galleryIntent.setType("image/* video/*"); - }else if (type == -9999) { - galleryIntent = new Intent(); - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { - galleryIntent.setAction(Intent.ACTION_OPEN_DOCUMENT); - } else { - galleryIntent.setAction(Intent.ACTION_GET_CONTENT); - } - galleryIntent.addCategory(Intent.CATEGORY_OPENABLE); - galleryIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { - galleryIntent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); - } - - // set MIME type for image - galleryIntent.setType("*/*"); - galleryIntent.putExtra(Intent.EXTRA_MIME_TYPES, Display.getInstance().getProperty("android.openGallery.accept", "*/*").split(",")); - }else{ - galleryIntent.setType("*/*"); - } - this.getActivity().startActivityForResult(galleryIntent, multi ? OPEN_GALLERY_MULTI: OPEN_GALLERY); - } - - @Override - public void openFileChooser(final ActionListener response, String accept) { - if (getActivity() == null) { - throw new RuntimeException("Cannot open file chooser in background mode"); - } - if(editInProgress()) { - stopEditing(true); - } - callback = new EventDispatcher(); - callback.addListener(response); - Intent pickerIntent = new Intent(); - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { - pickerIntent.setAction(Intent.ACTION_OPEN_DOCUMENT); - } else { - pickerIntent.setAction(Intent.ACTION_GET_CONTENT); - } - pickerIntent.addCategory(Intent.CATEGORY_OPENABLE); - pickerIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { - pickerIntent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); - } - String[] mimeTypes = getFileChooserMimeTypes(accept); - pickerIntent.setType("*/*"); - if (mimeTypes.length > 0) { - pickerIntent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes); - } - this.getActivity().startActivityForResult(pickerIntent, OPEN_GALLERY); - } - - private String[] getFileChooserMimeTypes(String accept) { - if (accept == null || accept.trim().length() == 0 || "*/*".equals(accept.trim())) { - return new String[0]; - } - ArrayList out = new ArrayList(); - String[] tokens = accept.split(","); - for (int iter = 0; iter < tokens.length; iter++) { - String token = tokens[iter].trim(); - if (token.length() == 0 || "*".equals(token)) { - continue; - } - if (token.indexOf('/') > 0) { - out.add(token); - } - } - if (out.isEmpty()) { - out.add("*/*"); - } - return out.toArray(new String[out.size()]); - } - - class NativeImage extends Image { - - public NativeImage(Bitmap nativeImage) { - super(nativeImage); - } - } - - /** - * Persist read permissions that were granted by an activity result so that media playback can - * continue after {@link Activity#onActivityResult(int, int, Intent)} returns. - * - *

Android 13 and newer revoke temporary grants immediately after the callback unless the - * app calls {@link ContentResolver#takePersistableUriPermission(Uri, int)}. Without this call - * {@link #createMedia(String, boolean, Runnable)} loses access to the {@code content://} URI - * provided by the system picker and playback fails on Android 15.

- */ - private void takePersistablePermissionsFromIntent(Intent intent) { - if (intent == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { - return; - } - int takeFlags = intent.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - if (takeFlags == 0) { - return; - } - ContentResolver resolver = getContext().getContentResolver(); - if (resolver == null) { - return; - } - ClipData clip = intent.getClipData(); - if (clip != null) { - for (int i = 0; i < clip.getItemCount(); i++) { - Uri uri = clip.getItemAt(i).getUri(); - if (uri != null) { - try { - resolver.takePersistableUriPermission(uri, takeFlags); - } catch (SecurityException ignored) { - } - } - } - } - Uri dataUri = intent.getData(); - if (dataUri != null) { - try { - resolver.takePersistableUriPermission(dataUri, takeFlags); - } catch (SecurityException ignored) { - } - } - } - - /** - * Create a File for saving an image or video - */ - private File getOutputMediaFile(boolean isVideo) { - // To be safe, you should check that the SDCard is mounted - // using Environment.getExternalStorageState() before doing this. - if (getActivity() != null) { - return GetOutputMediaFile.getOutputMediaFile(isVideo, getActivity()); - } else { - return GetOutputMediaFile.getOutputMediaFile(isVideo, getContext(), "Video"); - } - } - - private static class GetOutputMediaFile { - - public static File getOutputMediaFile(boolean isVideo,Activity activity) { - activity.getComponentName(); - return getOutputMediaFile(isVideo, activity, activity.getTitle()); - } - - public static File getOutputMediaFile(boolean isVideo, Context activity, CharSequence title) { - - - File mediaStorageDir = new File(new File(getContext().getCacheDir(), "intent_files"), ""+title); - - // Create the storage directory if it does not exist - if (!mediaStorageDir.exists()) { - if (!mediaStorageDir.mkdirs()) { - Log.d(Display.getInstance().getProperty("AppName", "CodenameOne"), "failed to create directory"); - return null; - } - } - - // Create a media file name - String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date()); - File mediaFile = null; - if (!isVideo) { - mediaFile = new File(mediaStorageDir.getPath() + File.separator - + "IMG_" + timeStamp + ".jpg"); - } else { - mediaFile = new File(mediaStorageDir.getPath() + File.separator - + "VID_" + timeStamp + ".mp4"); - } - - return mediaFile; - } - } - - @Override - 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; - } - - @Override - public void registerPush(Hashtable metaData, boolean noFallback) { - if (getActivity() == null) { - return; - } - - if (android.os.Build.VERSION.SDK_INT >= 33) { - if(!checkForPermission("android.permission.POST_NOTIFICATIONS", "This is required to receive push notifications")){ - return; - } - } - - 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!"); - return; - } - String id = ""; - if (!huawei) { - id = (String)metaData.get(com.codename1.push.Push.GOOGLE_PUSH_KEY); - if (id == null) { - id = Display.getInstance().getProperty("gcm.sender_id", null); - } - } - Log.d("Codename One", "Sending async push request for id: " + id); - ((CodenameOneActivity) getActivity()).registerForPush(id); - } - - public static void stopPollingLoop() { - stopPolling(); - } - - public static void registerPolling() { - registerPollingFallback(); - } - - @Override - public void deregisterPush() { - boolean has = hasAndroidMarket() - || "huawei".equals(Display.getInstance().getProperty("cn1.push.transport", "")); - if (has) { - ((CodenameOneActivity) getActivity()).stopReceivingPush(); - deregisterPushFromServer(); - } else { - super.deregisterPush(); - } - } - - private static String convertImageUriToFilePath(Uri imageUri, Context activity) { - Cursor cursor = null; - String[] proj = {MediaStore.Images.Media.DATA}; - cursor = activity.getContentResolver().query(imageUri, proj, null, null, null); - int column_index = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA); - cursor.moveToFirst(); - String path = cursor.getString(column_index); - cursor.close(); - return path; - } - - class CN1MediaController extends MediaController { - - public CN1MediaController() { - super(getActivity()); - } - - @Override - public boolean dispatchKeyEvent(KeyEvent event) { - int keycode = event.getKeyCode(); - keycode = CodenameOneView.internalKeyCodeTranslate(keycode); - if (keycode == AndroidImplementation.DROID_IMPL_KEY_BACK) { - // Claim the gesture so the activity's OnBackInvokedCallback - // stands down; on Android 16 the platform can deliver both for - // one press. See PredictiveBackBridge. The claim brackets the - // DOWN and the UP even though this path answers each of them - // with a whole press/release pair of its own. - switch (event.getAction()) { - case KeyEvent.ACTION_DOWN: - PredictiveBackBridge.keyEventBackStarted(); - break; - case KeyEvent.ACTION_UP: - PredictiveBackBridge.keyEventBackFinished(); - break; - default: - break; - } - Display.getInstance().keyPressed(keycode); - Display.getInstance().keyReleased(keycode); - return true; - } else { - return super.dispatchKeyEvent(event); - } - } - } - private L10NManager l10n; - - /** - * @inheritDoc - */ - public L10NManager getLocalizationManager() { - if (l10n == null) { - final Locale l = Locale.getDefault(); - l10n = new L10NManager(l.getLanguage(), l.getCountry()) { - public double parseDouble(String localeFormattedDecimal) { - try { - return NumberFormat.getNumberInstance().parse(localeFormattedDecimal).doubleValue(); - } catch (ParseException err) { - return Double.parseDouble(localeFormattedDecimal); - } - } - - @Override - public String getLongMonthName(Date date) { - java.text.SimpleDateFormat fmt = new java.text.SimpleDateFormat("MMMM", l); - return fmt.format(date); - } - - @Override - public String getShortMonthName(Date date) { - java.text.SimpleDateFormat fmt = new java.text.SimpleDateFormat("MMM", l); - return fmt.format(date); - } - - - - public String format(int number) { - return NumberFormat.getNumberInstance().format(number); - } - - public String format(double number) { - return NumberFormat.getNumberInstance().format(number); - } - - public String formatCurrency(double currency) { - return NumberFormat.getCurrencyInstance().format(currency); - } - - public String formatDateLongStyle(Date d) { - return DateFormat.getDateInstance(DateFormat.LONG).format(d); - } - - public String formatDateShortStyle(Date d) { - return DateFormat.getDateInstance(DateFormat.SHORT).format(d); - } - - public String formatDateTime(Date d) { - return DateFormat.getDateTimeInstance().format(d); - } - - public String formatDateTimeMedium(Date d) { - DateFormat dd = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM); - return dd.format(d); - } - - public String formatDateTimeShort(Date d) { - DateFormat dd = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT); - return dd.format(d); - } - - public String getCurrencySymbol() { - return NumberFormat.getInstance().getCurrency().getSymbol(); - } - - public void setLocale(String locale, String language) { - super.setLocale(locale, language); - Locale l = new Locale(language, locale); - Locale.setDefault(l); - } - }; - } - return l10n; - } - private com.codename1.ui.util.ImageIO imIO; - - private com.codename1.media.VideoIO videoIO; - private boolean videoIOResolved; - - @Override - public com.codename1.media.VideoIO getVideoIO() { - if (!videoIOResolved) { - videoIOResolved = true; - if (android.os.Build.VERSION.SDK_INT >= 21) { - videoIO = new AndroidVideoIO(); - } - } - return videoIO; - } - - @Override - public com.codename1.ui.util.ImageIO getImageIO() { - if (imIO == null) { - imIO = new com.codename1.ui.util.ImageIO() { - @Override - public Dimension getImageSize(String imageFilePath) throws IOException { - BitmapFactory.Options o = new BitmapFactory.Options(); - o.inJustDecodeBounds = true; - o.inPreferredConfig = Bitmap.Config.ARGB_8888; - - InputStream fis = createFileInputStream(imageFilePath); - BitmapFactory.decodeStream(fis, null, o); - fis.close(); - - ExifInterface exif = new ExifInterface(removeFilePrefix(imageFilePath)); - - // if the image is in portrait mode - int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); - if(orientation == ExifInterface.ORIENTATION_ROTATE_90 || orientation == ExifInterface.ORIENTATION_ROTATE_270) { - return new Dimension(o.outHeight, o.outWidth); - } - return new Dimension(o.outWidth, o.outHeight); - } - - private Dimension getImageSizeNoRotation(String imageFilePath) throws IOException { - BitmapFactory.Options o = new BitmapFactory.Options(); - o.inJustDecodeBounds = true; - o.inPreferredConfig = Bitmap.Config.ARGB_8888; - - InputStream fis = createFileInputStream(imageFilePath); - BitmapFactory.decodeStream(fis, null, o); - fis.close(); - - return new Dimension(o.outWidth, o.outHeight); - } - - @Override - public void save(InputStream image, OutputStream response, String format, int width, int height, float quality) throws IOException { - Bitmap.CompressFormat f = Bitmap.CompressFormat.PNG; - if (FORMAT_JPEG.equals(format)) { - f = Bitmap.CompressFormat.JPEG; - } - Image img = Image.createImage(image).scaled(width, height); - Bitmap b = (Bitmap) img.getImage(); - b.compress(f, (int) (quality * 100), response); - } - - @Override - public String saveAndKeepAspect(String imageFilePath, String preferredOutputPath, String format, int width, int height, float quality, boolean onlyDownscale, boolean scaleToFill) throws IOException{ - ExifInterface exif = new ExifInterface(removeFilePrefix(imageFilePath)); - Dimension d = getImageSizeNoRotation(imageFilePath); - if(onlyDownscale) { - if(scaleToFill) { - if(d.getHeight() <= height || d.getWidth() <= width) { - return imageFilePath; - } - } else { - if(d.getHeight() <= height && d.getWidth() <= width) { - return imageFilePath; - } - } - } - - float ratio = ((float)d.getWidth()) / ((float)d.getHeight()); - int heightBasedOnWidth = (int)(((float)width) / ratio); - int widthBasedOnHeight = (int)(((float)height) * ratio); - if(scaleToFill) { - if(heightBasedOnWidth >= width) { - height = heightBasedOnWidth; - } else { - width = widthBasedOnHeight; - } - } else { - if(heightBasedOnWidth > width) { - width = widthBasedOnHeight; - } else { - height = heightBasedOnWidth; - } - } - sampleSizeOverride = Math.max(d.getWidth()/width, d.getHeight()/height); - OutputStream im = FileSystemStorage.getInstance().openOutputStream(preferredOutputPath); - Image i = Image.createImage(imageFilePath); - Image newImage = i.scaled(width, height); - int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); - - int angle = 0; - switch (orientation) { - case ExifInterface.ORIENTATION_ROTATE_90: - angle = 90; - break; - case ExifInterface.ORIENTATION_ROTATE_180: - angle = 180; - break; - case ExifInterface.ORIENTATION_ROTATE_270: - angle = 270; - break; - } - if (angle != 0) { - Matrix mat = new Matrix(); - mat.postRotate(angle); - Bitmap b = (Bitmap)newImage.getImage(); - Bitmap correctBmp = Bitmap.createBitmap(b, 0, 0, b.getWidth(), b.getHeight(), mat, true); - b.recycle(); - newImage.dispose(); - Image tmp = Image.createImage(correctBmp); - newImage = tmp; - save(tmp, im, format, quality); - } else { - save(imageFilePath, im, format, width, height, quality); - } - sampleSizeOverride = -1; - return preferredOutputPath; - } - - @Override - public void save(String imageFilePath, OutputStream response, String format, int width, int height, float quality) throws IOException { - Image i = Image.createImage(imageFilePath); - Image newImage = i.scaled(width, height); - save(newImage, response, format, quality); - newImage.dispose(); - i.dispose(); - } - - @Override - protected void saveImage(Image img, OutputStream response, String format, float quality) throws IOException { - Bitmap.CompressFormat f = Bitmap.CompressFormat.PNG; - if (FORMAT_JPEG.equals(format)) { - f = Bitmap.CompressFormat.JPEG; - } - Bitmap b = (Bitmap) img.getImage(); - b.compress(f, (int) (quality * 100), response); - } - - @Override - public boolean isFormatSupported(String format) { - return FORMAT_JPEG.equals(format) || FORMAT_PNG.equals(format); - } - }; - } - return imIO; - } - - @Override - public Database openOrCreateDB(String databaseName) throws IOException { - // Reserved first, and recovery run inside the reservation. The slot has to be taken - // before the engine opens anything, or a conversion reading the count during the open - // starts replacing the file this is about to hand back -- and recovery has to be inside - // it too, because a conversion that has just installed its converted file leaves the live - // file and the backup both present, which recovery would otherwise read as a completed - // conversion and act on by deleting the backup. - String nativePath = resolveNativeDatabasePath(databaseName); - reserveDatabaseConnection(nativePath); - SQLiteDatabase db; - try { - // A plaintext open of a database mid-conversion would create an empty one over the - // top of the real data, which nothing afterwards could undo. - // - // One connection is allowed to be open here, and it is the reservation taken above. - // Anything beyond that is somebody else's handle -- including one taken through the - // constructor that wraps an already-open connection -- and recovery moves the file - // out from under it. When that is the case and a conversion is waiting to be - // finished, this open is refused rather than handing back a file recovery is going - // to replace; with nothing waiting there is nothing to recover and the open goes - // ahead as before. - recoverIfSoleConnection(nativePath); - if (databaseName.startsWith("file://")) { - db = SQLiteDatabase.openOrCreateDatabase( - FileSystemStorage.getInstance().toNativePath(databaseName), null, - KEEP_ON_CORRUPTION); - } else { - db = getContext().openOrCreateDatabase(databaseName, getContext().MODE_PRIVATE, - null, KEEP_ON_CORRUPTION); - } - } catch (RuntimeException didNotOpen) { - databaseConnectionClosed(nativePath); - // The engine reports a file it cannot read by throwing an unchecked - // SQLiteDatabaseCorruptException, and an encrypted database opened without its key is - // exactly that to the plain engine. This API promises every failure as an IOException, - // so the caller can catch one thing rather than an unchecked type per platform. - throw new IOException("The database " + databaseName + " could not be opened: " - + didNotOpen.getMessage(), didNotOpen); - } catch (IOException didNotRecover) { - databaseConnectionClosed(nativePath); - throw didNotRecover; - } - return new AndroidDB(db, nativePath); - } - - @Override - public Database openOrCreateDB(String databaseName, com.codename1.db.DatabaseConfig config) throws IOException { - if (config == null || !config.isEncrypted()) { - return openOrCreateDB(databaseName); - } - // The slot is taken before the engine opens anything, for the reason given in - // openOrCreateDB. AndroidCipherFactory hands back a connection that already holds it. - String nativePath = resolveNativeDatabasePath(databaseName); - reserveDatabaseConnection(nativePath); - // The SQLCipher-backed package is deleted at build time for apps that never touch - // DatabaseConfig, so it has to be reached reflectively - the same arrangement the - // ARCore-backed AR implementation uses. - Object opened; - try { - Class c = Class.forName("com.codename1.impl.android.cipher.AndroidCipherFactory"); - java.lang.reflect.Method open = c.getMethod("open", String.class, String.class, - String.class); - // Cast outside the try, below: inside a block that catches Throwable, a wrong type - // from the reflective call would be swallowed and reported as the package being - // absent. The resolved file, not the name it was asked for: a managed key with no explicit - // alias is stored under whatever is passed here, so two accepted spellings of one - // database would derive two different keys and the second open would report a wrong - // key against data that is perfectly intact. - opened = open.invoke(null, - resolveNativeDatabasePath(databaseName), databaseName, - config.resolveKeyMaterial(databaseKey(nativePath))); - } catch (java.lang.reflect.InvocationTargetException err) { - releaseUnusedDatabaseConnection(nativePath); - Throwable cause = err.getCause(); - if (cause instanceof IOException) { - throw (IOException) cause; - } - throw new IOException(cause == null ? err.toString() : cause.getMessage(), cause); - } catch (IOException err) { - releaseUnusedDatabaseConnection(nativePath); - throw err; - } catch (ClassNotFoundException notBundled) { - // The only benign reason to land here: the build pruned the package because the - // application never referenced DatabaseConfig. - releaseUnusedDatabaseConnection(nativePath); - throw new com.codename1.db.DatabaseEncryptionException( - com.codename1.db.DatabaseEncryptionException.NOT_SUPPORTED, - "This build does not include encrypted database support", notBundled); - } catch (NoSuchMethodException broken) { - // The package is present but does not expose the entry point this reaches through. - // That is a broken build, not an unsupported platform, and reporting it as - // NOT_SUPPORTED would hide it: every caller would be told encryption is unavailable - // on a device that ships the engine. This is the failure mode a compiler would have - // caught if the seam were not reflective, so it has to be loud. - releaseUnusedDatabaseConnection(nativePath); - throw new IOException("The encrypted database implementation is present but does not " - + "expose the expected entry point. This build is inconsistent: " - + broken.getMessage(), broken); - } catch (Throwable err) { - releaseUnusedDatabaseConnection(nativePath); - throw new com.codename1.db.DatabaseEncryptionException( - com.codename1.db.DatabaseEncryptionException.NOT_SUPPORTED, - "This build does not include encrypted database support", err); - } - if (!(opened instanceof Database)) { - releaseUnusedDatabaseConnection(nativePath); - throw new IOException("The encrypted database implementation returned " - + (opened == null ? "nothing" : opened.getClass().getName()) - + " rather than a Database. This build is inconsistent."); - } - return (Database) opened; - } - - /// The file an implicit managed key is stored under; see the open path, which resolves the - /// same way so two spellings of one database derive one key. - @Override - public String databaseManagedKeyIdentity(String databaseName) { - // Canonical, like the connection registry: resolveNativeDatabasePath leaves a custom - // spelling as it was given, so "/data/app/./db.sqlite" and "/data/app/db.sqlite" would - // otherwise pick different stored keys for one file and report the second open as wrong. - return databaseKey(resolveNativeDatabasePath(databaseName)); - } - - @Override - public boolean isDatabaseEncryptionSupported() { - Object available; - try { - Class c = Class.forName("com.codename1.impl.android.cipher.AndroidCipherFactory"); - available = c.getMethod("isAvailable").invoke(null); - } catch (Throwable notPresent) { - return false; - } - // Tested rather than cast inside the try: the reflective answer is untyped, and - // anything but a Boolean means the feature is unavailable rather than absent. - return available instanceof Boolean && ((Boolean) available).booleanValue(); - } - - @Override - public boolean isDatabaseManagedKeyHardwareBacked() { - // Ask the key itself. An API level says only that the API exists: emulators, and plenty of - // real devices, back AndroidKeyStore keys in software. Applications are told they may use - // this to refuse to store sensitive data, so it has to describe the actual key. - return AndroidSecureStorage.isPlainKeyInsideSecureHardware(); - } - - /** - * Absolute filesystem path for a database name, converting a custom file:// URL. - * - * getDatabasePath() deliberately echoes a file:// URL back unchanged, which is right for - * callers that hand it to FileSystemStorage but wrong for anything constructing a java.io.File - * from it. - */ - /// Directory holding the encrypted-database migration's working files. - /// - /// A directory beside the database, so the rename that installs the converted file stays - /// within one filesystem and is therefore atomic. - /// - /// The location alone does not make these files ours. Custom paths mean an application can - /// point a database anywhere, including inside here, so ownership is established by the - /// marker's contents rather than by where a file sits or what it is called. Nothing is - /// deleted, renamed over or truncated without that proof. - public static final String DATABASE_MIGRATION_DIR = ".cn1migration"; - - /// Marker name for a database. Deterministic so recovery can find it; its contents, not its - /// name, are what establish that a conversion wrote it. - public static final String MIGRATION_MARKER = ".marker"; - - /// Fourth line of a marker whose installed file was never shown to open. - private static final String MIGRATION_UNVALIDATED = "unvalidated"; - - /// First line of a marker written by this port. - private static final String MIGRATION_MARKER_MAGIC = "codename1-database-migration-1"; - - /// The migration directory for a database, or null if the path has no parent. - public static File databaseMigrationDir(String path) { - File parent = new File(path).getParentFile(); - return parent == null ? null : new File(parent, DATABASE_MIGRATION_DIR); - } - - public static File databaseMigrationMarker(String path) { - File dir = databaseMigrationDir(path); - return dir == null ? null : new File(dir, new File(path).getName() + MIGRATION_MARKER); - } - - /// Reads a marker written by this port, or null when the file is not one of ours. - /// - /// A marker is trusted only if it opens with the magic line. Anything else - including an - /// application database that happens to live at this path - is left alone. - /// - /// The two entries after it are the file holding the original and the export being built, - /// either of which may be absent: the marker is written before the export is filled in and - /// rewritten once the original has been moved aside, so which files exist depends on how far - /// the conversion got. - /// - /// What this does NOT defend against, deliberately: an actor who can write in the migration - /// directory can still write a marker naming files inside it. The magic line is in the - /// source, so it authenticates nothing -- and there is no secret this port could sign a - /// marker with that the same actor could not read out of the application. The damage is - /// bounded to that one directory, which that actor can already write to and delete from - /// directly, so the check earns its keep by keeping the names inside it rather than by - /// pretending the file is trusted. - /// - /// A rejected marker is treated as somebody else's file: recovery leaves it alone and a - /// conversion refuses to start rather than overwriting it, with a message naming the file. A - /// crafted marker therefore stops conversions of that one database until it is removed, which - /// is the outcome to prefer over acting on it. - /// - /// @return the two names, either element null, or null if this is not our marker - private static String[] readDatabaseMigrationMarker(String path) { - File marker = databaseMigrationMarker(path); - if (marker == null || !marker.isFile()) { - return null; - } - BufferedReader reader = null; - try { - reader = new BufferedReader(new InputStreamReader(new FileInputStream(marker), - "UTF-8")); - if (!MIGRATION_MARKER_MAGIC.equals(reader.readLine())) { - return null; - } - String backup = reader.readLine(); - String target = reader.readLine(); - String state = reader.readLine(); - String backupName = backup == null || backup.length() == 0 ? null : backup; - String targetName = target == null || target.length() == 0 ? null : target; - // The names this port writes are basenames createTempFile produced in the migration - // directory, and they are read back as files to truncate, delete and rename over. A - // marker is a plain text file beside the database, so where the database sits - // somewhere another actor can write -- which a custom path can -- an entry like - // "../../../files/secret" would be resolved against that directory and handed to the - // cleanup, which truncates and deletes what it is given. Anything that is not a - // simple name inside this directory means the file is not one of ours, which is the - // answer that stops every caller: recovery leaves it alone and a conversion refuses - // to overwrite it rather than starting. - File dir = databaseMigrationDir(path); - if ((backupName != null && !isMigrationEntryName(backupName, dir)) - || (targetName != null && !isMigrationEntryName(targetName, dir))) { - return null; - } - return new String[] { - backupName, - targetName, - state == null || state.length() == 0 ? null : state, - }; - } catch (IOException unreadable) { - return null; - } finally { - if (reader != null) { - try { - reader.close(); - } catch (IOException ignored) { - // Nothing useful to do. - } - } - } - } - - /// Whether a name a marker carries is one this port could have written there. - /// - /// A generated basename, and a file that really is a direct child of the migration directory: - /// the first rejects a path that climbs out of it, the second rejects a name inside it that - /// is a link to somewhere else. Both are checked because either alone can be walked around -- - /// a name with no separator can still be a symlink, and a canonical check on its own would - /// accept "sub/dir/../file". - /// - /// #### Parameters - /// - /// - `name`: the entry read from the marker - /// - `directory`: the migration directory the marker lives in - /// - /// #### Returns - /// - /// true if the name is safe to resolve against that directory - private static boolean isMigrationEntryName(String name, File directory) { - if (directory == null || name.length() == 0 || ".".equals(name) || "..".equals(name)) { - return false; - } - if (name.indexOf('/') >= 0 || name.indexOf('\\') >= 0 || name.indexOf('\u0000') >= 0) { - return false; - } - try { - File resolved = new File(directory, name).getCanonicalFile(); - File parent = resolved.getParentFile(); - return parent != null && parent.equals(directory.getCanonicalFile()); - } catch (IOException cannotResolve) { - // A name that cannot be resolved is not one that gets acted on. - return false; - } - } - - /// Whether the marker for this database was written by this port. - /// - /// Distinct from having a backup: a marker written before the export was filled in names no - /// backup yet, and is still ours to rewrite. - private static boolean ownsDatabaseMigrationMarker(String path) { - return readDatabaseMigrationMarker(path) != null; - } - - /// Reads the backup a marker claims, or null when there is none. - public static File readDatabaseMigrationBackup(String path) { - String[] entry = readDatabaseMigrationMarker(path); - if (entry == null || entry[0] == null) { - return null; - } - return new File(databaseMigrationMarker(path).getParentFile(), entry[0]); - } - - /// Whether the marker says its installed file was never shown to open. - private static boolean isDatabaseMigrationUnvalidated(String path) { - String[] entry = readDatabaseMigrationMarker(path); - return entry != null && entry.length > 2 && MIGRATION_UNVALIDATED.equals(entry[2]); - } - - /// Reads the export a marker claims, or null when there is none. - /// - /// The export is a second complete copy of the data, and a plaintext one when the conversion - /// was a decryption, so it is recorded before anything is written into it. Otherwise a process - /// death between creating it and finishing the conversion would leave readable data behind - /// under a name nothing knows to look for. - public static File readDatabaseMigrationTarget(String path) { - String[] entry = readDatabaseMigrationMarker(path); - if (entry == null || entry[1] == null) { - return null; - } - return new File(databaseMigrationMarker(path).getParentFile(), entry[1]); - } - - /// Every database connection this port has open, by the file it is open on. - /// - /// Shared by both implementations on purpose. Only a conversion needs it, and a conversion is - /// not a statement: it renames a new file over the database while the process is running, and - /// Android lets that succeed while another connection holds the old one. That connection goes - /// on writing to a file that is no longer the database, is told each write succeeded, and - /// loses all of it when the backup is deleted. - /// - /// The connection it collides with is usually not another encrypted one -- the ordinary case - /// is an application holding `Database.openOrCreate(name)` open, which is a plaintext - /// connection, and then calling `Database.encrypt(name, ...)`. Counting only the encrypted - /// ones would miss exactly the case that happens. - private static final java.util.Map OPEN_DATABASE_CONNECTIONS = - new java.util.HashMap(); - - /// The key a database file is tracked under. - /// - /// Canonical, because two spellings of one file must not be two entries: a connection opened - /// as `/data/app/db.sqlite` has to be visible to a conversion started as - /// `/data/app/./db.sqlite`, or the file is replaced underneath it and its later writes -- each - /// one reported as successful -- disappear with the old inode. `toNativePath` only strips the - /// `file://` prefix, so a custom path arrives however the caller spelled it. - /// - /// Falls back to the absolute path when the file system cannot answer, which still collapses - /// the relative spellings; a canonical path that cannot be resolved is not a reason to refuse - /// to open a database. - /// The canonical identity of a database file, for callers outside this class. - /// - /// The cipher package resolves a managed key against it, so that its key change and the next - /// open agree on which file they are talking about. - public static String canonicalDatabaseKey(String path) { - return databaseKey(path); - } - - private static String databaseKey(String path) { - if (path == null) { - return null; - } - try { - return new File(path).getCanonicalPath(); - } catch (IOException cannotResolve) { - return new File(path).getAbsolutePath(); - } - } - - /// Records a connection opened on a database file. - public static synchronized void databaseConnectionOpened(String rawPath) { - String path = databaseKey(rawPath); - if (path == null) { - return; - } - Integer count = OPEN_DATABASE_CONNECTIONS.get(path); - OPEN_DATABASE_CONNECTIONS.put(path, - Integer.valueOf(count == null ? 1 : count.intValue() + 1)); - } - - /// Records a connection closed on a database file. - public static synchronized void databaseConnectionClosed(String rawPath) { - String path = databaseKey(rawPath); - if (path == null) { - return; - } - Integer count = OPEN_DATABASE_CONNECTIONS.get(path); - if (count == null) { - return; - } - if (count.intValue() <= 1) { - OPEN_DATABASE_CONNECTIONS.remove(path); - } else { - OPEN_DATABASE_CONNECTIONS.put(path, Integer.valueOf(count.intValue() - 1)); - } - } - - /// Database files a conversion currently owns exclusively. - private static final java.util.Set MIGRATING_DATABASES = - new java.util.HashSet(); - - /// Claims a database for a conversion, or refuses. - /// - /// Counting the connections and then converting are one decision, not two. Between a count - /// read on its own and the rename that ends the conversion, another thread can open the - /// database, and that connection then holds the file the rename replaces: its writes are - /// accepted and disappear when the backup goes. So the count is read and the claim taken - /// under the same lock the opens take, and an open that arrives afterwards is refused for as - /// long as the conversion runs. - /// - /// #### Parameters - /// - /// - `path`: the database file - /// - /// #### Throws - /// - /// - `IOException`: if the database is open elsewhere, or already being converted - public static synchronized void beginDatabaseMigration(String rawPath) throws IOException { - String path = databaseKey(rawPath); - if (MIGRATING_DATABASES.contains(path)) { - throw new IOException("The database " + path + " is already being converted."); - } - Integer count = OPEN_DATABASE_CONNECTIONS.get(path); - if (count != null && count.intValue() > 1) { - throw new IOException("The database " + path + " is open more than once, and " - + "converting it replaces the file underneath every connection to it. Close " - + "the other connections first; writes made through them during the " - + "conversion would be accepted and then lost."); - } - MIGRATING_DATABASES.add(path); - } - - /// Recovers an interrupted conversion, but only for an open that has the file to itself. - /// - /// Called from the open paths, plaintext and encrypted, each of which has already reserved - /// its own connection -- so one open connection is this caller and anything beyond it is - /// somebody else's handle, including one taken through the constructor that wraps an - /// already-open connection. Recovery renames the live file aside and puts a backup back, and - /// a connection attached to the displaced file keeps accepting writes that go nowhere, so it - /// is left for the next open that has the file alone. - /// - /// #### Parameters - /// - /// - `rawPath`: the database file - /// - /// #### Throws - /// - /// - `IOException`: if the recovery itself fails - public static void recoverIfSoleConnection(String rawPath) throws IOException { - if (claimDatabaseForRecovery(rawPath, 1)) { - try { - recoverInterruptedDatabaseMigration(rawPath); - } finally { - endDatabaseMigration(rawPath); - } - return; - } - if (hasInterruptedDatabaseMigration(rawPath)) { - // Recovery could not run and there is work waiting for it, which means the file this - // open would hand back is one recovery is going to replace. Two handles writing to it - // in the meantime would both be told their writes succeeded, and the next open with - // the file to itself would restore the backup over the top of them. Refusing is the - // only answer that does not accept writes it cannot keep. - throw new IOException("The database " + rawPath + " has a conversion that was " - + "interrupted, and it cannot be finished while another connection holds the " - + "file. Close the other connections and open it again; the data is intact " - + "and will be put back then."); - } - } - - /// Whether a conversion of this database was interrupted and still has work waiting. - /// - /// A marker this port wrote is the record of that. One written by something else is not ours - /// to read, and recovery leaves it alone for the same reason. - /// - /// #### Parameters - /// - /// - `rawPath`: the database file - /// - /// #### Returns - /// - /// true when recovery has something to do - private static boolean hasInterruptedDatabaseMigration(String rawPath) { - File marker = databaseMigrationMarker(rawPath); - return marker != null && marker.isFile() && ownsDatabaseMigrationMarker(rawPath); - } - - /// Takes the conversion claim for a recovery, or reports that a conversion already holds it. - /// - /// Recovery moves the same three files a conversion does, so the two must not overlap. The - /// claim is the conversion's own, so a conversion starting while recovery runs is refused by - /// `#beginDatabaseMigration(String)` exactly as a second conversion would be. - /// - /// #### Parameters - /// - /// - `rawPath`: the database file - /// - /// #### Returns - /// - /// true when the claim was taken and must be given back - private static synchronized boolean claimDatabaseForRecovery(String rawPath, - int connectionsOfOurOwn) { - String path = databaseKey(rawPath); - if (path == null || MIGRATING_DATABASES.contains(path)) { - return false; - } - Integer count = OPEN_DATABASE_CONNECTIONS.get(path); - if (count != null && count.intValue() > connectionsOfOurOwn) { - // Somebody else holds the file. Recovery renames the live file aside and puts a - // backup back, and a connection already attached to the displaced file keeps - // accepting writes that go nowhere -- worst of all for a conversion whose converted - // file was never validated, where the backup is what recovery installs. Refusing - // leaves the marker in place for the next open that has the file to itself. - return false; - } - MIGRATING_DATABASES.add(path); - return true; - } - - /// Whether a conversion currently owns a database file. - public static synchronized boolean isDatabaseBeingConverted(String rawPath) { - return MIGRATING_DATABASES.contains(databaseKey(rawPath)); - } - - /// Releases a database claimed by `#beginDatabaseMigration(String)`. - public static synchronized void endDatabaseMigration(String rawPath) { - MIGRATING_DATABASES.remove(databaseKey(rawPath)); - } - - /// Gives back a slot taken by `#reserveDatabaseConnection(String)` when no connection was - /// handed to the caller after all. - public static void releaseUnusedDatabaseConnection(String path) { - databaseConnectionClosed(path); - } - - /// Takes a connection slot on a database, or refuses because a conversion owns it. - /// - /// The check and the count are one step. Checking that no conversion is running and then - /// registering afterwards leaves a gap: the engine's open sits between them, and a conversion - /// that reads the count during it sees only its own connection, takes its claim, and starts - /// replacing the file the open is about to return a connection to. Taking the slot inside the - /// same lock as the check closes that -- a conversion either sees the slot and refuses, or - /// holds the claim and the open refuses. - /// - /// The caller releases the slot with `#databaseConnectionClosed(String)` if the open itself - /// then fails, and the connection releases it on close. - /// - /// #### Throws - /// - /// - `IOException`: if a conversion currently owns the file - public static synchronized void reserveDatabaseConnection(String rawPath) throws IOException { - String path = databaseKey(rawPath); - if (path != null && com.codename1.db.Database.isDatabaseBeingDeleted(path)) { - // The claim the delete holds, not one of this port's: it is taken before the count - // this method increments is read, so an open arriving mid-delete is refused here and - // an open that got in first is seen by that count. A claim of our own, taken when - // the delete reached this port, would have been too late -- the count had already - // been read by then, and an open landing in between would have been handed a file - // about to lose its name. - throw new IOException("The database " + path + " is being deleted and cannot be " - + "opened."); - } - if (path != null && MIGRATING_DATABASES.contains(path)) { - throw new IOException("The database " + path + " is being converted and cannot be " - + "opened until that finishes."); - } - databaseConnectionOpened(path); - } - - /// How many connections are open on a database file, encrypted or not. - public static synchronized int connectionsOpenOn(String rawPath) { - Integer count = OPEN_DATABASE_CONNECTIONS.get(databaseKey(rawPath)); - return count == null ? 0 : count.intValue(); - } - - /// Disposes of an export, and reports anything that survived. - /// - /// If the file cannot be unlinked it is truncated instead, which removes the contents even - /// where the directory entry survives. - /// - /// @return a sentence to append to a failure message, empty when nothing survived - public static String discardDatabaseMigrationExport(File target) { - if (target == null) { - return ""; - } - // The sidecars before anything else, and through the platform's own deletion, which knows - // the whole set: -wal, -shm, -journal and the master journals. A database written here - // leaves rows in those, so removing the file alone left the data behind under a name - // nobody was looking at -- which is the one thing this method exists to prevent. It is - // also the case that matters most, since the export is a complete copy of the database, - // in plaintext whenever the conversion was a decrypt. - android.database.sqlite.SQLiteDatabase.deleteDatabase(target); - String survivingSidecars = discardDatabaseSidecars(target); - if (!target.exists() || target.delete()) { - return survivingSidecars; - } - if (isSymbolicLink(target)) { - // Emptying follows the link, and what it would empty is whatever the link points at. - // The name was checked before any of this began, but a directory another actor can - // write to can have that name replaced afterwards, and unlinking a link that cannot - // be unlinked leaves this holding a name that now means somebody else's file. - // Reported instead: the export could not be removed, and nothing else is touched. - return " A complete copy of the data was left at " + target.getPath() - + ", which is now a link and was left alone; delete it." + survivingSidecars; - } - try { - new FileOutputStream(target).close(); - } catch (IOException cannotEmptyIt) { - return " A complete copy of the data was left at " + target.getPath() - + " and could not be removed; delete it." + survivingSidecars; - } - if (!target.exists() || target.delete()) { - return survivingSidecars; - } - return " An emptied file was left at " + target.getPath() + "." + survivingSidecars; - } - - /// Whether a name now resolves to something other than itself. - /// - /// Everything under the migration directory was checked to be a plain name inside it before - /// any of it was acted on. That check happens once, and a directory another actor can write to - /// can have an entry replaced between then and the cleanup -- so anything that opens a file - /// rather than unlinking it asks again, immediately before it opens it. - /// - /// Unlinking needs no such question: removing a link removes the link. Emptying does, because - /// a stream follows it and empties whatever it points at. - /// - /// Compares the canonical path with the absolute one rather than using a no-follow open, which - /// this port cannot reach at the API levels it supports. It does not close the window between - /// the question and the open, and cannot from Java; it does stop the case that makes the - /// window worth anything, which is a link that has been left in place because it could not be - /// unlinked. - /// - /// #### Parameters - /// - /// - `f`: the entry about to be opened - /// - /// #### Returns - /// - /// true if it is a link, or if that could not be determined - private static boolean isSymbolicLink(File f) { - try { - return !f.getCanonicalFile().equals(f.getAbsoluteFile()); - } catch (IOException cannotResolve) { - // Unresolvable is treated as a link: this only decides whether to open something, and - // not opening it costs a message where opening it could truncate another file. - return true; - } - } - - /// Disposes of the files SQLite keeps beside a database, and reports anything that survived. - /// - /// Called after the platform's own deletion rather than instead of it: that removes them in - /// the ordinary case, and this is what happens when one could not be unlinked. Emptying is - /// the fallback for the same reason it is for the database itself -- a file that cannot be - /// removed can still be stripped of what it holds. - /// - /// @param target the database file whose companions these are - /// @return a sentence to append to a failure message, empty when nothing survived - private static String discardDatabaseSidecars(File target) { - String[] suffixes = {"-wal", "-shm", "-journal"}; - StringBuilder left = new StringBuilder(); - for (int iter = 0; iter < suffixes.length; iter++) { - File sidecar = new File(target.getPath() + suffixes[iter]); - if (!sidecar.exists() || sidecar.delete()) { - continue; - } - if (isSymbolicLink(sidecar)) { - // As above: emptying a link empties its target, and the target is not ours. - left.append(" A working file was left at ").append(sidecar.getPath()) - .append(", which is now a link and was left alone."); - continue; - } - try { - new FileOutputStream(sidecar).close(); - } catch (IOException cannotEmptyIt) { - left.append(" Part of the data was left at ").append(sidecar.getPath()) - .append(" and could not be removed; delete it."); - continue; - } - if (sidecar.exists() && !sidecar.delete()) { - left.append(" An emptied file was left at ").append(sidecar.getPath()).append("."); - } - } - return left.toString(); - } - - /// Records that a conversion is under way and which file holds the original. - /// - /// The marker is the one file here whose name has to be predictable, because recovery has to - /// find it without being told. So it is the one place something could already be sitting - - /// an application may point a database at this exact path - and writing over it would - /// destroy that database. Anything already there that this port did not write means the - /// conversion does not start. - /// Marks a conversion whose installed file was never shown to open. - /// - /// Recovery reads a live file and a backup both being present as a completed conversion and - /// removes the backup. That is right when the converted file opened, and catastrophic when it - /// did not and could not be taken back out either: the last readable copy would go. This - /// records the difference, and recovery puts the backup back instead. - public static void markDatabaseMigrationUnvalidated(String path, File backup) - throws IOException { - writeMarker(path, backup, null, true); - } - - /// The same, for a conversion whose export has not been installed yet. - /// - /// The export has to stay named while it still exists under its own name, or recovery cannot - /// find it to clean it up -- and a conversion interrupted here leaves a complete copy of the - /// database in the migration directory, which after a decryption is a plaintext one. - /// - /// #### Parameters - /// - /// - `path`: the live database - /// - `backup`: the file the original was moved to - /// - `target`: the export, while it is still under its own name - /// - /// #### Throws - /// - /// - `IOException`: if the record cannot be written - public static void markDatabaseMigrationUnvalidated(String path, File backup, File target) - throws IOException { - writeMarker(path, backup, target, true); - } - - public static void writeDatabaseMigrationMarker(String path, File backup, File target) - throws IOException { - writeMarker(path, backup, target, false); - } - - private static void writeMarker(String path, File backup, File target, boolean unvalidated) - throws IOException { - File marker = databaseMigrationMarker(path); - if (marker == null) { - throw new IOException("The database " + path + " has no directory to convert it in"); - } - if (marker.exists() && !ownsDatabaseMigrationMarker(path)) { - throw new IOException("There is already a file at " + marker + " that this port did " - + "not write, so the conversion was not started rather than overwriting it. " - + "Move it aside if it is not a database you need."); - } - // Written beside the marker and renamed over it, never written into it. The second call - // updates a marker that is already valid and already naming a file holding data, and - // opening it for writing truncates it first: a process death in that window leaves a - // marker that recovery cannot recognise, so it acts on nothing and the export it named is - // orphaned. A rename is atomic, so the marker is only ever the old contents or the new. - // The marker's own name already carries the ".marker" suffix, so it is never short - // enough for createTempFile to reject the prefix. - File pending = File.createTempFile(marker.getName() + ".", ".pending", - marker.getParentFile()); - Writer writer = new OutputStreamWriter(new FileOutputStream(pending), "UTF-8"); - try { - writer.write(MIGRATION_MARKER_MAGIC); - writer.write("\n"); - writer.write(backup == null ? "" : backup.getName()); - writer.write("\n"); - writer.write(target == null ? "" : target.getName()); - writer.write("\n"); - writer.write(unvalidated ? MIGRATION_UNVALIDATED : ""); - writer.write("\n"); - } finally { - writer.close(); - } - // renameTo replaces an existing destination on the filesystems Android puts databases on. - // Deleting first would reopen exactly the window this is here to close. - if (!pending.renameTo(marker)) { - pending.delete(); - throw new IOException("The record of the conversion at " + marker + " could not be " - + "written, so the conversion was not started."); - } - } - - /// Restores a database whose conversion was interrupted between the two renames. - /// - /// Called before every open, encrypted or not. Encrypt and decrypt move the original aside - /// and install the converted file in its place, so a process death in that gap leaves a - /// complete database in the migration directory and nothing under the live name. Putting it - /// back is what makes that window recoverable rather than a silent empty database. - /// - /// Acts only on a marker this port wrote, and only on the backup that marker names. - public static void recoverInterruptedDatabaseMigration(String path) throws IOException { - if (path == null) { - return; - } - File marker = databaseMigrationMarker(path); - if (marker == null || !marker.isFile() || !ownsDatabaseMigrationMarker(path)) { - // Nothing of ours is here, and nothing of anybody else's gets touched. A file at this - // name that this port did not write belongs to someone -- a custom database path can - // legitimately put another database here -- and this runs before every open, so acting - // on it would mean that opening one database destroys an unrelated one. - return; - } - // The export first, whatever else is true. It is a second complete copy of the data, and - // a plaintext one when the conversion was a decryption, so an interrupted conversion must - // not leave it lying in the migration directory. It is only ever installed by being - // renamed over the live database, so anything still under its own name is an orphan. - File orphanedExport = readDatabaseMigrationTarget(path); - if (orphanedExport != null && orphanedExport.exists()) { - String surviving = discardDatabaseMigrationExport(orphanedExport); - if (surviving.length() > 0) { - throw new IOException("The database " + path + " has an interrupted conversion " - + "whose working copy could not be cleaned up." + surviving); - } - } - File backup = readDatabaseMigrationBackup(path); - if (backup == null) { - // No original was moved aside, so the conversion never reached the swap. Only the - // export existed, and it is gone. - marker.delete(); - return; - } - File live = new File(path); - if (!backup.isFile()) { - // The marker outlived its backup, so there is nothing to put back or clean up. - marker.delete(); - return; - } - if (!live.exists()) { - // Died between the two renames: the backup is the only copy. Put it back, and refuse - // to continue if that fails - opening would create an empty database over the top and - // the next conversion would remove the backup as stale, losing the data for good. - if (!backup.renameTo(live)) { - throw new IOException("The database " + path + " is mid-conversion and the copy " - + "holding its contents, at " + backup + ", could not be moved back. The " - + "data is intact in that file; the database was not opened rather than " - + "replacing it with an empty one."); - } - marker.delete(); - return; - } - if (isDatabaseMigrationUnvalidated(path)) { - // The converted file is in place but was never shown to open, and the conversion could - // not take it back out. Both files existing is not evidence of success here, so the - // backup goes back rather than away: deleting it would drop the last readable copy. - File displaced = unusedSibling(path + ".unvalidated"); - if (displaced == null) { - throw new IOException("The database " + path + " holds a converted file that was " - + "never shown to open, and there is nowhere to move it aside to. The " - + "original is intact at " + backup + "; nothing was overwritten."); - } - // Named in the marker before the first rename, in the slot an export is named in. - // The two renames below are not one step: a process dying between them leaves the - // converted file under a name nothing knows about, and the recovery after that takes - // the branch above -- restores the backup, deletes the marker, and leaves that file - // beside the database for good. After a failed decryption it is a plaintext copy. - // Recorded first, the next recovery finds it exactly where it finds an abandoned - // export, and discards it the same way. - try { - markDatabaseMigrationUnvalidated(path, backup, displaced); - } catch (IOException cannotRecord) { - throw new IOException("The database " + path + " holds a converted file that was " - + "never shown to open, and where it is about to be moved could not be " - + "recorded. The original is intact at " + backup + "; nothing was moved.", - cannotRecord); - } - if (!live.renameTo(displaced) || !backup.renameTo(live)) { - throw new IOException("The database " + path + " holds a converted file that was " - + "never shown to open, and the original at " + backup + " could not be " - + "put back. The data is in that file; it was left there rather than " - + "removed."); - } - // The same cleanup an abandoned export gets, and for the same reason: this file is a - // complete copy of the database, and after a failed decryption it is the plaintext - // one. A delete() whose result nobody reads would leave it beside the restored - // database under a predictable name while recovery reported success. - String surviving = discardDatabaseMigrationExport(displaced); - if (surviving.length() > 0) { - throw new IOException("The database " + path + " was restored from its backup, but" - + " the converted copy could not be removed." + surviving); - } - marker.delete(); - return; - } - // Both exist, so the swap completed and only the cleanup was lost. The backup is the - // database in its previous form, which after an encrypt is a plaintext copy of an - // encrypted database - the encryption-at-rest hole in slow motion. - if (!backup.delete() && backup.exists()) { - throw new IOException("The database " + path + " was converted, but the copy of its " - + "previous form at " + backup + " could not be removed. Delete it before " - + "relying on this database being encrypted."); - } - marker.delete(); - } - - /// A path near `preferred` that no file occupies, or null if too many are taken. - /// - /// The recovery moves the rejected file aside before putting the original back, and on these - /// filesystems a rename replaces whatever is at the destination. A custom database path can put - /// that destination anywhere the application also keeps files, so writing to it blind would let - /// a failed conversion destroy an unrelated file of the application's while reporting that it - /// recovered cleanly. - private static File unusedSibling(String preferred) { - File candidate = new File(preferred); - if (!candidate.exists()) { - return candidate; - } - for (int iter = 1; iter < 100; iter++) { - candidate = new File(preferred + "." + iter); - if (!candidate.exists()) { - return candidate; - } - } - return null; - } - - /// Removes the working files for a database, reporting anything it could not remove. - /// - /// Used by delete, where the caller's intent is that the data goes away. A failure here has - /// to stop the deletion: continuing would report success while a complete copy of the - /// database survives, and a later open would restore it. - static void discardDatabaseMigrationArtifacts(String path) throws IOException { - if (path == null) { - return; - } - File export = readDatabaseMigrationTarget(path); - if (export != null && export.exists()) { - String surviving = discardDatabaseMigrationExport(export); - if (surviving.length() > 0) { - throw new IOException("The database " + path + " was not deleted, because the " - + "working copy of its interrupted conversion could not be removed." - + surviving); - } - } - File backup = readDatabaseMigrationBackup(path); - if (backup == null) { - File onlyMarker = databaseMigrationMarker(path); - if (onlyMarker != null && onlyMarker.isFile() && ownsDatabaseMigrationMarker(path) - && !onlyMarker.delete() && onlyMarker.exists()) { - throw new IOException("The database " + path + " was not deleted, because the " - + "record of its interrupted conversion at " + onlyMarker + " could not " - + "be removed."); - } - return; - } - if (backup.exists() && !backup.delete() && backup.exists()) { - throw new IOException("The database " + path + " was not deleted, because the copy of " - + "it at " + backup + " could not be removed and a later open would restore " - + "it."); - } - File marker = databaseMigrationMarker(path); - if (marker.exists() && !marker.delete() && marker.exists()) { - throw new IOException("The database " + path + " was not deleted, because the record " - + "of its interrupted conversion at " + marker + " could not be removed."); - } - } - - /// Whether a marked migration backup is holding a database's contents. - static boolean hasRecoverableDatabaseBackup(String path) { - File backup = readDatabaseMigrationBackup(path); - return backup != null && backup.isFile(); - } - - /// Leaves a database that will not open where it is. - /// - /// The platform default answers corruption by deleting the file. An encrypted database opened - /// without its key is ciphertext to the plain engine, which is indistinguishable from - /// corruption -- so a single accidental openOrCreate(name) against an encrypted database - /// destroyed it, and destroyed it in the one case where the data was perfectly intact and one - /// correct-key open away from being readable. - /// - /// Keeping the file turns that into a failed open, which is what a wrong key should be. A - /// genuinely corrupt database is kept too, which is the answer every other port gives: - /// reporting the failure and leaving the bytes for a backup or a repair tool beats deleting - /// them on the application's behalf. - private static final class KeepDatabaseOnCorruption - implements android.database.DatabaseErrorHandler { - @Override - public void onCorruption(SQLiteDatabase databaseObject) { - com.codename1.io.Log.p("Database " + databaseObject.getPath() + " could not be read. " - + "It was left in place rather than deleted: an encrypted database opened " - + "without its key looks exactly like this."); - } - } - - private static final android.database.DatabaseErrorHandler KEEP_ON_CORRUPTION = - new KeepDatabaseOnCorruption(); - - private String resolveNativeDatabasePath(String databaseName) { - if (databaseName.startsWith("file://")) { - return FileSystemStorage.getInstance().toNativePath(databaseName); - } - return getDatabasePath(databaseName); - } - - @Override - public Database openOrCreateDBForRekey(String databaseName) throws IOException { - // The stock android.database.sqlite engine has no cipher, so a plaintext database opened - // through it can never be encrypted in place. Route the migration through SQLCipher, which - // opens an unencrypted file when given an empty key and can then rekey it. - if (!isDatabaseEncryptionSupported()) { - return openOrCreateDB(databaseName); - } - // The slot is taken before the engine opens anything, for the reason given in - // openOrCreateDB. AndroidCipherFactory hands back a connection that already holds it. - String nativePath = resolveNativeDatabasePath(databaseName); - reserveDatabaseConnection(nativePath); - Object opened; - try { - Class c = Class.forName("com.codename1.impl.android.cipher.AndroidCipherFactory"); - java.lang.reflect.Method open = c.getMethod("open", String.class, String.class, String.class); - // Cast below, outside the try, for the reason given in openOrCreateDB. - opened = open.invoke(null, - resolveNativeDatabasePath(databaseName), databaseName, ""); - } catch (java.lang.reflect.InvocationTargetException err) { - // The open threw, so no connection exists to release the slot later. A rekey open of - // a file that turns out to be encrypted lands here, and leaving the slot behind would - // make every later conversion of that database see a connection that is not there. - releaseUnusedDatabaseConnection(nativePath); - Throwable cause = err.getCause(); - if (cause instanceof IOException) { - throw (IOException) cause; - } - throw new IOException(cause == null ? err.toString() : cause.getMessage(), cause); - } catch (NoSuchMethodException broken) { - // Same reasoning as openOrCreateDB: falling back to the plaintext engine here would - // silently turn a re-key into a no-op on a build that does ship the cipher. - releaseUnusedDatabaseConnection(nativePath); - throw new IOException("The encrypted database implementation is present but does not " - + "expose the expected entry point. This build is inconsistent: " - + broken.getMessage(), broken); - } catch (Throwable err) { - releaseUnusedDatabaseConnection(nativePath); - return openOrCreateDB(databaseName); - } - if (!(opened instanceof Database)) { - releaseUnusedDatabaseConnection(nativePath); - throw new IOException("The encrypted database implementation returned " - + (opened == null ? "nothing" : opened.getClass().getName()) - + " rather than a Database. This build is inconsistent."); - } - return (Database) opened; - } - - @Override - public boolean isBlobQueryParameterSupported() { - return true; - } - - @Override - public boolean isDatabaseCustomPathSupported() { - return true; - } - - - - /// How many connections this port has open on a database, for the delete guard in core. - /// - /// This port counts connections in its own registry rather than the base class's, because the - /// conversion that consults them runs here. Answering from it is what makes - /// `Database.delete(String)` refuse on Android as it does everywhere else. - @Override - public int openDatabaseConnections(String databaseName) { - try { - return connectionsOpenOn(resolveNativeDatabasePath(databaseName)); - } catch (RuntimeException cannotResolve) { - // An unresolvable name cannot be matched against the registry. Reporting none leaves - // the delete to the checks below rather than refusing something that may be fine. - return 0; - } - } - - @Override - public void deleteDB(String databaseName) throws IOException { - String deletePath = resolveNativeDatabasePath(databaseName); - if (isDatabaseBeingConverted(deletePath)) { - // A conversion owns the file and its working copies. Deleting either underneath it - // would strand the data in whichever one the conversion has not installed yet. - throw new IOException("The database " + deletePath + " is being converted and cannot " - + "be deleted until that finishes."); - } - // The working files first. They survive deleting the live file, and the next open runs - // recovery and puts the backup back - so a database the caller was told had been deleted - // reappears, and after an interrupted encryption what reappears is the plaintext copy. - discardDatabaseMigrationArtifacts(deletePath); - if (databaseName.startsWith("file://")) { - // Through the platform's own deletion rather than by removing the file, which is what - // this used to do. A SQLite database is more than its file: a crash or a kill leaves - // -wal, -shm and -journal beside it, holding rows that were written, and for an - // encrypted database those rows are as readable as the pages they came from. Removing - // the file alone reported a successful delete and left them there, and the next open - // on the same name would read them back. deleteDatabase takes the sidecars and the - // master journals with it, which is exactly what the non-custom branch below has been - // getting from Context.deleteDatabase all along. - android.database.sqlite.SQLiteDatabase.deleteDatabase(new File(deletePath)); - } else { - getContext().deleteDatabase(databaseName); - } - requireDatabaseGone(deletePath); - } - - /// Reports anything the platform left behind, rather than trusting that it deleted it. - /// - /// Both calls above answer with a boolean and neither says what it could not remove -- - /// deleteDatabase ORs the results of deleting the file, the journal, the shared-memory index, - /// the write-ahead log and any master journals, so it answers true when the database file went - /// and a read-only or busy -wal stayed. Reading that boolean would therefore report success - /// over surviving pages just as ignoring it did, so this looks at the files instead. - /// - /// It matters most for the case this was added for: those files hold rows that were written, - /// and for an encrypted database they are as readable as the pages they came from. A caller - /// told the database was deleted has no reason to look, so the only chance to say so is here. - /// - /// #### Parameters - /// - /// - `path`: the database file, whose companions share its name - /// - /// #### Throws - /// - /// - `IOException`: naming whatever is still on disk - private void requireDatabaseGone(String path) throws IOException { - File database = new File(path); - StringBuilder left = new StringBuilder(); - if (database.exists()) { - left.append(' ').append(database.getPath()); - } - String[] sidecars = databaseSidecarPaths(path); - for (int iter = 0; iter < sidecars.length; iter++) { - File sidecar = new File(sidecars[iter]); - if (sidecar.exists()) { - left.append(' ').append(sidecar.getPath()); - } - } - // The master journals as well, which is why this lists the directory rather than checking - // three fixed names: SQLite names them -mj and there can be more than one. - File directory = database.getParentFile(); - if (directory != null) { - final String prefix = database.getName() + "-mj"; - File[] journals = directory.listFiles(); - if (journals != null) { - for (int iter = 0; iter < journals.length; iter++) { - if (journals[iter].getName().startsWith(prefix)) { - left.append(' ').append(journals[iter].getPath()); - } - } - } - } - if (left.length() > 0) { - throw new IOException("The database was not fully deleted. These files are still on " - + "disk and hold its data:" + left + ". Close every connection to it and try " - + "again, or remove them."); - } - } - - @Override - public boolean existsDB(String databaseName) { - // Recover first. A conversion interrupted between its two renames leaves the live name - // missing while the database itself sits complete in the migration directory, and - // reporting "does not exist" there would refuse a retry of encrypt or decrypt - the one - // operation that could put it right. - String path = resolveNativeDatabasePath(databaseName); - // The claim, not a look at it. Asking whether a conversion is running and then recovering - // are two steps, and a conversion starting in between would find recovery already moving - // its marker, target and backup around: depending on how far it had got, recovery would - // delete the export it was writing, restore the backup during the swap, or -- the worst - // of the three -- remove the backup before the converted file had been validated, which - // is the copy the conversion falls back to when the reopen fails. - if (!claimDatabaseForRecovery(path, 0)) { - // A conversion is mid-flight and owns both the live file and its working copies. - // Recovering underneath it would act on a half-installed state, so this answers from - // what the conversion has not yet consumed instead. - return hasRecoverableDatabaseBackup(path) || new File(path).exists(); - } - try { - recoverInterruptedDatabaseMigration(path); - } catch (IOException cannotRecover) { - // The data is still in the migration directory, so the database does exist even - // though it could not be moved back. Say so; the open will report the real problem. - return hasRecoverableDatabaseBackup(path); - } finally { - endDatabaseMigration(path); - } - if (databaseName.startsWith("file://")) { - return exists(databaseName); - } - File db = new File(getContext().getApplicationInfo().dataDir + "/databases/" + databaseName); - return db.exists(); - } - - public String getDatabasePath(String databaseName) { - if (databaseName.startsWith("file://")) { - return databaseName; - } - File db = new File(getContext().getApplicationInfo().dataDir + "/databases/" + databaseName); - return db.getAbsolutePath(); - } - - public boolean isNativeTitle() { - if(com.codename1.ui.Toolbar.isGlobalToolbar()) { - return false; - } - Form f = getCurrentForm(); - boolean nativeCommand; - if(f != null){ - nativeCommand = f.getMenuBar().getCommandBehavior() == Display.COMMAND_BEHAVIOR_NATIVE; - }else{ - nativeCommand = getCommandBehavior() == Display.COMMAND_BEHAVIOR_NATIVE; - } - return hasActionBar() && nativeCommand; - } - - public void refreshNativeTitle(){ - if (getActivity() == null || com.codename1.ui.Toolbar.isGlobalToolbar()) { - return; - } - Form f = getCurrentForm(); - if (f != null && isNativeTitle() && !(f instanceof Dialog)) { - getActivity().runOnUiThread(new SetCurrentFormImpl(getActivity(), f)); - } - } - - public void setCurrentForm(final Form f) { - if (getActivity() == null) { - return; - } - if(getCurrentForm() == null){ - flushGraphics(); - } - if(editInProgress()) { - stopEditing(true); - } - super.setCurrentForm(f); - if (isNativeTitle() && !(f instanceof Dialog)) { - getActivity().runOnUiThread(new SetCurrentFormImpl(getActivity(), f)); - } - } - - @Override - public void setNativeCommands(Vector commands) { - refreshNativeTitle(); - } - - @Override - public boolean isScreenLockSupported() { - return true; - } - - @Override - public void lockScreen(){ - ((CodenameOneActivity)getContext()).lockScreen(); - } - - @Override - public void unlockScreen(){ - ((CodenameOneActivity)getContext()).unlockScreen(); - } - - private static class SetCurrentFormImpl implements Runnable { - private Activity activity; - private Form f; - - public SetCurrentFormImpl(Activity activity, Form f) { - this.activity = activity; - this.f = f; - } - - @Override - public void run() { - if(com.codename1.ui.Toolbar.isGlobalToolbar()) { - return; - } - ActionBar ab = activity.getActionBar(); - String title = f.getTitle(); - boolean hasMenuBtn = false; - if(android.os.Build.VERSION.SDK_INT >= 14){ - try { - ViewConfiguration vc = ViewConfiguration.get(activity); - Method m = vc.getClass().getMethod("hasPermanentMenuKey", (Class[])null); - hasMenuBtn = ((Boolean)m.invoke(vc, (Object[])null)).booleanValue(); - } catch(Throwable t) { - t.printStackTrace(); - } - } - if((title != null && title.length() > 0) || (f.getCommandCount() > 0 && !hasMenuBtn)){ - activity.runOnUiThread(new NotifyActionBar(activity, true)); - }else{ - activity.runOnUiThread(new NotifyActionBar(activity, false)); - return; - } - - ab.setTitle(title); - ab.setDisplayHomeAsUpEnabled(f.getBackCommand() != null); - if(android.os.Build.VERSION.SDK_INT >= 14){ - Image icon = f.getTitleComponent().getIcon(); - try { - if(icon != null){ - ab.getClass().getMethod("setIcon", Drawable.class).invoke(ab, new BitmapDrawable(activity.getResources(), (Bitmap)icon.getImage())); - }else{ - if(activity.getApplicationInfo().icon != 0){ - ab.getClass().getMethod("setIcon", Integer.TYPE).invoke(ab, activity.getApplicationInfo().icon); - } - } - activity.runOnUiThread(new InvalidateOptionsMenuImpl(activity)); - } catch(Throwable t) { - t.printStackTrace(); - } - } - return; - } - - } - - private Purchase pur; - - @Override - public Purchase getInAppPurchase() { - try { - pur = ZoozPurchase.class.newInstance(); - return pur; - } catch(Throwable t) { - return super.getInAppPurchase(); - } - } - - @Override - public boolean isTimeoutSupported() { - return true; - } - - @Override - public void setTimeout(int t) { - timeout = t; - } - - @Override - public CodeScanner getCodeScanner() { - if(scannerInstance == null) { - scannerInstance = new CodeScannerImpl(); - } - return scannerInstance; - } - - public void addCookie(Cookie c, boolean addToWebViewCookieManager, boolean sync) { - if(addToWebViewCookieManager) { - CookieManager mgr; - CookieSyncManager syncer; - try { - syncer = CookieSyncManager.getInstance(); - mgr = getCookieManager(); - } catch(IllegalStateException ex) { - syncer = CookieSyncManager.createInstance(this.getContext()); - mgr = getCookieManager(); - } - java.text.SimpleDateFormat format = new java.text.SimpleDateFormat("EEE, dd-MMM-yyyy HH:mm:ss z"); - format.setTimeZone(TimeZone.getTimeZone("GMT")); - addCookie(c, mgr, format); - if(sync) { - syncer.sync(); - } - } - super.addCookie(c); - - - - } - - private void addCookie(Cookie c, CookieManager mgr, java.text.SimpleDateFormat format) { - - String d = c.getDomain(); - String port = ""; - if (d.contains(":")) { - // For some reason, the port must be stripped and stored separately - // or it won't retrieve it properly. - // https://github.com/codenameone/CodenameOne/issues/2804 - port = "; Port=" + d.substring(d.indexOf(":")+1); - d = d.substring(0, d.indexOf(":")); - } - String cookieString = c.getName() + "=" + c.getValue() + - "; Domain=" + d + - port + - "; Path=" + c.getPath() + - "; " + (c.isSecure() ? "Secure;" : "") - + (c.getExpires() != 0 ? (" Expires="+format.format(new Date(c.getExpires()))+";") : "") - + (c.isHttpOnly() ? "httpOnly;" : ""); - String cookieUrl = "http" + - (c.isSecure() ? "s" : "") + "://" + - d + - c.getPath(); - mgr.setCookie(cookieUrl, cookieString); - } - - public void addCookie(Cookie[] cs, boolean addToWebViewCookieManager, boolean sync) { - if(addToWebViewCookieManager) { - CookieManager mgr; - CookieSyncManager syncer; - try { - syncer = CookieSyncManager.getInstance(); - mgr = getCookieManager(); - } catch(IllegalStateException ex) { - syncer = CookieSyncManager.createInstance(this.getContext()); - mgr = getCookieManager(); - } - java.text.SimpleDateFormat format = new java.text.SimpleDateFormat("EEE, dd-MMM-yyyy HH:mm:ss z"); - format.setTimeZone(TimeZone.getTimeZone("GMT")); - - for (Cookie c : cs) { - addCookie(c, mgr, format); - - } - - if(sync) { - syncer.sync(); - } - } - super.addCookie(cs); - - - - } - - @Override - public void addCookie(Cookie c) { - if(isUseNativeCookieStore()) { - this.addCookie(c, true, true); - } else { - super.addCookie(c); - } - } - - - - @Override - public void addCookie(Cookie[] cookiesArray) { - if(isUseNativeCookieStore()) { - this.addCookie(cookiesArray, true); - } else { - super.addCookie(cookiesArray); - } - } - - public void addCookie(Cookie[] cookiesArray, boolean addToWebViewCookieManager){ - addCookie(cookiesArray, addToWebViewCookieManager, false); - - } - - - - class CodeScannerImpl extends CodeScanner implements IntentResultListener { - private ScanResult callback; - - @Override - public void scanQRCode(ScanResult callback) { - if (getActivity() == null) { - return; - } - if (getActivity() instanceof CodenameOneActivity) { - ((CodenameOneActivity) getActivity()).setIntentResultListener(this); - } - this.callback = callback; - IntentIntegrator in = new IntentIntegrator(getActivity()); - if(!in.initiateScan(IntentIntegrator.QR_CODE_TYPES, "QR_CODE_MODE")){ - // restore old activity handling - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - if(CodeScannerImpl.this != null && CodeScannerImpl.this.callback != null) { - CodeScannerImpl.this.callback.scanError(-1, "no scan app"); - CodeScannerImpl.this.callback = null; - } - } - }); - - if (getActivity() instanceof CodenameOneActivity) { - ((CodenameOneActivity) getActivity()).restoreIntentResultListener(); - } - } - } - - @Override - public void scanBarCode(ScanResult callback) { - if (getActivity() == null) { - return; - } - if (getActivity() instanceof CodenameOneActivity) { - ((CodenameOneActivity) getActivity()).setIntentResultListener(this); - } - this.callback = callback; - IntentIntegrator in = new IntentIntegrator(getActivity()); - Collection types = IntentIntegrator.PRODUCT_CODE_TYPES; - if(Display.getInstance().getProperty("scanAllCodeTypes", "false").equals("true")) { - types = IntentIntegrator.ALL_CODE_TYPES; - } - if(Display.getInstance().getProperty("android.scanTypes", null) != null) { - String[] arr = Display.getInstance().getProperty("android.scanTypes", null).split(";"); - types = Arrays.asList(arr); - } - - if(!in.initiateScan(types, "ONE_D_MODE")){ - // restore old activity handling - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - CodeScannerImpl.this.callback.scanError(-1, "no scan app"); - CodeScannerImpl.this.callback = null; - } - }); - - if (getActivity() instanceof CodenameOneActivity) { - ((CodenameOneActivity) getActivity()).restoreIntentResultListener(); - } - } - } - - public void onActivityResult(int requestCode, final int resultCode, Intent data) { - if (requestCode == IntentIntegrator.REQUEST_CODE && callback != null) { - final ScanResult sr = callback; - if (resultCode == Activity.RESULT_OK) { - final String contents = data.getStringExtra("SCAN_RESULT"); - final String formatName = data.getStringExtra("SCAN_RESULT_FORMAT"); - final byte[] rawBytes = data.getByteArrayExtra("SCAN_RESULT_BYTES"); - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - sr.scanCompleted(contents, formatName, rawBytes); - } - }); - } else if(resultCode == Activity.RESULT_CANCELED) { - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - sr.scanCanceled(); - } - }); - - } else { - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - sr.scanError(resultCode, null); - } - }); - } - callback = null; - } - - // restore old activity handling - if (getActivity() instanceof CodenameOneActivity) { - ((CodenameOneActivity) getActivity()).restoreIntentResultListener(); - } - } - } - - public boolean hasCamera() { - try { - int numCameras = Camera.getNumberOfCameras(); - return numCameras > 0; - } catch(Throwable t) { - return true; - } - } - - @Override - public com.codename1.impl.CameraImpl createCameraImpl() { - Activity act = getActivity(); - if (act == null) return null; - return new AndroidCameraImpl(act); - } - - @Override - public com.codename1.impl.ARImpl createARImpl() { - Activity act = getActivity(); - if (act == null) { - return null; - } - // The ARCore-backed impl lives in a package the build deletes for - // apps that never reference com.codename1.ar (it compiles against - // com.google.ar.core which only exists when the AR gradle dependency - // was injected), so it must be reached reflectively. - try { - Class clazz = Class.forName("com.codename1.impl.android.ar.AndroidARImpl"); - return (com.codename1.impl.ARImpl) clazz - .getConstructor(Activity.class).newInstance(act); - } catch (Throwable t) { - return null; - } - } - - private AndroidNearbyBridge nearbyBridge; - - /// The nearby bridge, which finds its own implementation. - /// - /// Always returned rather than conditionally null: the shell answers every - /// capability query honestly whether or not the optional backend was - /// bundled, so the public API reports NOT_SUPPORTED without this getter - /// having to know how the app was built. - @Override - public synchronized com.codename1.nearby.spi.NearbyBridge - getNearbyBridge() { - // Synchronized, because two threads reaching nearby for the first - // time both saw null and both built a backend. Only one was kept, - // and the loser could already have prepared a UWB session or taken - // the companion chooser slot in state nothing could reach again -- - // so a later start or stop could not find its session, and the radio - // it had opened stayed open. - if (nearbyBridge == null) { - nearbyBridge = new AndroidNearbyBridge(getActivity()); - } - return nearbyBridge; - } - - private com.codename1.impl.android.call.AndroidCallBridge callBridge; - - private com.codename1.impl.android.vpn.AndroidVpnBridge vpnBridge; - - /// The call bridge, on Telecom. - /// - /// Always returned rather than conditionally null: the bridge answers - /// every capability query honestly, including reporting no support at all - /// below API 26 where a self-managed ConnectionService does not exist, so - /// the public API degrades without this getter having to know the OS - /// version. - /// - /// Synchronized for the reason the nearby getter is: the bridge holds the - /// registered PhoneAccount, and two threads racing this would each build - /// one, with the loser's registration unreachable. - @Override - public synchronized com.codename1.call.spi.CallBridge getCallBridge() { - if (callBridge == null) { - callBridge = new com.codename1.impl.android.call.AndroidCallBridge( - callServiceContext()); - } - return callBridge; - } - - /// The context the call and VPN bridges do their system work through. - /// - /// NOT getActivity(): Codename One can be initialised from a Service -- - /// which is what happens when a push wakes the app to report an incoming - /// call -- and getActivity() is null there. The bridge cached that null - /// for the life of the process, so even isSupported() threw on the - /// TelecomManager lookup, and foregrounding later did not repair it. - /// - /// An activity is only needed to SHOW something, and the two places that - /// need one look for it when they get there. - private Context callServiceContext() { - Context any = getActivity(); - if (any == null) { - any = getContext(); - } - if (any == null) { - return null; - } - // The APPLICATION context, never the Activity. Both bridges keep - // what they are given in a final field and are never cleared, so - // caching an Activity here held that Activity and its whole view - // hierarchy reachable for the rest of the process -- a leak renewed - // by every rotation. Nothing the bridges do with it needs an - // Activity: they look up system services, the package manager and - // the application label, and the two places that must SHOW - // something ask getActivity() at the point of showing, which is - // what the comment above already promised and what - // currentActivity() implements. - Context app = any.getApplicationContext(); - return app != null ? app : any; - } - - /// The VPN bridge, on the platform's managed IKEv2 client. - /// - /// Reports no support below API 30, where `VpnManager` does not exist. - @Override - public synchronized com.codename1.vpn.spi.VpnBridge getVpnBridge() { - if (vpnBridge == null) { - vpnBridge = new com.codename1.impl.android.vpn.AndroidVpnBridge( - callServiceContext()); - } - return vpnBridge; - } - - @Override - public com.codename1.impl.VisionImpl createVisionImpl() { - return (com.codename1.impl.VisionImpl) createOptionalAiBackend( - "com.codename1.impl.android.ai.AndroidVisionImpl"); - } - - @Override - public com.codename1.impl.InferenceImpl createInferenceImpl() { - return (com.codename1.impl.InferenceImpl) createOptionalAiBackend( - "com.codename1.impl.android.ai.AndroidInferenceImpl"); - } - - @Override - public com.codename1.impl.LanguageImpl createLanguageImpl() { - return (com.codename1.impl.LanguageImpl) createOptionalAiBackend( - "com.codename1.impl.android.ai.AndroidLanguageImpl"); - } - - private Object createOptionalAiBackend(String className) { - try { - return Class.forName(className).newInstance(); - } catch (Throwable t) { - return null; - } - } - - // Deeper-network connectivity platform factories. Each returns a small - // platform-specific class living under - // com.codename1.impl.android.connectivity. Those classes are loaded - // lazily on first call so apps that never reference WiFi / Bonjour / - // USB / NetworkTypeListener never pay the loading cost. - - @Override - protected com.codename1.io.wifi.WifiPlatform createWifiPlatform() { - return new com.codename1.impl.android.connectivity.AndroidWifiPlatform(); - } - - @Override - protected com.codename1.io.wifi.WifiDirectPlatform createWifiDirectPlatform() { - return new com.codename1.impl.android.connectivity.AndroidWifiDirectPlatform(); - } - - @Override - protected com.codename1.io.bonjour.BonjourPlatform createBonjourPlatform() { - return new com.codename1.impl.android.connectivity.AndroidBonjourPlatform(); - } - - @Override - protected com.codename1.io.usb.UsbPlatform createUsbPlatform() { - return new com.codename1.impl.android.connectivity.AndroidUsbPlatform(); - } - - @Override - protected com.codename1.io.NetworkTypePlatform createNetworkTypePlatform() { - return new com.codename1.impl.android.connectivity.AndroidNetworkTypePlatform(); - } - - public String getCurrentAccessPoint() { - - ConnectivityManager cm = (ConnectivityManager) getContext().getSystemService(Context.CONNECTIVITY_SERVICE); - NetworkInfo info = cm.getActiveNetworkInfo(); - if (info == null) { - return null; - } - String apName = info.getTypeName() + "_" + info.getSubtypeName(); - if (info.getExtraInfo() != null) { - apName += "_" + info.getExtraInfo(); - } - return apName; - } - - @Override - public boolean isVPNDetectionSupported() { - return true; - } - - @Override - public boolean isVPNActive() { - try { - ConnectivityManager cm = (ConnectivityManager) getContext().getSystemService(Context.CONNECTIVITY_SERVICE); - if (cm != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - android.net.Network network = cm.getActiveNetwork(); - if (network != null) { - android.net.NetworkCapabilities capabilities = cm.getNetworkCapabilities(network); - if (capabilities != null && capabilities.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN)) { - return true; - } - } - } - - Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); - while (interfaces != null && interfaces.hasMoreElements()) { - NetworkInterface current = interfaces.nextElement(); - if (!current.isUp() || current.isLoopback()) { - continue; - } - String name = current.getName(); - if (name == null) { - continue; - } - name = name.toLowerCase(Locale.US); - if (name.startsWith("tun") || name.startsWith("ppp") || name.startsWith("tap") || name.startsWith("ipsec")) { - return true; - } - } - } catch (Throwable t) { - Log.d("Codename One", "VPN detection failed", t); - } - return false; - } - - /** - * @inheritDoc - */ - public String[] getAPIds() { - if (apIds == null) { - apIds = new HashMap(); - NetworkInfo[] aps = ((ConnectivityManager) getContext().getSystemService(Context.CONNECTIVITY_SERVICE)).getAllNetworkInfo(); - for (int i = 0; i < aps.length; i++) { - String apName = aps[i].getTypeName() + "_" + aps[i].getSubtypeName(); - if (aps[i].getExtraInfo() != null) { - apName += "_" + aps[i].getExtraInfo(); - } - apIds.put(apName, aps[i]); - } - } - if (apIds.isEmpty()) { - return null; - } - String[] ret = new String[apIds.size()]; - Iterator iter = apIds.keySet().iterator(); - for (int i = 0; iter.hasNext(); i++) { - ret[i] = iter.next().toString(); - } - return ret; - - } - - /** - * @inheritDoc - */ - public int getAPType(String id) { - if (apIds == null) { - getAPIds(); - } - NetworkInfo info = (NetworkInfo) apIds.get(id); - if (info == null) { - return NetworkManager.ACCESS_POINT_TYPE_UNKNOWN; - } - int type = info.getType(); - int subType = info.getSubtype(); - if (type == ConnectivityManager.TYPE_WIFI) { - return NetworkManager.ACCESS_POINT_TYPE_WLAN; - } else if (type == ConnectivityManager.TYPE_MOBILE) { - switch (subType) { - case TelephonyManager.NETWORK_TYPE_1xRTT: - return NetworkManager.ACCESS_POINT_TYPE_NETWORK2G; // ~ 50-100 kbps - case TelephonyManager.NETWORK_TYPE_CDMA: - return NetworkManager.ACCESS_POINT_TYPE_NETWORK2G; // ~ 14-64 kbps - case TelephonyManager.NETWORK_TYPE_EDGE: - return NetworkManager.ACCESS_POINT_TYPE_NETWORK2G; // ~ 50-100 kbps - case TelephonyManager.NETWORK_TYPE_EVDO_0: - return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 400-1000 kbps - case TelephonyManager.NETWORK_TYPE_EVDO_A: - return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 600-1400 kbps - case TelephonyManager.NETWORK_TYPE_GPRS: - return NetworkManager.ACCESS_POINT_TYPE_NETWORK2G; // ~ 100 kbps - case TelephonyManager.NETWORK_TYPE_HSDPA: - return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 2-14 Mbps - case TelephonyManager.NETWORK_TYPE_HSPA: - return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 700-1700 kbps - case TelephonyManager.NETWORK_TYPE_HSUPA: - return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 1-23 Mbps - case TelephonyManager.NETWORK_TYPE_UMTS: - return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 400-7000 kbps - /* - * Above API level 7, make sure to set android:targetSdkVersion - * to appropriate level to use these - */ - case TelephonyManager.NETWORK_TYPE_EHRPD: // API level 11 - return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 1-2 Mbps - case TelephonyManager.NETWORK_TYPE_EVDO_B: // API level 9 - return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 5 Mbps - case TelephonyManager.NETWORK_TYPE_HSPAP: // API level 13 - return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 10-20 Mbps - case TelephonyManager.NETWORK_TYPE_IDEN: // API level 8 - return NetworkManager.ACCESS_POINT_TYPE_NETWORK2G; // ~25 kbps - case TelephonyManager.NETWORK_TYPE_LTE: // API level 11 - return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 10+ Mbps - // Unknown - case TelephonyManager.NETWORK_TYPE_UNKNOWN: - default: - return NetworkManager.ACCESS_POINT_TYPE_NETWORK2G; - } - } else { - return NetworkManager.ACCESS_POINT_TYPE_UNKNOWN; - } - } - - /** - * @inheritDoc - */ - public void setCurrentAccessPoint(String id) { - - if (apIds == null) { - getAPIds(); - } - NetworkInfo info = (NetworkInfo) apIds.get(id); - if (info == null || info.isConnectedOrConnecting()) { - return; - - } - ConnectivityManager cm = (ConnectivityManager) getContext().getSystemService(Context.CONNECTIVITY_SERVICE); - cm.setNetworkPreference(info.getType()); - } - - private void scanMedia(File file) { - Uri uri = Uri.fromFile(file); - Intent scanFileIntent = new Intent( - Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, uri); - getActivity().sendBroadcast(scanFileIntent); - } - - /** - * Gets the last image id from the media store - * - * @return - */ - private String getLastImageId() { - int idVal = 0;; - final String[] imageColumns = {MediaStore.Images.Media._ID}; - final String imageOrderBy = MediaStore.Images.Media._ID + " DESC"; - final String imageWhere = null; - final String[] imageArguments = null; - Cursor imageCursor = getContext().getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, imageColumns, imageWhere, imageArguments, imageOrderBy); - if (imageCursor.moveToFirst()) { - int id = imageCursor.getInt(imageCursor.getColumnIndex(MediaStore.Images.Media._ID)); - imageCursor.close(); - idVal = id; - } - return "" + idVal; - } - - private void clearMediaDB(String lastId, String capturePath) { - final String[] imageColumns = {MediaStore.Images.Media.DATA, MediaStore.Images.Media.DATE_TAKEN, MediaStore.Images.Media.SIZE, MediaStore.Images.Media._ID}; - final String imageOrderBy = MediaStore.Images.Media._ID + " DESC"; - final String imageWhere = MediaStore.Images.Media._ID + ">?"; - final String[] imageArguments = {lastId}; - Cursor imageCursor = getContext().getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, imageColumns, imageWhere, imageArguments, imageOrderBy); - if (imageCursor.getCount() > 1) { - while (imageCursor.moveToNext()) { - int id = imageCursor.getInt(imageCursor.getColumnIndex(MediaStore.Images.Media._ID)); - String path = imageCursor.getString(imageCursor.getColumnIndex(MediaStore.Images.Media.DATA)); - Long takenTimeStamp = imageCursor.getLong(imageCursor.getColumnIndex(MediaStore.Images.Media.DATE_TAKEN)); - Long size = imageCursor.getLong(imageCursor.getColumnIndex(MediaStore.Images.Media.SIZE)); - if (path.contentEquals(capturePath)) { - // Remove it - ContentResolver cr = getContext().getContentResolver(); - cr.delete(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaStore.Images.Media._ID + "=?", new String[]{Long.toString(id)}); - break; - } - } - } - imageCursor.close(); - } - - - @Override - public boolean isNativePickerTypeSupported(int pickerType) { - if(android.os.Build.VERSION.SDK_INT >= 11) { - return pickerType == Display.PICKER_TYPE_DATE || pickerType == Display.PICKER_TYPE_TIME || pickerType == Display.PICKER_TYPE_STRINGS; - } - return pickerType == Display.PICKER_TYPE_DATE || pickerType == Display.PICKER_TYPE_TIME; - } - - @Override - public Object showNativePicker(final int type, final Component source, final Object currentValue, final Object data) { - if (getActivity() == null) { - return null; - } - final boolean [] canceled = new boolean[1]; - final boolean [] dismissed = new boolean[1]; - - if(editInProgress()) { - stopEditing(true); - } - if(type == Display.PICKER_TYPE_TIME) { - - class TimePick implements TimePickerDialog.OnTimeSetListener, TimePickerDialog.OnCancelListener, Runnable { - int result = ((Integer)currentValue).intValue(); - public void onTimeSet(TimePicker tp, int hour, int minute) { - result = hour * 60 + minute; - dismissed[0] = true; - synchronized(this) { - notify(); - } - } - - public void run() { - while(!dismissed[0]) { - synchronized(this) { - try { - wait(50); - } catch(InterruptedException er) {} - } - } - } - - @Override - public void onCancel(DialogInterface di) { - dismissed[0] = true; - canceled[0] = true; - synchronized (this) { - notify(); - } - } - } - final TimePick pickInstance = new TimePick(); - getActivity().runOnUiThread(new Runnable() { - public void run() { - int hour = ((Integer)currentValue).intValue() / 60; - int minute = ((Integer)currentValue).intValue() % 60; - TimePickerDialog tp = new TimePickerDialog(getActivity(), pickInstance, hour, minute, true){ - - @Override - public void cancel() { - super.cancel(); - dismissed[0] = true; - canceled[0] = true; - } - - @Override - public void dismiss() { - super.dismiss(); - dismissed[0] = true; - } - - }; - tp.setOnCancelListener(pickInstance); - //DateFormat.is24HourFormat(activity)); - tp.show(); - } - }); - Display.getInstance().invokeAndBlock(pickInstance); - if(canceled[0]) { - return null; - } - return new Integer(pickInstance.result); - } - if(type == Display.PICKER_TYPE_DATE) { - final java.util.Calendar cl = java.util.Calendar.getInstance(); - if(currentValue != null) { - cl.setTime((Date)currentValue); - } - class DatePick implements DatePickerDialog.OnDateSetListener,DatePickerDialog.OnCancelListener, Runnable { - Date result = (Date)currentValue; - - public void onDateSet(DatePicker dp, int year, int month, int day) { - java.util.Calendar c = java.util.Calendar.getInstance(); - c.set(java.util.Calendar.YEAR, year); - c.set(java.util.Calendar.MONTH, month); - c.set(java.util.Calendar.DAY_OF_MONTH, day); - result = c.getTime(); - dismissed[0] = true; - synchronized(this) { - notify(); - } - } - - public void run() { - while(!dismissed[0]) { - synchronized(this) { - try { - wait(50); - } catch(InterruptedException er) {} - } - } - } - - public void onCancel(DialogInterface di) { - result = null; - dismissed[0] = true; - canceled[0] = true; - synchronized(this) { - notify(); - } - } - } - final DatePick pickInstance = new DatePick(); - getActivity().runOnUiThread(new Runnable() { - public void run() { - DatePickerDialog tp = new DatePickerDialog(getActivity(), pickInstance, cl.get(java.util.Calendar.YEAR), cl.get(java.util.Calendar.MONTH), cl.get(java.util.Calendar.DAY_OF_MONTH)){ - - @Override - public void cancel() { - super.cancel(); - dismissed[0] = true; - canceled[0] = true; - } - - @Override - public void dismiss() { - super.dismiss(); - dismissed[0] = true; - } - - }; - tp.setOnCancelListener(pickInstance); - tp.show(); - } - }); - Display.getInstance().invokeAndBlock(pickInstance); - return pickInstance.result; - } - if(type == Display.PICKER_TYPE_STRINGS) { - final String[] values = (String[])data; - class StringPick implements Runnable, NumberPicker.OnValueChangeListener { - int result = -1; - - StringPick() { - } - - public void run() { - while(!dismissed[0]) { - synchronized(this) { - try { - wait(50); - } catch(InterruptedException er) {} - } - } - } - - public void cancel() { - dismissed[0] = true; - canceled[0] = true; - synchronized(this) { - notify(); - } - } - - public void ok() { - canceled[0] = false; - dismissed[0] = true; - synchronized(this) { - notify(); - } - } - - @Override - public void onValueChange(NumberPicker np, int oldVal, int newVal) { - result = newVal; - } - } - - final StringPick pickInstance = new StringPick(); - for(int iter = 0 ; iter < values.length ; iter++) { - if(values[iter].equals(currentValue)) { - pickInstance.result = iter; - break; - } - } - if (pickInstance.result == -1 && values.length > 0) { - // The picker will default to showing the first element anyways - // If we don't set the result to 0, then the user has to first - // scroll to a different number, then back to the first option - // to pick the first option. - pickInstance.result = 0; - } - - getActivity().runOnUiThread(new Runnable() { - public void run() { - NumberPicker picker = new NumberPicker(getActivity()); - if(source.getClientProperty("showKeyboard") == null) { - picker.setDescendantFocusability(NumberPicker.FOCUS_BLOCK_DESCENDANTS); - } - picker.setMinValue(0); - picker.setMaxValue(values.length - 1); - picker.setDisplayedValues(values); - picker.setOnValueChangedListener(pickInstance); - if(pickInstance.result > -1) { - picker.setValue(pickInstance.result); - } - RelativeLayout linearLayout = new RelativeLayout(getActivity()); - RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(50, 50); - RelativeLayout.LayoutParams numPicerParams = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT); - numPicerParams.addRule(RelativeLayout.CENTER_HORIZONTAL); - - linearLayout.setLayoutParams(params); - linearLayout.addView(picker,numPicerParams); - - AlertDialog.Builder alertDialogBuilder = new AlertDialog.Builder(getActivity()); - alertDialogBuilder.setView(linearLayout); - alertDialogBuilder - .setCancelable(false) - .setPositiveButton("Ok", - new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, - int id) { - pickInstance.ok(); - } - }) - .setNegativeButton("Cancel", - new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, - int id) { - dialog.cancel(); - pickInstance.cancel(); - } - }); - AlertDialog alertDialog = alertDialogBuilder.create(); - alertDialog.show(); - } - }); - Display.getInstance().invokeAndBlock(pickInstance); - if(canceled[0]) { - return null; - } - if(pickInstance.result < 0) { - return null; - } - return values[pickInstance.result]; - } - return null; - } - - private ServerSockets serverSockets; - private synchronized ServerSockets getServerSockets() { - if (serverSockets == null) { - serverSockets = new ServerSockets(); - } - return serverSockets; - } - - class ServerSockets { - Map socks = new HashMap(); - Map loopbackSocks = new HashMap(); - - public synchronized ServerSocket get(int port) throws IOException { - return get(port, false); - } - - /** - * When loopbackOnly is set the socket binds 127.0.0.1 rather than the wildcard - * address, so the channel isn't published on every network interface. The two - * are cached in SEPARATE maps: a port that is already bound to the wildcard - * address must never be handed back to a caller that asked for loopback. - * Distinguishing them by sign within one map would collide on port 0, the - * ephemeral-port request, where -0 == 0. - * - * The IPv4 loopback is named explicitly rather than taken from - * InetAddress.getLoopbackAddress(), which answers ::1 when the runtime - * prefers IPv6. A client that then connects to 127.0.0.1 - which is what - * adb forward and attaching agents do, and what the iOS port binds - would - * find nothing listening, with the server reporting that it had started. - */ - public synchronized ServerSocket get(int port, boolean loopbackOnly) throws IOException { - Map cache = loopbackOnly ? loopbackSocks : socks; - Integer key = Integer.valueOf(port); - ServerSocket sock = cache.get(key); - if (sock == null || sock.isClosed()) { - sock = loopbackOnly - ? new ServerSocket(port, 50, InetAddress.getByName("127.0.0.1")) - : new ServerSocket(port); - cache.put(key, sock); - } - return sock; - } - - /** - * Closes and forgets the socket, so a thread blocked in accept returns and a - * later listener on this port binds a fresh one rather than sharing this. - */ - public synchronized void close(int port, boolean loopbackOnly) { - Map cache = loopbackOnly ? loopbackSocks : socks; - ServerSocket sock = cache.remove(Integer.valueOf(port)); - if (sock != null) { - try { - sock.close(); - } catch (IOException ignored) { - // best effort: the point is to unblock accept, and a socket that - // cannot be closed is already unusable - } - } - } - - - } - - class SocketImpl { - java.net.Socket socketInstance; - int errorCode = -1; - String errorMessage = null; - InputStream is; - OutputStream os; - - public boolean connect(String param, int param1, int connectTimeout) { - try { - socketInstance = new java.net.Socket(); - socketInstance.connect(new InetSocketAddress(param, param1), connectTimeout); - return true; - } catch(Exception err) { - err.printStackTrace(); - errorMessage = err.toString(); - return false; - } - } - - private InputStream getInput() throws IOException { - if(is == null) { - if(socketInstance != null) { - is = socketInstance.getInputStream(); - } else { - - } - } - return is; - } - - private OutputStream getOutput() throws IOException { - if(os == null) { - os = socketInstance.getOutputStream(); - } - return os; - } - - public int getAvailableInput() { - try { - return getInput().available(); - } catch(IOException err) { - errorMessage = err.toString(); - err.printStackTrace(); - } - return 0; - } - - public String getErrorMessage() { - return errorMessage; - } - - public byte[] readFromStream() { - try { - int av = getAvailableInput(); - if(av > 0) { - byte[] arr = new byte[av]; - int size = getInput().read(arr); - if(size == arr.length) { - return arr; - } - return shrink(arr, size); - } - byte[] arr = new byte[8192]; - int size = getInput().read(arr); - if(size == arr.length) { - return arr; - } - return shrink(arr, size); - } catch(IOException err) { - err.printStackTrace(); - errorMessage = err.toString(); - return null; - } - } - - private byte[] shrink(byte[] arr, int size) { - if(size == -1) { - return null; - } - byte[] n = new byte[size]; - System.arraycopy(arr, 0, n, 0, size); - return n; - } - - public void writeToStream(byte[] param) { - writeToStream(param, 0, param.length); - } - - public void writeToStream(byte[] param, int offset, int len) { - try { - OutputStream os = getOutput(); - os.write(param, offset, len); - os.flush(); - } catch(IOException err) { - errorMessage = err.toString(); - err.printStackTrace(); - } - } - - public void disconnect() { - try { - if(socketInstance != null) { - if(is != null) { - try { - is.close(); - } catch(IOException err) {} - } - if(os != null) { - try { - os.close(); - } catch(IOException err) {} - } - socketInstance.close(); - socketInstance = null; - } - } catch(IOException err) { - errorMessage = err.toString(); - err.printStackTrace(); - } - } - - public Object listen(int param) { - return listen(param, false); - } - - public Object listen(int param, boolean loopbackOnly) { - ServerSocket serverSocketInstance = null; - try { - serverSocketInstance = getServerSockets().get(param, loopbackOnly); - socketInstance = serverSocketInstance.accept(); - SocketImpl si = new SocketImpl(); - si.socketInstance = socketInstance; - return si; - } catch(Exception err) { - errorMessage = err.toString(); - // A closed socket here is the deliberate stop path: stopping a - // listener closes it precisely to bring this accept back. Printing a - // stack trace for that would put an alarming fake failure in the log - // every time a listener is stopped. - if(serverSocketInstance == null || !serverSocketInstance.isClosed()) { - err.printStackTrace(); - } - return null; - } - } - - public boolean isConnected() { - return socketInstance != null; - } - - public int getErrorCode() { - return errorCode; - } - } - - @Override - public Object connectSocket(String host, int port) { - return connectSocket(host, port, 0); - } - - - - @Override - public Object connectSocket(String host, int port, int connectTimeout) { - SocketImpl i = new SocketImpl(); - if(i.connect(host, port, connectTimeout)) { - return i; - } - return null; - } - - @Override - public Object listenSocket(int port) { - return new SocketImpl().listen(port); - } - - @Override - public boolean isLoopbackServerSocketAvailable() { - return true; - } - - @Override - public Object listenSocketLoopback(int port) { - return new SocketImpl().listen(port, true); - } - - @Override - public void stopListeningSocket(int port, boolean loopbackOnly) { - getServerSockets().close(port, loopbackOnly); - } - - /** - * A debuggable package is one built for development: the flag is set by the - * build for a debug variant and cleared for a release variant, so this reads the - * distinction straight off the installed application rather than guessing. - */ - @Override - public boolean isDebuggableBuild() { - Context ctx = getContext(); - if (ctx == null) { - return false; - } - ApplicationInfo info = ctx.getApplicationInfo(); - return info != null && (info.flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0; - } - - @Override - public String getHostOrIP() { - try { - InetAddress i = java.net.InetAddress.getLocalHost(); - if(i.isLoopbackAddress()) { - Enumeration nie = NetworkInterface.getNetworkInterfaces(); - while(nie.hasMoreElements()) { - NetworkInterface current = nie.nextElement(); - if(!current.isLoopback()) { - Enumeration iae = current.getInetAddresses(); - while(iae.hasMoreElements()) { - InetAddress currentI = iae.nextElement(); - if(!currentI.isLoopbackAddress()) { - return currentI.getHostAddress(); - } - } - } - } - } - return i.getHostAddress(); - } catch(Throwable t) { - com.codename1.io.Log.e(t); - return null; - } - } - - @Override - public void disconnectSocket(Object socket) { - ((SocketImpl)socket).disconnect(); - } - - @Override - public boolean isSocketConnected(Object socket) { - return ((SocketImpl)socket).isConnected(); - } - - - - @Override - public boolean isServerSocketAvailable() { - return true; - } - - @Override - public boolean isSocketAvailable() { - return true; - } - - @Override - public String getSocketErrorMessage(Object socket) { - return ((SocketImpl)socket).getErrorMessage(); - } - - @Override - public int getSocketErrorCode(Object socket) { - return ((SocketImpl)socket).getErrorCode(); - } - - @Override - public int getSocketAvailableInput(Object socket) { - return ((SocketImpl)socket).getAvailableInput(); - } - - @Override - public byte[] readFromSocketStream(Object socket) { - return ((SocketImpl)socket).readFromStream(); - } - - @Override - public void writeToSocketStream(Object socket, byte[] data) { - ((SocketImpl)socket).writeToStream(data); - } - - @Override - public boolean isWebSocketSupported() { - return true; - } - - @Override - public com.codename1.impl.WebSocketImpl createWebSocketImpl(String url) { - return new AndroidWebSocketImpl(url); - } - - @Override - public void writeToSocketStream(Object socket, byte[] data, int offset, int len) { - ((SocketImpl)socket).writeToStream(data, offset, len); - } - - //Begin new Graphics Work - @Override - public boolean isShapeSupported(Object graphics) { - return true; - } - - @Override - public boolean isTransformSupported(Object graphics) { - return true; - } - - @Override - public boolean isPerspectiveTransformSupported(Object graphics){ - return android.os.Build.VERSION.SDK_INT >= 14; - } - - @Override - public void fillShape(Object graphics, com.codename1.ui.geom.Shape shape) { - AndroidGraphics ag = (AndroidGraphics)graphics; - Path p = cn1ShapeToAndroidPath(shape); - ag.fillPath(p); - } - - @Override - public void fillShapeShadow(Object graphics, com.codename1.ui.geom.Shape shape, int fillColor, - int fillAlpha, int shadowColor, float shadowOpacity, int blurRadius, int offsetX, int offsetY) { - AndroidGraphics ag = (AndroidGraphics)graphics; - Path p = cn1ShapeToAndroidPath(shape); - ag.fillPathShadow(p, fillColor, fillAlpha, shadowColor, shadowOpacity, blurRadius, offsetX, offsetY); - } - - @Override - public boolean isShapeShadowSupported(Object graphics) { - // Android's Canvas has no cheap GPU shadow for arbitrary shapes: BlurMaskFilter is ignored on - // the hardware canvas, and Paint.setShadowLayer collapses the whole view to software rendering - // (severe jank/ANR). Fall back to the cached-image path; the RAM cost is bounded by keeping the - // number of live shadowed components small (windowed lists) or disabling the per-border cache. - return false; - } - - @Override - public void drawShape(Object graphics, com.codename1.ui.geom.Shape shape, com.codename1.ui.Stroke stroke) { - AndroidGraphics ag = (AndroidGraphics)graphics; - Path p = cn1ShapeToAndroidPath(shape); - ag.drawPath(p, stroke); - - } - - @Override - public void drawShadow(Object graphics, Object image, int x, int y, int offsetX, int offsetY, int blurRadius, int spreadRadius, int color, float opacity) { - AndroidGraphics ag = (AndroidGraphics)graphics; - - ag.drawShadow(image, x, y, offsetX, offsetY, blurRadius, spreadRadius, color, opacity); - } - - @Override - public boolean isDrawShadowSupported() { - return true; - } - - @Override - public boolean isDrawShadowFast() { - return false; - } - // BEGIN TRANSFORMATION METHODS--------------------------------------------------------- - - - - @Override - public boolean transformEqualsImpl(Transform t1, Transform t2) { - Object o1 = null; - if(t1 != null) { - o1 = t1.getNativeTransform(); - } - Object o2 = null; - if(t2 != null) { - o2 = t2.getNativeTransform(); - } - return transformNativeEqualsImpl(o1, o2); - } - - @Override - public boolean transformNativeEqualsImpl(Object t1, Object t2) { - if ( t1 != null ){ - CN1Matrix4f m1 = (CN1Matrix4f)t1; - CN1Matrix4f m2 = (CN1Matrix4f)t2; - return m1.equals(m2); - } else { - return t2 == null; - } - } - - - @Override - public boolean isTransformSupported() { - return true; - } - - @Override - public boolean isPerspectiveTransformSupported() { - - return true; - } - - @Override - public Object makeTransformAffine(double m00, double m10, double m01, double m11, double m02, double m12) { - CN1Matrix4f t = CN1Matrix4f.make(new float[]{ - (float)m00, (float)m10, 0, 0, - (float)m01, (float)m11, 0, 0, - 0, 0, 1, 0, - (float)m02, (float)m12, 0, 1 - }); - return t; - } - - @Override - public void setTransformAffine(Object nativeTransform, double m00, double m10, double m01, double m11, double m02, double m12) { - ((CN1Matrix4f)nativeTransform).setData(new float[]{ - (float)m00, (float)m10, 0, 0, - (float)m01, (float)m11, 0, 0, - 0, 0, 1, 0, - (float)m02, (float)m12, 0, 1 - }); - } - - - @Override - public Object makeTransformTranslation(float translateX, float translateY, float translateZ) { - return CN1Matrix4f.makeTranslation(translateX, translateY, translateZ); - } - - @Override - public void setTransformTranslation(Object nativeTransform, float translateX, float translateY, float translateZ) { - CN1Matrix4f m = (CN1Matrix4f)nativeTransform; - m.reset(); - m.translate(translateX, translateY, translateZ); - } - - @Override - public Object makeTransformScale(float scaleX, float scaleY, float scaleZ) { - CN1Matrix4f t = CN1Matrix4f.makeIdentity(); - t.scale(scaleX, scaleY, scaleZ); - return t; - } - - @Override - public void setTransformScale(Object nativeTransform, float scaleX, float scaleY, float scaleZ) { - CN1Matrix4f t = (CN1Matrix4f)nativeTransform; - t.reset(); - t.scale(scaleX, scaleY, scaleZ); - } - - @Override - public Object makeTransformRotation(float angle, float x, float y, float z) { - return CN1Matrix4f.makeRotation(angle, x, y, z); - } - - @Override - public void setTransformRotation(Object nativeTransform, float angle, float x, float y, float z) { - CN1Matrix4f m = (CN1Matrix4f)nativeTransform; - m.reset(); - m.rotate(angle, x, y, z); - } - - @Override - public Object makeTransformPerspective(float fovy, float aspect, float zNear, float zFar) { - return CN1Matrix4f.makePerspective(fovy, aspect, zNear, zFar); - } - - @Override - public void setTransformPerspective(Object nativeGraphics, float fovy, float aspect, float zNear, float zFar) { - CN1Matrix4f m = (CN1Matrix4f)nativeGraphics; - m.setPerspective(fovy, aspect, zNear, zFar); - } - - @Override - public Object makeTransformOrtho(float left, float right, float bottom, float top, float near, float far) { - return CN1Matrix4f.makeOrtho(left, right, bottom, top, near, far); - } - - @Override - public void setTransformOrtho(Object nativeGraphics, float left, float right, float bottom, float top, float near, float far) { - CN1Matrix4f m = (CN1Matrix4f)nativeGraphics; - m.setOrtho(left, right, bottom, top, near, far); - } - - @Override - public Object makeTransformCamera(float eyeX, float eyeY, float eyeZ, float centerX, float centerY, float centerZ, float upX, float upY, float upZ) { - return CN1Matrix4f.makeCamera(eyeX, eyeY, eyeZ, centerX, centerY, centerZ, upX, upY, upZ); - } - - @Override - public void setTransformCamera(Object nativeGraphics, float eyeX, float eyeY, float eyeZ, float centerX, float centerY, float centerZ, float upX, float upY, float upZ) { - CN1Matrix4f m = (CN1Matrix4f)nativeGraphics; - m.setCamera(eyeX, eyeY, eyeZ, centerX, centerY, centerZ, upX, upY, upZ); - } - - - @Override - public void transformRotate(Object nativeTransform, float angle, float x, float y, float z) { - ((CN1Matrix4f)nativeTransform).rotate(angle, x, y, z); - } - - @Override - public void transformTranslate(Object nativeTransform, float x, float y, float z) { - //((Matrix) nativeTransform).preTranslate(x, y); - ((CN1Matrix4f)nativeTransform).translate(x, y, z); - } - - @Override - public void transformScale(Object nativeTransform, float x, float y, float z) { - //((Matrix) nativeTransform).preScale(x, y); - ((CN1Matrix4f)nativeTransform).scale(x, y, z); - } - - @Override - public Object makeTransformInverse(Object nativeTransform) { - - CN1Matrix4f inverted = CN1Matrix4f.makeIdentity(); - inverted.setData(((CN1Matrix4f)nativeTransform).getData()); - if( inverted.invert()){ - return inverted; - } - return null; - - //Matrix inverted = new Matrix(); - //if(((Matrix) nativeTransform).invert(inverted)){ - // return inverted; - //} - //return null; - } - - @Override - public void setTransformInverse(Object nativeTransform) throws com.codename1.ui.Transform.NotInvertibleException { - - CN1Matrix4f m = (CN1Matrix4f)nativeTransform; - if (!m.invert()) { - throw new com.codename1.ui.Transform.NotInvertibleException(); - } - } - - @Override - public void setTransformIdentity(Object transform) { - CN1Matrix4f m = (CN1Matrix4f)transform; - m.setIdentity(); - } - - @Override - public Object makeTransformIdentity() { - return CN1Matrix4f.makeIdentity(); - } - - @Override - public void copyTransform(Object src, Object dest) { - CN1Matrix4f t1 = (CN1Matrix4f) src; - CN1Matrix4f t2 = (CN1Matrix4f) dest; - t2.setData(t1.getData()); - } - - @Override - public void concatenateTransform(Object t1, Object t2) { - //((Matrix) t1).preConcat((Matrix) t2); - ((CN1Matrix4f)t1).concatenate((CN1Matrix4f)t2); - } - - @Override - public void transformPoint(Object nativeTransform, float[] in, float[] out) { - //Matrix t = (Matrix) nativeTransform; - //t.mapPoints(in, 0, out, 0, 2); - ((CN1Matrix4f)nativeTransform).transformCoord(in, out); - } - - @Override - public void setTransform(Object graphics, Transform transform) { - AndroidGraphics ag = (AndroidGraphics) graphics; - Transform existing = ag.getTransform(); - if (existing == null) { - existing = transform == null ? Transform.makeIdentity() : transform.copy(); - ag.setTransform(existing); - } else { - if (transform == null) { - existing.setIdentity(); - } else { - existing.setTransform(transform); - } - ag.setTransform(existing); // sets dirty flag for transform - } - - } - - @Override - public com.codename1.ui.Transform getTransform(Object graphics) { - com.codename1.ui.Transform t = ((AndroidGraphics) graphics).getTransform(); - if (t == null) { - return Transform.makeIdentity(); - } - Transform t2 = Transform.makeIdentity(); - t2.setTransform(t); - return t2; - } - - @Override - public void getTransform(Object graphics, Transform transform) { - com.codename1.ui.Transform t = ((AndroidGraphics) graphics).getTransform(); - if (t == null) { - transform.setIdentity(); - } else { - transform.setTransform(t); - } - } - - - // END TRANSFORM STUFF - - - static Path cn1ShapeToAndroidPath(com.codename1.ui.geom.Shape shape, Path p) { - //Path p = new Path(); - p.rewind(); - - com.codename1.ui.geom.PathIterator it = shape.getPathIterator(); - switch (it.getWindingRule()) { - case GeneralPath.WIND_EVEN_ODD: - p.setFillType(Path.FillType.EVEN_ODD); - break; - case GeneralPath.WIND_NON_ZERO: - p.setFillType(Path.FillType.WINDING); - break; - } - //p.setWindingRule(it.getWindingRule() == com.codename1.ui.geom.PathIterator.WIND_EVEN_ODD ? GeneralPath.WIND_EVEN_ODD : GeneralPath.WIND_NON_ZERO); - float[] buf = new float[6]; - while (!it.isDone()) { - int type = it.currentSegment(buf); - switch (type) { - case com.codename1.ui.geom.PathIterator.SEG_MOVETO: - p.moveTo(buf[0], buf[1]); - break; - case com.codename1.ui.geom.PathIterator.SEG_LINETO: - p.lineTo(buf[0], buf[1]); - break; - case com.codename1.ui.geom.PathIterator.SEG_QUADTO: - p.quadTo(buf[0], buf[1], buf[2], buf[3]); - break; - case com.codename1.ui.geom.PathIterator.SEG_CUBICTO: - p.cubicTo(buf[0], buf[1], buf[2], buf[3], buf[4], buf[5]); - break; - case com.codename1.ui.geom.PathIterator.SEG_CLOSE: - p.close(); - break; - - } - it.next(); - } - - return p; - } - - static Path cn1ShapeToAndroidPath(com.codename1.ui.geom.Shape shape) { - return cn1ShapeToAndroidPath(shape, new Path()); - } - - /** - * The ID used for a local notification that should actually trigger a background - * fetch. This type of notification is handled specially by the {@link LocalNotificationPublisher}. It - * doesn't display a notification to the user, but instead just calls the {@link #performBackgroundFetch() } - * method. - */ - static final String BACKGROUND_FETCH_NOTIFICATION_ID="$$$CN1_BACKGROUND_FETCH$$$"; - - - /** - * Calls the background fetch callback. If the app is in teh background, this will - * check to see if the lifecycle class implements the {@link com.codename1.background.BackgroundFetch} - * interface. If it does, it will execute its {@link com.codename1.background.BackgroundFetch#performBackgroundFetch(long, com.codename1.util.Callback) } - * method. - * @param blocking True if this should block until it is complete. - */ - public static void performBackgroundFetch(boolean blocking) { - - if (Display.getInstance().isMinimized()) { - // By definition, background fetch should only occur if the app is minimized. - // This keeps it consistent with the iOS implementation that doesn't have a - // choice - final boolean[] complete = new boolean[1]; - final Object lock = new Object(); - final BackgroundFetch bgFetchListener = instance.getBackgroundFetchListener(); - final long timeout = System.currentTimeMillis()+25000; - if (bgFetchListener != null) { - Display.getInstance().callSerially(new Runnable() { - public void run() { - bgFetchListener.performBackgroundFetch(timeout, new Callback() { - - @Override - public void onSucess(Boolean value) { - // On Android the OS doesn't care whether it worked or not - // So we'll just consume this. - synchronized (lock) { - complete[0] = true; - lock.notify(); - } - } - - @Override - public void onError(Object sender, Throwable err, int errorCode, String errorMessage) { - com.codename1.io.Log.e(err); - synchronized (lock) { - complete[0] = true; - lock.notify(); - } - } - - }); - } - }); - - } - - while (blocking && !complete[0]) { - Util.wait(lock, 1000); - if (!complete[0]) { - System.out.println("Waiting for background fetch to complete. Make sure your background fetch handler calls onSuccess() or onError() in the callback when complete"); - - } - if (System.currentTimeMillis() > timeout) { - System.out.println("Background fetch exceeded time alotted. Not waiting for its completion"); - break; - } - - } - - - } - } - - /** - * Starts the background fetch service. - */ - public void startBackgroundFetchService() { - LocalNotification n = new LocalNotification(); - n.setId(BACKGROUND_FETCH_NOTIFICATION_ID); - cancelLocalNotification(BACKGROUND_FETCH_NOTIFICATION_ID); - // We schedule a local notification - // First callback will be at the repeat interval - // We don't specify a repeat interval because the scheduleLocalNotification will - // set that for us using the getPreferredBackgroundFetchInterval method. - scheduleLocalNotification(n, System.currentTimeMillis() + getPreferredBackgroundFetchInterval() * 1000, 0); - } - - public void stopBackgroundFetchService() { - cancelLocalNotification(BACKGROUND_FETCH_NOTIFICATION_ID); - } - - - private boolean backgroundFetchInitialized; - - @Override - public void setPreferredBackgroundFetchInterval(int seconds) { - int oldInterval = getPreferredBackgroundFetchInterval(); - super.setPreferredBackgroundFetchInterval(seconds); - - if (!backgroundFetchInitialized || oldInterval != seconds) { - backgroundFetchInitialized = true; - if (seconds > 0) { - startBackgroundFetchService(); - } else { - stopBackgroundFetchService(); - } - } - } - - - - @Override - public boolean isBackgroundFetchSupported() { - return true; - } - public static BackgroundFetch backgroundFetchListener; - - BackgroundFetch getBackgroundFetchListener() { - if (getActivity() != null && getActivity().getApp() instanceof BackgroundFetch) { - return (BackgroundFetch)getActivity().getApp(); - } else if (backgroundFetchListener != null) { - return backgroundFetchListener; - } else { - return null; - } - } - - /** - * Returns the fully qualified class name of the app's background fetch listener, or null - * when the app does not implement {@link com.codename1.background.BackgroundFetch}. The - * surfaces plumbing persists this name on publish so a home screen widget that rendered an - * exhausted timeline can start {@link BackgroundFetchHandler} and let the app republish - * fresh content while no activity exists. - * - * @return the listener class name or null - */ - public static String getBackgroundFetchListenerClassName() { - if (instance == null) { - return null; - } - BackgroundFetch listener = instance.getBackgroundFetchListener(); - return listener == null ? null : listener.getClass().getName(); - } - - public void scheduleLocalNotification(LocalNotification notif, long firstTime, int repeat) { - if (android.os.Build.VERSION.SDK_INT >= 33) { - if(!checkForPermission("android.permission.POST_NOTIFICATIONS", "This is required to receive notifications")){ - com.codename1.io.Log.e(new RuntimeException("Local notification was prevented the POST_NOTIFICATIONS permission was not granted by the user.")); - return; - } - } - final Intent notificationIntent = new Intent(getContext(), LocalNotificationPublisher.class); - notificationIntent.setAction(getContext().getApplicationInfo().packageName + "." + notif.getId()); - notificationIntent.putExtra(LocalNotificationPublisher.NOTIFICATION, createBundleFromNotification(notif)); - - Intent contentIntent = new Intent(); - if (activityComponentName != null) { - contentIntent.setComponent(activityComponentName); - } else { - try { - contentIntent.setComponent(getContext().getPackageManager().getLaunchIntentForPackage(getContext().getApplicationInfo().packageName).getComponent()); - } catch (Exception ex) { - System.err.println("Failed to get the component name for local notification. Local notification may not work."); - ex.printStackTrace(); - } - } - contentIntent.putExtra("LocalNotificationID", notif.getId()); - - if (BACKGROUND_FETCH_NOTIFICATION_ID.equals(notif.getId()) && getBackgroundFetchListener() != null) { - Context context = AndroidNativeUtil.getContext(); - - Intent intent = new Intent(context, BackgroundFetchHandler.class); - //there is an bug that causes this to not to workhttps://code.google.com/p/android/issues/detail?id=81812 - //intent.putExtra("backgroundClass", getBackgroundLocationListener().getName()); - //an ugly workaround to the putExtra bug - intent.setData(Uri.parse("http://codenameone.com/a?" + getBackgroundFetchListener().getClass().getName())); - PendingIntent pendingIntent = getPendingIntent(context, 0, - intent); - notificationIntent.putExtra(LocalNotificationPublisher.BACKGROUND_FETCH_INTENT, pendingIntent); - - } else { - contentIntent.setData(Uri.parse("http://codenameone.com/a?LocalNotificationID="+Uri.encode(notif.getId()))); - } - PendingIntent pendingContentIntent = createPendingIntent(getContext(), 0, contentIntent); - - notificationIntent.putExtra(LocalNotificationPublisher.NOTIFICATION_INTENT, pendingContentIntent); - // carry the configured content intent as a template so the publisher can build - // a distinct per-action PendingIntent (with the action id and any remote input) - if (!notif.getActions().isEmpty()) { - notificationIntent.putExtra(LocalNotificationPublisher.NOTIFICATION_CONTENT_TEMPLATE, contentIntent); - } - - - PendingIntent pendingIntent = getBroadcastPendingIntent(getContext(), 0, notificationIntent); - - AlarmManager alarmManager = (AlarmManager) getContext().getSystemService(Context.ALARM_SERVICE); - if (BACKGROUND_FETCH_NOTIFICATION_ID.equals(notif.getId())) { - alarmManager.setRepeating(AlarmManager.RTC_WAKEUP, firstTime, getPreferredBackgroundFetchInterval() * 1000, pendingIntent); - } else { - if(repeat == LocalNotification.REPEAT_NONE){ - alarmManager.set(AlarmManager.RTC_WAKEUP, firstTime, pendingIntent); - - }else if(repeat == LocalNotification.REPEAT_MINUTE){ - - alarmManager.setRepeating(AlarmManager.RTC_WAKEUP, firstTime, 60*1000, pendingIntent); - - }else if(repeat == LocalNotification.REPEAT_HOUR){ - - alarmManager.setInexactRepeating(AlarmManager.RTC_WAKEUP, firstTime, AlarmManager.INTERVAL_HALF_HOUR, pendingIntent); - - }else if(repeat == LocalNotification.REPEAT_DAY){ - - alarmManager.setInexactRepeating(AlarmManager.RTC_WAKEUP, firstTime, AlarmManager.INTERVAL_DAY, pendingIntent); - - }else if(repeat == LocalNotification.REPEAT_WEEK){ - - alarmManager.setRepeating(AlarmManager.RTC_WAKEUP, firstTime, AlarmManager.INTERVAL_DAY * 7, pendingIntent); - - } - } - } - - public void cancelLocalNotification(String notificationId) { - Intent notificationIntent = new Intent(getContext(), LocalNotificationPublisher.class); - notificationIntent.setAction(getContext().getApplicationInfo().packageName + "." + notificationId); - - PendingIntent pendingIntent = getBroadcastPendingIntent(getContext(), 0, notificationIntent); - AlarmManager alarmManager = (AlarmManager) getContext().getSystemService(Context.ALARM_SERVICE); - alarmManager.cancel(pendingIntent); - } - - static Bundle createBundleFromNotification(LocalNotification notif){ - Bundle b = new Bundle(); - b.putString("NOTIF_ID", notif.getId()); - b.putString("NOTIF_TITLE", notif.getAlertTitle()); - b.putString("NOTIF_BODY", notif.getAlertBody()); - b.putString("NOTIF_SOUND", notif.getAlertSound()); - b.putString("NOTIF_IMAGE", notif.getAlertImage()); - b.putInt("NOTIF_NUMBER", notif.getBadgeNumber()); - b.putString("NOTIF_CHANNEL", notif.getChannelId()); - b.putString("NOTIF_GROUP", notif.getGroupId()); - b.putBoolean("NOTIF_GROUP_SUMMARY", notif.isGroupSummary()); - b.putBoolean("NOTIF_FULLSCREEN", notif.isFullScreenIntent()); - b.putBoolean("NOTIF_TIME_SENSITIVE", notif.isTimeSensitive()); - b.putBoolean("NOTIF_ONGOING", notif.isOngoing()); - b.putInt("NOTIF_PROGRESS_MAX", notif.getProgressMax()); - b.putInt("NOTIF_PROGRESS", notif.getProgress()); - b.putBoolean("NOTIF_PROGRESS_INDETERMINATE", notif.isProgressIndeterminate()); - b.putString("NOTIF_CUSTOM_VIEW", notif.getCustomView()); - java.util.List actions = notif.getActions(); - if (!actions.isEmpty()) { - ArrayList ids = new ArrayList(); - ArrayList titles = new ArrayList(); - ArrayList icons = new ArrayList(); - ArrayList placeholders = new ArrayList(); - ArrayList buttons = new ArrayList(); - for (LocalNotification.Action a : actions) { - ids.add(a.getId()); - titles.add(a.getTitle() == null ? "" : a.getTitle()); - icons.add(a.getIcon() == null ? "" : a.getIcon()); - placeholders.add(a.getTextInputPlaceholder() == null ? "" : a.getTextInputPlaceholder()); - buttons.add(a.getTextInputButtonText() == null ? "" : a.getTextInputButtonText()); - } - b.putStringArrayList("NOTIF_ACTION_IDS", ids); - b.putStringArrayList("NOTIF_ACTION_TITLES", titles); - b.putStringArrayList("NOTIF_ACTION_ICONS", icons); - b.putStringArrayList("NOTIF_ACTION_PLACEHOLDERS", placeholders); - b.putStringArrayList("NOTIF_ACTION_BUTTONS", buttons); - } - LocalNotification.MessagingStyle ms = notif.getMessagingStyle(); - if (ms != null) { - b.putString("NOTIF_MSG_SELF", ms.getSelfDisplayName()); - b.putString("NOTIF_MSG_TITLE", ms.getConversationTitle()); - b.putBoolean("NOTIF_MSG_GROUP", ms.isGroupConversation()); - ArrayList texts = new ArrayList(); - ArrayList senders = new ArrayList(); - long[] times = new long[ms.getMessages().size()]; - int i = 0; - for (LocalNotification.MessagingStyle.Message m : ms.getMessages()) { - texts.add(m.getText() == null ? "" : m.getText()); - senders.add(m.getSenderName() == null ? "" : m.getSenderName()); - times[i++] = m.getTimestamp(); - } - b.putStringArrayList("NOTIF_MSG_TEXTS", texts); - b.putStringArrayList("NOTIF_MSG_SENDERS", senders); - b.putLongArray("NOTIF_MSG_TIMES", times); - } - return b; - } - - static LocalNotification createNotificationFromBundle(Bundle b){ - LocalNotification n = new LocalNotification(); - n.setId(b.getString("NOTIF_ID")); - n.setAlertTitle(b.getString("NOTIF_TITLE")); - n.setAlertBody(b.getString("NOTIF_BODY")); - n.setAlertSound(b.getString("NOTIF_SOUND")); - n.setAlertImage(b.getString("NOTIF_IMAGE")); - n.setBadgeNumber(b.getInt("NOTIF_NUMBER")); - // new fields are guarded so bundles serialized by older builds still parse - if (b.containsKey("NOTIF_CHANNEL")) { - n.setChannelId(b.getString("NOTIF_CHANNEL")); - } - if (b.containsKey("NOTIF_GROUP")) { - n.setGroup(b.getString("NOTIF_GROUP")); - } - n.setGroupSummary(b.getBoolean("NOTIF_GROUP_SUMMARY", false)); - n.setFullScreenIntent(b.getBoolean("NOTIF_FULLSCREEN", false)); - n.setTimeSensitive(b.getBoolean("NOTIF_TIME_SENSITIVE", false)); - n.setOngoing(b.getBoolean("NOTIF_ONGOING", false)); - int progressMax = b.getInt("NOTIF_PROGRESS_MAX", 0); - if (progressMax > 0) { - n.setProgress(progressMax, b.getInt("NOTIF_PROGRESS", 0)); - } - n.setIndeterminateProgress(b.getBoolean("NOTIF_PROGRESS_INDETERMINATE", false)); - if (b.containsKey("NOTIF_CUSTOM_VIEW")) { - n.setCustomView(b.getString("NOTIF_CUSTOM_VIEW")); - } - ArrayList ids = b.getStringArrayList("NOTIF_ACTION_IDS"); - if (ids != null) { - ArrayList titles = b.getStringArrayList("NOTIF_ACTION_TITLES"); - ArrayList icons = b.getStringArrayList("NOTIF_ACTION_ICONS"); - ArrayList placeholders = b.getStringArrayList("NOTIF_ACTION_PLACEHOLDERS"); - ArrayList buttons = b.getStringArrayList("NOTIF_ACTION_BUTTONS"); - for (int i = 0; i < ids.size(); i++) { - String placeholder = placeholders != null ? emptyToNull(placeholders.get(i)) : null; - String button = buttons != null ? emptyToNull(buttons.get(i)) : null; - if (placeholder != null || button != null) { - n.addInputAction(ids.get(i), titles.get(i), placeholder, button); - } else { - String icon = icons != null ? emptyToNull(icons.get(i)) : null; - n.addAction(new LocalNotification.Action(ids.get(i), titles.get(i), icon)); - } - } - } - if (b.containsKey("NOTIF_MSG_SELF")) { - LocalNotification.MessagingStyle ms = n.asMessagingStyle(b.getString("NOTIF_MSG_SELF")); - ms.conversationTitle(b.getString("NOTIF_MSG_TITLE")); - ms.groupConversation(b.getBoolean("NOTIF_MSG_GROUP", false)); - ArrayList texts = b.getStringArrayList("NOTIF_MSG_TEXTS"); - ArrayList senders = b.getStringArrayList("NOTIF_MSG_SENDERS"); - long[] times = b.getLongArray("NOTIF_MSG_TIMES"); - if (texts != null) { - for (int i = 0; i < texts.size(); i++) { - ms.addMessage(texts.get(i), - times != null && i < times.length ? times[i] : 0, - senders != null ? emptyToNull(senders.get(i)) : null); - } - } - } - return n; - } - - private static String emptyToNull(String s) { - return s == null || s.length() == 0 ? null : s; - } - - @Override - public void requestNotificationPermission(final NotificationPermissionRequest request, final NotificationPermissionCallback callback) { - if (callback == null) { - return; - } - final boolean granted; - if (android.os.Build.VERSION.SDK_INT >= 33) { - granted = checkForPermission("android.permission.POST_NOTIFICATIONS", "This is required to receive notifications", true); - } else { - // notifications are allowed by default below Android 13 - granted = true; - } - Display.getInstance().callSerially(new Runnable() { - public void run() { - callback.notificationPermissionResult(new NotificationPermissionResult(granted - ? NotificationPermissionResult.AuthorizationLevel.AUTHORIZED - : NotificationPermissionResult.AuthorizationLevel.DENIED)); - } - }); - } - - @Override - public void registerNotificationChannel(NotificationChannelBuilder builder) { - if (builder == null || android.os.Build.VERSION.SDK_INT < 26) { - return; - } - try { - NotificationManager nm = (NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE); - Class clsChannel = Class.forName("android.app.NotificationChannel"); - Constructor ctor = clsChannel.getConstructor(String.class, CharSequence.class, int.class); - // map our 0..5 importance onto the platform IMPORTANCE_* (NONE=0 .. MAX=5) - Object channel = ctor.newInstance(builder.getId(), builder.getName(), builder.getImportance()); - if (builder.getDescription() != null) { - clsChannel.getMethod("setDescription", String.class).invoke(channel, builder.getDescription()); - } - clsChannel.getMethod("enableLights", boolean.class).invoke(channel, builder.isLightsEnabled()); - if (builder.isLightsEnabled()) { - clsChannel.getMethod("setLightColor", int.class).invoke(channel, builder.getLightColor()); - } - clsChannel.getMethod("enableVibration", boolean.class).invoke(channel, builder.isVibrationEnabled()); - if (builder.getVibrationPattern() != null) { - clsChannel.getMethod("setVibrationPattern", long[].class).invoke(channel, (Object) builder.getVibrationPattern()); - } - clsChannel.getMethod("setLockscreenVisibility", int.class).invoke(channel, builder.getLockscreenVisibility()); - clsChannel.getMethod("setShowBadge", boolean.class).invoke(channel, builder.isShowBadge()); - if (builder.getGroup() != null) { - clsChannel.getMethod("setGroup", String.class).invoke(channel, builder.getGroup()); - } - String sound = builder.getSound(); - if (sound != null && sound.length() > 0) { - sound = sound.toLowerCase(); - Uri uri = Uri.parse("android.resource://" + getContext().getApplicationInfo().packageName + "/raw" - + sound.substring(0, sound.indexOf("."))); - android.media.AudioAttributes attrs = new android.media.AudioAttributes.Builder() - .setContentType(android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION) - .setUsage(android.media.AudioAttributes.USAGE_NOTIFICATION) - .build(); - clsChannel.getMethod("setSound", Uri.class, android.media.AudioAttributes.class).invoke(channel, uri, attrs); - } - nm.getClass().getMethod("createNotificationChannel", clsChannel).invoke(nm, channel); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - } - - @Override - public void deleteNotificationChannel(String channelId) { - if (channelId == null || android.os.Build.VERSION.SDK_INT < 26) { - return; - } - try { - NotificationManager nm = (NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE); - nm.getClass().getMethod("deleteNotificationChannel", String.class).invoke(nm, channelId); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - } - - @Override - public void createNotificationChannelGroup(String groupId, String groupName) { - if (groupId == null || android.os.Build.VERSION.SDK_INT < 26) { - return; - } - try { - NotificationManager nm = (NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE); - Class clsGroup = Class.forName("android.app.NotificationChannelGroup"); - Constructor ctor = clsGroup.getConstructor(String.class, CharSequence.class); - Object group = ctor.newInstance(groupId, groupName); - nm.getClass().getMethod("createNotificationChannelGroup", clsGroup).invoke(nm, group); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - } - - @Override - public void subscribeToPushTopic(final String topic) { - invokeFirebaseTopic("subscribeToTopic", topic); - } - - @Override - public void unsubscribeFromPushTopic(final String topic) { - invokeFirebaseTopic("unsubscribeFromTopic", 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); - } - } - - @Override - public boolean isReceiveSharedContentSupported() { - return true; - } - - private static SharedContent pendingSharedContent; - - /// Delivers shared content received from another app. If the CN1 app instance is - /// running it is dispatched immediately on the EDT; otherwise it is held until the app - /// finishes starting and `#deliverPendingSharedContent()` is invoked. - static void deliverSharedContent(SharedContent content) { - if (content == null) { - return; - } - Object app = CodenameOneImplementation.getCurrentApplicationInstance(); - if (app != null && Display.isInitialized()) { - dispatchSharedContent(app, content); - } else { - pendingSharedContent = content; - } - } - - /// Invoked once the app has started to flush any shared content that arrived before the - /// app instance existed. - public static void deliverPendingSharedContent() { - SharedContent c = pendingSharedContent; - pendingSharedContent = null; - Object app = CodenameOneImplementation.getCurrentApplicationInstance(); - if (c != null && app != null) { - dispatchSharedContent(app, c); - } - } - - private static void dispatchSharedContent(final Object app, final SharedContent content) { - if (!(app instanceof com.codename1.system.Lifecycle)) { - return; - } - Display.getInstance().callSerially(new Runnable() { - public void run() { - ((com.codename1.system.Lifecycle) app).onReceivedSharedContent(content); - } - }); - } - - // ---- Constraint-aware background work (JobScheduler) ---- - - @Override - public boolean isBackgroundWorkSupported() { - return android.os.Build.VERSION.SDK_INT >= 21; - } - - private static int jobIdFor(String id) { - return (id.hashCode() & 0x7fffffff) % 1000000 + 1000; - } - - @Override - public void scheduleBackgroundWork(WorkRequest request) { - if (android.os.Build.VERSION.SDK_INT < 21) { - return; - } - try { - android.app.job.JobScheduler scheduler = - (android.app.job.JobScheduler) getContext().getSystemService(Context.JOB_SCHEDULER_SERVICE); - android.content.ComponentName component = - new android.content.ComponentName(getContext(), CodenameOneJobService.class); - android.app.job.JobInfo.Builder builder = - new android.app.job.JobInfo.Builder(jobIdFor(request.getId()), component); - - if (request.isRequiresUnmeteredNetwork()) { - builder.setRequiredNetworkType(android.app.job.JobInfo.NETWORK_TYPE_UNMETERED); - } else if (request.isRequiresNetwork()) { - builder.setRequiredNetworkType(android.app.job.JobInfo.NETWORK_TYPE_ANY); - } - builder.setRequiresCharging(request.isRequiresCharging()); - if (android.os.Build.VERSION.SDK_INT >= 23) { - builder.setRequiresDeviceIdle(request.isRequiresIdle()); - } - if (android.os.Build.VERSION.SDK_INT >= 26) { - builder.setRequiresBatteryNotLow(request.isRequiresBatteryNotLow()); - } - if (request.isPeriodic()) { - builder.setPeriodic(Math.max(15 * 60 * 1000L, request.getMinIntervalMillis())); - } else { - if (request.getInitialDelayMillis() > 0) { - builder.setMinimumLatency(request.getInitialDelayMillis()); - } - builder.setOverrideDeadline(Math.max(request.getInitialDelayMillis(), 0) + 60 * 60 * 1000L); - } - - PersistableBundle extras = new PersistableBundle(); - extras.putString(CodenameOneJobService.EXTRA_WORKER_CLASS, request.getWorkerClass()); - extras.putString(CodenameOneJobService.EXTRA_WORK_ID, request.getId()); - for (java.util.Map.Entry e : request.getInputData().entrySet()) { - extras.putString(CodenameOneJobService.INPUT_PREFIX + e.getKey(), e.getValue()); - } - builder.setExtras(extras); - scheduler.schedule(builder.build()); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - } - - @Override - public void cancelBackgroundWork(String workId) { - if (android.os.Build.VERSION.SDK_INT < 21) { - return; - } - try { - android.app.job.JobScheduler scheduler = - (android.app.job.JobScheduler) getContext().getSystemService(Context.JOB_SCHEDULER_SERVICE); - scheduler.cancel(jobIdFor(workId)); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - } - - @Override - public boolean isBackgroundProcessingSupported() { - return android.os.Build.VERSION.SDK_INT >= 21; - } - - @Override - public void scheduleBackgroundProcessing(String id, long earliestBeginEpochMs, boolean requiresNetwork, boolean requiresPower, Runnable task) { - if (android.os.Build.VERSION.SDK_INT < 21 || task == null) { - return; - } - try { - CodenameOneJobService.registerProcessingRunnable(id, task); - android.app.job.JobScheduler scheduler = - (android.app.job.JobScheduler) getContext().getSystemService(Context.JOB_SCHEDULER_SERVICE); - android.content.ComponentName component = - new android.content.ComponentName(getContext(), CodenameOneJobService.class); - android.app.job.JobInfo.Builder builder = - new android.app.job.JobInfo.Builder(jobIdFor("proc-" + id), component); - if (requiresNetwork) { - builder.setRequiredNetworkType(android.app.job.JobInfo.NETWORK_TYPE_ANY); - } - builder.setRequiresCharging(requiresPower); - long delay = earliestBeginEpochMs <= 0 ? 0 : Math.max(0, earliestBeginEpochMs - System.currentTimeMillis()); - if (delay > 0) { - builder.setMinimumLatency(delay); - } - builder.setOverrideDeadline(delay + 60 * 60 * 1000L); - PersistableBundle extras = new PersistableBundle(); - extras.putString(CodenameOneJobService.EXTRA_PROCESSING_ID, id); - builder.setExtras(extras); - scheduler.schedule(builder.build()); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - } - - @Override - public void cancelBackgroundProcessing(String id) { - CodenameOneJobService.unregisterProcessingRunnable(id); - if (android.os.Build.VERSION.SDK_INT < 21) { - return; - } - try { - android.app.job.JobScheduler scheduler = - (android.app.job.JobScheduler) getContext().getSystemService(Context.JOB_SCHEDULER_SERVICE); - scheduler.cancel(jobIdFor("proc-" + id)); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - } - - // ---- Foreground service ---- - - @Override - public boolean isForegroundServiceSupported() { - return true; - } - - @Override - public Object startForegroundService(String channelId, String title, String body, String iconName, ForegroundService.Task task, ForegroundService handle) { - int token = CodenameOneForegroundService.registerTask(task, handle, channelId, title, body, iconName); - try { - Intent intent = new Intent(getContext(), CodenameOneForegroundService.class); - intent.setAction(CodenameOneForegroundService.ACTION_START); - intent.putExtra(CodenameOneForegroundService.EXTRA_TOKEN, token); - intent.putExtra(CodenameOneForegroundService.EXTRA_CHANNEL, channelId); - intent.putExtra(CodenameOneForegroundService.EXTRA_TITLE, title); - intent.putExtra(CodenameOneForegroundService.EXTRA_BODY, body); - intent.putExtra(CodenameOneForegroundService.EXTRA_ICON, iconName); - if (android.os.Build.VERSION.SDK_INT >= 26) { - getContext().startForegroundService(intent); - } else { - getContext().startService(intent); - } - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - return Integer.valueOf(token); - } - - @Override - public void updateForegroundServiceNotification(Object nativeHandle, String title, String body) { - try { - Intent intent = new Intent(getContext(), CodenameOneForegroundService.class); - intent.setAction(CodenameOneForegroundService.ACTION_UPDATE); - if (nativeHandle instanceof Integer) { - intent.putExtra(CodenameOneForegroundService.EXTRA_TOKEN, ((Integer) nativeHandle).intValue()); - } - intent.putExtra(CodenameOneForegroundService.EXTRA_TITLE, title); - intent.putExtra(CodenameOneForegroundService.EXTRA_BODY, body); - getContext().startService(intent); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - } - - @Override - public void stopForegroundService(Object nativeHandle) { - try { - Intent intent = new Intent(getContext(), CodenameOneForegroundService.class); - intent.setAction(CodenameOneForegroundService.ACTION_STOP); - if (nativeHandle instanceof Integer) { - intent.putExtra(CodenameOneForegroundService.EXTRA_TOKEN, ((Integer) nativeHandle).intValue()); - } - getContext().startService(intent); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - } - - boolean brokenGaussian; - public Image gaussianBlurImage(Image image, float radius) { - try { - Bitmap outputBitmap = Bitmap.createBitmap((Bitmap)image.getImage()); - - RenderScript rs = RenderScript.create(getContext()); - try { - ScriptIntrinsicBlur theIntrinsic = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs)); - Allocation tmpIn = Allocation.createFromBitmap(rs, (Bitmap)image.getImage()); - Allocation tmpOut = Allocation.createFromBitmap(rs, outputBitmap); - theIntrinsic.setRadius(radius); - theIntrinsic.setInput(tmpIn); - theIntrinsic.forEach(tmpOut); - tmpOut.copyTo(outputBitmap); - tmpIn.destroy(); - tmpOut.destroy(); - theIntrinsic.destroy(); - } finally { - rs.destroy(); - } - - return new NativeImage(outputBitmap); - } catch(Throwable t) { - brokenGaussian = true; - return image; - } - } - - public boolean isGaussianBlurSupported() { - return (!brokenGaussian) && android.os.Build.VERSION.SDK_INT >= 11; - } - - @Override - public boolean blurRegion(Object graphics, int x, int y, int width, int height, float radius) { - if (radius <= 0f || width <= 0 || height <= 0 || !isGaussianBlurSupported()) { - return radius <= 0f || width <= 0 || height <= 0; - } - // In-place CSS backdrop-filter:blur on a mutable-image target. Read/write the - // backing Bitmap directly at absolute coordinates (bypassing the canvas - // transform), Gaussian-blur the region via RenderScript. The live screen - // canvas has no backing Bitmap here -> returns false (component paints - // without the blur). - if (!(graphics instanceof AndroidGraphics)) { - return false; - } - Bitmap dest = ((AndroidGraphics) graphics).underlyingBitmap; - if (dest == null || !dest.isMutable()) { - return false; - } - try { - int rx = Math.max(0, x), ry = Math.max(0, y); - int rw = Math.min(width, dest.getWidth() - rx); - int rh = Math.min(height, dest.getHeight() - ry); - if (rw <= 0 || rh <= 0) { - return true; - } - int[] pix = new int[rw * rh]; - dest.getPixels(pix, 0, rw, rx, ry, rw, rh); - Bitmap region = Bitmap.createBitmap(pix, rw, rh, Bitmap.Config.ARGB_8888); - Bitmap blurred = Bitmap.createBitmap(region); - RenderScript rs = RenderScript.create(getContext()); - try { - ScriptIntrinsicBlur theIntrinsic = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs)); - Allocation tmpIn = Allocation.createFromBitmap(rs, region); - Allocation tmpOut = Allocation.createFromBitmap(rs, blurred); - // RenderScript blur radius is capped at 25. - theIntrinsic.setRadius(Math.min(25f, radius)); - theIntrinsic.setInput(tmpIn); - theIntrinsic.forEach(tmpOut); - tmpOut.copyTo(blurred); - tmpIn.destroy(); - tmpOut.destroy(); - theIntrinsic.destroy(); - } finally { - rs.destroy(); - } - blurred.getPixels(pix, 0, rw, 0, 0, rw, rh); - dest.setPixels(pix, 0, rw, rx, ry, rw, rh); - return true; - } catch (Throwable t) { - brokenGaussian = true; - return false; - } - } - - public static boolean checkForPermission(String permission, String description){ - return checkForPermission(permission, description, false); - } - - public static void setPermissionPromptCallback(PermissionPromptCallback callback) { - permissionPromptCallback = callback; - } - - public static PermissionPromptCallback getPermissionPromptCallback() { - return permissionPromptCallback; - } - - private static String getPermissionText(String key, String defaultValue) { - return UIManager.getInstance().localize(key, Display.getInstance().getProperty(key, defaultValue)); - } - - private static boolean showPermissionPrompt(String permission, String title, String body, String positiveButtonText, String negativeButtonText) { - if (permissionPromptCallback != null) { - return permissionPromptCallback.showPermissionPrompt(permission, title, body, positiveButtonText, negativeButtonText); - } - return Dialog.show(title, body, positiveButtonText, negativeButtonText); - } - - private static void showPermissionMessage(String permission, String title, String body, String okButtonText) { - if (permissionPromptCallback != null) { - permissionPromptCallback.showPermissionMessage(permission, title, body, okButtonText); - return; - } - Dialog.show(title, body, okButtonText, null); - } - - /** - * Return a list of all of the permissions that have been requested by the app (granted or no). - * This can be used to see which permissions are included in the manifest file. - * @return - */ - public static List getRequestedPermissions() { - PackageManager pm = getContext().getPackageManager(); - try - { - PackageInfo packageInfo = pm.getPackageInfo(getContext().getPackageName(), PackageManager.GET_PERMISSIONS); - String[] requestedPermissions = null; - if (packageInfo != null) { - requestedPermissions = packageInfo.requestedPermissions; - return Arrays.asList(requestedPermissions); - } - return new ArrayList(); - } - catch (PackageManager.NameNotFoundException e) - { - com.codename1.io.Log.e(e); - return new ArrayList(); - } - } - - public static boolean checkForPermission(String permission, String description, boolean forceAsk){ - //before sdk 23 no need to ask for permission - if(android.os.Build.VERSION.SDK_INT < 23){ - return true; - } - - if (android.os.Build.VERSION.SDK_INT >= 30 && "android.permission.ACCESS_BACKGROUND_LOCATION".equals(permission)) { - if (android.support.v4.content.ContextCompat.checkSelfPermission(getContext(), permission) == PackageManager.PERMISSION_GRANTED) { - return true; - } - if (getActivity() == null) { - return false; - } - - String prompt = getPermissionText(permission, description); - String title = getPermissionText("android.permission.ACCESS_BACKGROUND_LOCATION.title", "Requires permission"); - String settingsBtn = getPermissionText("android.permission.ACCESS_BACKGROUND_LOCATION.settings", "Settings"); - String cancelBtn = getPermissionText("android.permission.ACCESS_BACKGROUND_LOCATION.cancel", "Cancel"); - - if(showPermissionPrompt(permission, title, prompt, settingsBtn, cancelBtn)){ - Intent intent = new Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS); - Uri uri = Uri.fromParts("package", getContext().getPackageName(), null); - intent.setData(uri); - getActivity().startActivity(intent); - - String explanationTitle = getPermissionText("android.permission.ACCESS_BACKGROUND_LOCATION.explanation_title", "Permission Required"); - String explanationBody = getPermissionText("android.permission.ACCESS_BACKGROUND_LOCATION.explanation_body", "Please enable 'Allow all the time' in the settings, then press OK."); - String okBtn = getPermissionText("android.permission.ACCESS_BACKGROUND_LOCATION.ok", "OK"); - - showPermissionMessage(permission, explanationTitle, explanationBody, okBtn); - return android.support.v4.content.ContextCompat.checkSelfPermission(getActivity(), permission) == PackageManager.PERMISSION_GRANTED; - } else { - return false; - } - } - - String prompt = getPermissionText(permission, description); - - if (android.support.v4.content.ContextCompat.checkSelfPermission(getContext(), - permission) - != PackageManager.PERMISSION_GRANTED) { - - if (getActivity() == null) { - return false; - } - - // Should we show an explanation? - if (!forceAsk && android.support.v4.app.ActivityCompat.shouldShowRequestPermissionRationale(getActivity(), - permission)) { - - // Show an expanation to the user *asynchronously* -- don't block - String title = getPermissionText(permission + ".title", "Requires permission"); - String askAgain = getPermissionText(permission + ".askAgain", "Ask again"); - String dontAsk = getPermissionText(permission + ".dontAsk", "Don't Ask"); - if(showPermissionPrompt(permission, title, prompt, askAgain, dontAsk)){ - return checkForPermission(permission, description, true); - }else { - return false; - } - } else { - - // No explanation needed, we can request the permission. - ((CodenameOneActivity)getActivity()).setRequestForPermission(true); - ((CodenameOneActivity)getActivity()).setWaitingForPermissionResult(true); - android.support.v4.app.ActivityCompat.requestPermissions(getActivity(), - new String[]{permission}, - 1); - //wait for a response - Display.getInstance().invokeAndBlock(new Runnable() { - @Override - public void run() { - while(((CodenameOneActivity)getActivity()).isRequestForPermission()) { - try { - Thread.sleep(50); - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - } - }); - //check again if the permission is given after the dialog was displayed - return android.support.v4.content.ContextCompat.checkSelfPermission(getActivity(), - permission) == PackageManager.PERMISSION_GRANTED; - - } - } - return true; - } - - public boolean isJailbrokenDevice() { - try { - Runtime.getRuntime().exec("su"); - return true; - } catch(Throwable t) { - com.codename1.io.Log.e(t); - } - return false; - } - - @Override - public boolean isAttestationSupported() { - try { - Class.forName("com.google.android.play.core.integrity.IntegrityManagerFactory"); - return true; - } catch(Throwable t) { - return false; - } - } - - @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); - } - return result; - } - - @Override - public boolean isDeviceCompromised() { - return getCompromiseReasons().length > 0; - } - - /** - * Base64 SHA-256 digests of the certificates this APK is actually signed with. - * - *

Uses the v2/v3 signing-block API on API 28 and up, which reports the full - * signing lineage after a key rotation; below that only the legacy v1 signature - * is available. Note that under Play App Signing the digest seen here is - * Google's app signing key, not the developer's upload key -- comparing - * against the upload key is the classic way to make every production install - * report itself as repackaged.

- */ - @Override - public String[] getAppSignerDigests() { - try { - Context ctx = getContext(); - if (ctx == null) { - return new String[0]; - } - PackageManager pm = ctx.getPackageManager(); - String pkg = ctx.getPackageName(); - Signature[] signatures = null; - if (android.os.Build.VERSION.SDK_INT >= 28) { - // Reflection because the port compiles against an older android.jar - // than the devices it runs on, the same reason the Play Integrity - // call in this file is reflective. - signatures = signingCertificatesViaReflection(pm, pkg); - } - if (signatures == null) { - PackageInfo info = pm.getPackageInfo(pkg, PackageManager.GET_SIGNATURES); - signatures = info.signatures; - } - if (signatures == null) { - return new String[0]; - } - java.util.ArrayList out = new java.util.ArrayList(); - for (int i = 0; i < signatures.length; i++) { - MessageDigest md = MessageDigest.getInstance("SHA-256"); - md.update(signatures[i].toByteArray()); - out.add(com.codename1.util.Base64.encodeNoNewline(md.digest())); - } - return out.toArray(new String[out.size()]); - } catch (Throwable t) { - // Reporting nothing is better than failing a request over a - // package-manager quirk on some OEM build. - com.codename1.io.Log.e(t); - return new String[0]; - } - } - - /** - * PackageManager.GET_SIGNING_CERTIFICATES. Inlined because the port compiles - * against an android.jar that predates it. - */ - private static final int FLAG_GET_SIGNING_CERTIFICATES = 0x08000000; - - /** - * Reads the v2/v3 signing certificates on API 28+, or null when unavailable so - * the caller falls back to the legacy v1 signatures. - */ - private static Signature[] signingCertificatesViaReflection(PackageManager pm, String pkg) { - try { - PackageInfo info = pm.getPackageInfo(pkg, FLAG_GET_SIGNING_CERTIFICATES); - java.lang.reflect.Field signingInfoField = - PackageInfo.class.getField("signingInfo"); - Object signingInfo = signingInfoField.get(info); - if (signingInfo == null) { - return null; - } - Class signingInfoClass = signingInfo.getClass(); - boolean multipleSigners = ((Boolean) signingInfoClass - .getMethod("hasMultipleSigners").invoke(signingInfo)).booleanValue(); - // With one signer the history includes the pre-rotation certificates, - // which a server comparing against an older build still needs to accept. - String method = multipleSigners - ? "getApkContentsSigners" - : "getSigningCertificateHistory"; - return (Signature[]) signingInfoClass.getMethod(method).invoke(signingInfo); - } catch (Throwable t) { - return null; - } - } - - @Override - public String[] getCompromiseReasons() { - java.util.ArrayList reasons = new java.util.ArrayList(); - if(isRootedViaRootBeer() || isJailbrokenDevice()) { - reasons.add("root"); - } - try { - if(FridaDetectionUtil.isFridaDetected()) { - reasons.add("frida"); - } - } catch(Throwable t) { - // detection must never crash the host app - } - if(isProbablyEmulator()) { - reasons.add("emulator"); - } - return reasons.toArray(new String[reasons.size()]); - } - - private boolean isRootedViaRootBeer() { - try { - Class rootBeerClass = Class.forName("com.scottyab.rootbeer.RootBeer"); - Object rootBeer = rootBeerClass.getConstructor(Context.class).newInstance(getContext()); - Object rooted = rootBeerClass.getMethod("isRooted").invoke(rootBeer); - return Boolean.TRUE.equals(rooted); - } catch(Throwable t) { - // RootBeer not bundled (android.rootCheck off) - caller falls back to the su probe - return false; - } - } - - private boolean isProbablyEmulator() { - try { - String fingerprint = Build.FINGERPRINT; - if(fingerprint != null && (fingerprint.startsWith("generic") || fingerprint.startsWith("unknown") - || fingerprint.contains("emulator"))) { - return true; - } - String model = Build.MODEL; - if(model != null && (model.contains("google_sdk") || model.contains("Emulator") - || model.contains("Android SDK built for"))) { - return true; - } - String manufacturer = Build.MANUFACTURER; - if(manufacturer != null && manufacturer.contains("Genymotion")) { - return true; - } - String product = Build.PRODUCT; - if(product != null && (product.contains("sdk_gphone") || product.equals("google_sdk") - || product.contains("emulator") || product.contains("simulator"))) { - return true; - } - String hardware = Build.HARDWARE; - if(hardware != null && (hardware.contains("goldfish") || hardware.contains("ranchu"))) { - return true; - } - } catch(Throwable t) { - // ignore - } - return false; - } - - @Override - public String[] getEnabledAccessibilityServices() { - Context context = getContext(); - if(context == null) { - return new String[0]; - } - try { - AccessibilityManager am = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); - if(am != null) { - java.util.List list = - am.getEnabledAccessibilityServiceList( - android.accessibilityservice.AccessibilityServiceInfo.FEEDBACK_ALL_MASK); - if(list != null && !list.isEmpty()) { - java.util.ArrayList ids = new java.util.ArrayList(); - for(android.accessibilityservice.AccessibilityServiceInfo info : list) { - String id = info.getId(); - if(id != null && id.length() > 0) { - ids.add(id); - } - } - return ids.toArray(new String[ids.size()]); - } - } - } catch(Throwable t) { - // fall through to the Settings.Secure based lookup below - } - try { - String enabled = Settings.Secure.getString(context.getContentResolver(), - Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES); - if(enabled != null && enabled.length() > 0) { - return enabled.split(":"); - } - } catch(Throwable t) { - com.codename1.io.Log.e(t); - } - return new String[0]; - } - - @Override - public void setSecureScreen(final boolean secure) { - final Activity act = getActivity(); - if(act == null) { - return; - } - act.runOnUiThread(new Runnable() { - public void run() { - try { - if(secure) { - act.getWindow().addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE); - } else { - act.getWindow().clearFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE); - } - } catch(Throwable t) { - com.codename1.io.Log.e(t); - } - } - }); - } - - @Override - public boolean isHideOverlayWindowsSupported() { - // The permission half matters as much as the API level. Window.setHideOverlayWindows - // throws SecurityException without HIDE_OVERLAY_WINDOWS; reflection wraps it and the - // catch below only logs it, so reporting support on the API level alone would tell an - // app its native peers were protected when in fact nothing happened. It is a normal - // permission, granted at install once the manifest declares it, which the - // android.tapjackingGuard / android.hideOverlayWindows build hints arrange. - return Build.VERSION.SDK_INT >= 31 && hasHideOverlayWindowsPermission(); - } - - /** The last value passed to setHideOverlayWindows, replayed onto a recreated window. */ - private boolean hideOverlayWindowsRequested; - - private boolean hasHideOverlayWindowsPermission() { - try { - Context ctx = getContext(); - if (ctx == null) { - return false; - } - return ctx.checkSelfPermission("android.permission.HIDE_OVERLAY_WINDOWS") - == android.content.pm.PackageManager.PERMISSION_GRANTED; - } catch (Throwable t) { - return false; - } - } - - @Override - public void setHideOverlayWindows(final boolean hide) { - // Recorded before the guards below because it is a request, not a result: the flag - // lives on the Window, and a configuration change destroys and recreates the activity - // without touching this implementation instance. initSurface() replays it onto the new - // window, otherwise an app that hid overlays on a sensitive screen would come back from - // a rotation with them allowed again and no way to notice. - hideOverlayWindowsRequested = hide; - if (Build.VERSION.SDK_INT < 31) { - return; - } - if (!hasHideOverlayWindowsPermission()) { - // Said out loud rather than left to the swallowed SecurityException below: an app - // that calls this without the build hint would otherwise see no effect and no - // explanation for why its overlays were never hidden. - com.codename1.io.Log.p("Codename One: setHideOverlayWindows ignored, the app does " - + "not hold android.permission.HIDE_OVERLAY_WINDOWS. Enable the " - + "android.tapjackingGuard or android.hideOverlayWindows build hint."); - return; - } - final Activity act = getActivity(); - if (act == null) { - return; - } - act.runOnUiThread(new Runnable() { - public void run() { - try { - // Window.setHideOverlayWindows(boolean) is API 31 and absent from the - // android.jar this port compiles against, so it is reached reflectively -- - // the same approach the port uses for the Play Integrity API. - android.view.Window w = act.getWindow(); - if (w == null) { - return; - } - java.lang.reflect.Method m = android.view.Window.class.getMethod( - "setHideOverlayWindows", boolean.class); - m.invoke(w, Boolean.valueOf(hide)); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - } - }); - } - - @Override - public void announceForAccessibility(final Component cmp, final String text) { - final Activity act = getActivity(); - if (act == null) { - return; - } - act.runOnUiThread(new Runnable() { - @Override - public void run() { - View view = null; - if (cmp instanceof PeerComponent) { - Object peer = ((PeerComponent) cmp).getNativePeer(); - if (peer instanceof View) { - view = (View) peer; - } - } - if (view == null) { - view = act.getWindow().getDecorView(); - } - if (view == null) { - return; - } - if (Build.VERSION.SDK_INT >= 16) { - view.announceForAccessibility(text); - } else { - AccessibilityManager manager = (AccessibilityManager) act.getSystemService(Context.ACCESSIBILITY_SERVICE); - if (manager != null && manager.isEnabled()) { - AccessibilityEvent event = AccessibilityEvent.obtain(AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED); - event.getText().add(text); - event.setSource(view); - manager.sendAccessibilityEvent(event); - } - } - } - }); - } - - @Override - public boolean isHighContrastEnabled() { - try { - AccessibilityManager manager = (AccessibilityManager)getContext() - .getSystemService(Context.ACCESSIBILITY_SERVICE); - if (android.os.Build.VERSION.SDK_INT >= 21 && manager != null) { - Object enabled = AccessibilityManager.class.getMethod("isHighTextContrastEnabled") - .invoke(manager); - return enabled instanceof Boolean && ((Boolean)enabled).booleanValue(); - } - } catch (Throwable t) { - // Fall through to the secure settings used by older Android stubs. - } - return secureSettingEnabled("high_text_contrast_enabled") - || secureSettingEnabled("accessibility_display_high_text_contrast_enabled"); - } - - @Override - public boolean isDifferentiateWithoutColorEnabled() { - return secureSettingEnabled("accessibility_display_daltonizer_enabled"); - } - - @Override - public AccessibilityColorVisionDeficiency getColorVisionDeficiency() { - if (!secureSettingEnabled("accessibility_display_daltonizer_enabled")) { - return AccessibilityColorVisionDeficiency.NONE; - } - try { - int mode = Settings.Secure.getInt(getContext().getContentResolver(), - "accessibility_display_daltonizer"); - switch (mode) { - case 0: return AccessibilityColorVisionDeficiency.MONOCHROMACY; - case 11: return AccessibilityColorVisionDeficiency.PROTANOPIA; - case 12: return AccessibilityColorVisionDeficiency.DEUTERANOPIA; - case 13: return AccessibilityColorVisionDeficiency.TRITANOPIA; - default: return AccessibilityColorVisionDeficiency.UNKNOWN; - } - } catch (Throwable t) { - return AccessibilityColorVisionDeficiency.UNKNOWN; - } - } - - @Override - public boolean isReduceMotionEnabled() { - try { - return Settings.Global.getFloat(getContext().getContentResolver(), - Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f; - } catch (Throwable t) { - return false; - } - } - - @Override - public boolean isBoldTextEnabled() { - try { - Object value = Configuration.class.getField("fontWeightAdjustment") - .get(getContext().getResources().getConfiguration()); - return value instanceof Integer && ((Integer)value).intValue() >= 300; - } catch (Throwable t) { - return false; - } - } - - @Override - public boolean isInvertColorsEnabled() { - return secureSettingEnabled("accessibility_display_inversion_enabled"); - } - - @Override - public boolean isGrayscaleEnabled() { - return getColorVisionDeficiency() == AccessibilityColorVisionDeficiency.MONOCHROMACY; - } - - @Override - public boolean isScreenReaderEnabled() { - try { - AccessibilityManager manager = (AccessibilityManager)getContext() - .getSystemService(Context.ACCESSIBILITY_SERVICE); - return manager != null && manager.isEnabled() && manager.isTouchExplorationEnabled(); - } catch (Throwable t) { - return false; - } - } - - private boolean secureSettingEnabled(String key) { - try { - return Settings.Secure.getInt(getContext().getContentResolver(), key, 0) == 1; - } catch (Throwable t) { - return false; - } - } - - @Override - public void accessibilityTreeChanged(final int changeType) { - final Activity act = getActivity(); - if (act == null || accessibilityProvider == null) return; - act.runOnUiThread(new Runnable() { - public void run() { - if (accessibilityProvider != null) accessibilityProvider.invalidate(changeType); - } - }); - } - - @Override - public boolean isAccessibilityTreeSupported() { - return Build.VERSION.SDK_INT >= 16; - } - - @Override - public boolean isAccessibilityTreeUpdateRequired() { - return accessibilityTreeUpdateRequired; - } - - void setAccessibilityTreeUpdateRequired(boolean required) { - accessibilityTreeUpdateRequired = required; - } - - // ================================================================ - // Crypto bridge -- routes com.codename1.security onto the standard - // Android JCE provider. - - private static java.security.SecureRandom androidSecureRandom; - private static final Object androidSecureRandomSync = new Object(); - - private static java.security.SecureRandom androidSecureRandom() { - synchronized (androidSecureRandomSync) { - if (androidSecureRandom == null) { - androidSecureRandom = new java.security.SecureRandom(); - } - return androidSecureRandom; - } - } - - @Override - public void secureRandomBytes(byte[] out) { - if (out == null) return; - androidSecureRandom().nextBytes(out); - } - - @Override - public byte[] aesEncrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] plaintext) { - return androidAes(transformation, key, iv, aad, plaintext, javax.crypto.Cipher.ENCRYPT_MODE); - } - - @Override - public byte[] aesDecrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] ciphertext) { - return androidAes(transformation, key, iv, aad, ciphertext, javax.crypto.Cipher.DECRYPT_MODE); - } - - private static byte[] androidAes(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] input, int mode) { - try { - javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation); - javax.crypto.spec.SecretKeySpec keySpec = new javax.crypto.spec.SecretKeySpec(key, "AES"); - String tu = transformation == null ? "" : transformation.toUpperCase(); - if (tu.indexOf("GCM") >= 0) { - cipher.init(mode, keySpec, new javax.crypto.spec.GCMParameterSpec(128, iv)); - } else if (iv != null) { - cipher.init(mode, keySpec, new javax.crypto.spec.IvParameterSpec(iv)); - } else { - cipher.init(mode, keySpec); - } - if (aad != null && aad.length > 0) { - cipher.updateAAD(aad); - } - return cipher.doFinal(input); - } catch (java.security.GeneralSecurityException e) { - throw new RuntimeException("AES " + (mode == javax.crypto.Cipher.ENCRYPT_MODE ? "encrypt" : "decrypt") + " failed: " + e.getMessage()); - } - } - - /// The RSA transformations this port implements, matched exactly. - /// - /// A substring test for "OAEP" would answer every OAEP name -- including - /// RSA/ECB/OAEPWithSHA-1AndMGF1Padding -- with the SHA-256 parameters below, - /// producing ciphertext no standards-compliant peer could read under the name - /// it asked for. The native ports already accept only these two, so refusing - /// anything else here keeps every port answering the same question. - private static boolean cn1IsOaepTransformation(String transformation) { - return com.codename1.security.Cipher.RSA_OAEP_SHA256.equals(transformation); - } - - private static void cn1CheckRsaTransformation(String transformation) { - if (!cn1IsOaepTransformation(transformation) - && !com.codename1.security.Cipher.RSA_PKCS1.equals(transformation)) { - throw new RuntimeException("unsupported cipher transformation: " + transformation); - } - } - - /// The OAEP parameters every port agrees on. - /// - /// The JCE transformation name "OAEPWithSHA-256AndMGF1Padding" leaves MGF1 on - /// SHA-1 by default, which no other backend here can reproduce: Web Crypto's - /// RSA-OAEP uses one hash for the label and the mask, and so does Apple's - /// SecKey. Naming SHA-256 for both is the only pairing all six ports can - /// produce, so it is what the portable constant means -- stated explicitly - /// rather than inherited from a provider default. - private static javax.crypto.spec.OAEPParameterSpec cn1OaepParameters() { - return new javax.crypto.spec.OAEPParameterSpec("SHA-256", "MGF1", - java.security.spec.MGF1ParameterSpec.SHA256, - javax.crypto.spec.PSource.PSpecified.DEFAULT); - } - - @Override - public byte[] rsaEncrypt(String transformation, byte[] publicKeyX509, byte[] plaintext) { - try { - javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation); - java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); - java.security.PublicKey key = kf.generatePublic(new java.security.spec.X509EncodedKeySpec(publicKeyX509)); - cn1CheckRsaTransformation(transformation); - if (cn1IsOaepTransformation(transformation)) { - cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key, cn1OaepParameters()); - } else { - cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key); - } - return cipher.doFinal(plaintext); - } catch (java.security.GeneralSecurityException e) { - throw new RuntimeException("RSA encrypt failed: " + e.getMessage()); - } - } - - @Override - public byte[] rsaDecrypt(String transformation, byte[] privateKeyPkcs8, byte[] ciphertext) { - try { - javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation); - java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); - java.security.PrivateKey key = kf.generatePrivate(new java.security.spec.PKCS8EncodedKeySpec(privateKeyPkcs8)); - cn1CheckRsaTransformation(transformation); - if (cn1IsOaepTransformation(transformation)) { - cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key, cn1OaepParameters()); - } else { - cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key); - } - return cipher.doFinal(ciphertext); - } catch (java.security.GeneralSecurityException e) { - throw new RuntimeException("RSA decrypt failed: " + e.getMessage()); - } - } - - @Override - public byte[] cryptoSign(String algorithm, String keyAlgorithm, byte[] privateKeyPkcs8, byte[] data) { - try { - java.security.KeyFactory kf = java.security.KeyFactory.getInstance(keyAlgorithm); - java.security.PrivateKey priv = kf.generatePrivate(new java.security.spec.PKCS8EncodedKeySpec(privateKeyPkcs8)); - java.security.Signature sig = java.security.Signature.getInstance(algorithm); - sig.initSign(priv); - sig.update(data); - return sig.sign(); - } catch (java.security.GeneralSecurityException e) { - throw new RuntimeException("sign failed: " + e.getMessage()); - } - } - - @Override - public boolean cryptoVerify(String algorithm, String keyAlgorithm, byte[] publicKeyX509, byte[] data, byte[] signature) { - try { - java.security.KeyFactory kf = java.security.KeyFactory.getInstance(keyAlgorithm); - java.security.PublicKey pub = kf.generatePublic(new java.security.spec.X509EncodedKeySpec(publicKeyX509)); - java.security.Signature sig = java.security.Signature.getInstance(algorithm); - sig.initVerify(pub); - sig.update(data); - return sig.verify(signature); - } catch (java.security.GeneralSecurityException e) { - throw new RuntimeException("verify failed: " + e.getMessage()); - } - } - - @Override - public byte[][] generateRsaKeyPair(int bits) { - try { - java.security.KeyPairGenerator kpg = java.security.KeyPairGenerator.getInstance("RSA"); - kpg.initialize(bits); - java.security.KeyPair kp = kpg.generateKeyPair(); - return new byte[][]{ kp.getPublic().getEncoded(), kp.getPrivate().getEncoded() }; - } catch (java.security.GeneralSecurityException e) { - throw new RuntimeException("RSA keypair generation failed: " + e.getMessage()); - } - } -} +/* + * 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.Manifest; +import android.annotation.TargetApi; +import com.codename1.impl.android.permissions.DevicePermission; +import com.codename1.impl.android.permissions.PermissionsHelper; +import com.codename1.location.AndroidLocationManager; +import android.app.*; +import android.content.pm.PackageManager.NameNotFoundException; +import android.media.AudioTimestamp; +import android.support.v4.content.ContextCompat; +import android.view.MotionEvent; +import com.codename1.codescan.ScanResult; +import com.codename1.media.Media; +import com.codename1.ui.geom.Dimension; + + +import android.webkit.CookieSyncManager; +import android.content.*; +import android.content.pm.*; +import android.content.res.AssetFileDescriptor; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.graphics.Path; +import android.graphics.drawable.Drawable; +import android.media.AudioManager; +import android.net.Uri; +import android.os.Vibrator; +import android.os.PowerManager; +import android.provider.Settings; +import android.telephony.TelephonyManager; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.TypedValue; +import android.view.KeyEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.accessibility.AccessibilityManager; +import android.view.Window; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.RelativeLayout; +import android.widget.TextView; +import com.codename1.ui.BrowserComponent; +import com.codename1.ui.AccessibilityColorVisionDeficiency; + +import com.codename1.ui.Component; +import com.codename1.ui.Font; +import com.codename1.ui.Image; +import com.codename1.ui.PeerComponent; +import com.codename1.ui.ClipboardContent; +import com.codename1.ui.ClipboardDataProvider; +import com.codename1.ui.events.ActionEvent; +import com.codename1.impl.CodenameOneImplementation; +import com.codename1.impl.VirtualKeyboardInterface; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.util.Resources; +import java.lang.ref.SoftReference; +import java.lang.reflect.Method; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.util.Vector; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.graphics.Matrix; +import android.graphics.drawable.BitmapDrawable; +import android.hardware.Camera; +import android.media.AudioFormat; +import android.media.AudioRecord; +import android.media.ExifInterface; +import android.media.MediaPlayer; +import android.media.MediaRecorder; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.os.Build; +import android.os.Bundle; +import android.os.PersistableBundle; +import android.os.Environment; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.os.RemoteException; +import android.provider.MediaStore; +import android.provider.Settings; +import android.provider.Settings.Secure; +import android.renderscript.Allocation; +import android.renderscript.Element; +import android.renderscript.RenderScript; +import android.renderscript.ScriptIntrinsicBlur; +import android.support.v4.app.NotificationCompat; +import android.support.v4.content.FileProvider; +import android.support.v4.media.MediaBrowserCompat; +import android.support.v4.media.session.MediaControllerCompat; +import android.support.v4.media.session.PlaybackStateCompat; +import android.telephony.SmsManager; +import android.telephony.gsm.GsmCellLocation; +import android.text.Html; +import android.view.*; +import android.view.View.MeasureSpec; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityManager; +import android.webkit.*; +import android.widget.*; +import com.codename1.background.BackgroundFetch; +import com.codename1.capture.VideoCaptureConstraints; +import com.codename1.codescan.CodeScanner; +import com.codename1.contacts.Contact; +import com.codename1.db.Database; +import com.codename1.impl.android.compat.app.NotificationCompatWrapper; +import com.codename1.impl.android.compat.app.NotificationCompatWrapper.ActionWrapper; +import com.codename1.impl.android.compat.app.RemoteInputWrapper; +import com.codename1.io.BufferedInputStream; +import com.codename1.io.BufferedOutputStream; +import com.codename1.io.*; +import com.codename1.l10n.L10NManager; +import com.codename1.location.LocationManager; +import com.codename1.media.AbstractMedia; +import com.codename1.media.AsyncMedia; +import com.codename1.media.AsyncMedia.MediaErrorType; +import com.codename1.media.AsyncMedia.MediaException; +import com.codename1.media.Audio; +import com.codename1.media.AudioService; +import com.codename1.media.BackgroundAudioService; +import com.codename1.media.MediaProxy; +import com.codename1.media.MediaRecorderBuilder; +import com.codename1.messaging.Message; +import com.codename1.notifications.LocalNotification; +import com.codename1.notifications.NotificationChannelBuilder; +import com.codename1.notifications.NotificationPermissionCallback; +import com.codename1.notifications.NotificationPermissionRequest; +import com.codename1.notifications.NotificationPermissionResult; +import com.codename1.background.ForegroundService; +import com.codename1.background.WorkRequest; +import com.codename1.share.SharedContent; +import com.codename1.payment.Purchase; +import com.codename1.push.PushAction; +import com.codename1.push.PushActionCategory; +import com.codename1.push.PushActionsProvider; +import com.codename1.push.PushCallback; +import com.codename1.push.PushContent; +import com.codename1.ui.*; +import com.codename1.ui.Dialog; +import com.codename1.ui.Display; +import com.codename1.ui.animations.Animation; +import com.codename1.ui.animations.CommonTransitions; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.geom.GeneralPath; +import com.codename1.ui.geom.Rectangle; +import com.codename1.ui.geom.Shape; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.util.EventDispatcher; +import com.codename1.util.AsyncResource; +import com.codename1.util.Callback; +import java.io.File; +import java.io.BufferedReader; +import java.io.FileDescriptor; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.io.RandomAccessFile; +import java.nio.channels.FileLock; +import java.io.Writer; +import java.lang.reflect.Constructor; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.net.URLConnection; +import java.text.DateFormat; +import java.text.NumberFormat; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.Hashtable; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; +import com.codename1.util.StringUtil; +import com.codename1.util.SuccessCallback; +import java.io.*; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Modifier; +import java.net.CookieHandler; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.NetworkInterface; +import java.net.ServerSocket; +import java.security.MessageDigest; +import java.text.ParseException; +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; +import javax.net.ssl.HttpsURLConnection; +import javax.xml.parsers.ParserConfigurationException; + +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONStringer; +import org.xml.sax.SAXException; +//import android.webkit.JavascriptInterface; + +public class AndroidImplementation extends CodenameOneImplementation implements IntentResultListener { + private AndroidCalendarSource calendarSource; + private static final AtomicLong V3_NOTIFICATION_SEQUENCE = new AtomicLong(); + + public static final Thread.UncaughtExceptionHandler exceptionHandler = new Thread.UncaughtExceptionHandler() { + @Override + public void uncaughtException(Thread t, Throwable e) { + try { + com.codename1.crash.CrashProtection.capture(e); + } catch (Throwable ignore) { + } + } + }; + + public static final int FLAG_ONE_SHOT = 0x40000000; + public static final int FLAG_MUTABLE = 0x02000000; + + public static final int FLAG_IMMUTABLE = 0x04000000; + + /** + * make sure these important keys have a negative value when passed to + * Codename One or they might be interpreted as characters. + */ + static final int DROID_IMPL_KEY_LEFT = -23446; + static final int DROID_IMPL_KEY_RIGHT = -23447; + static final int DROID_IMPL_KEY_UP = -23448; + static final int DROID_IMPL_KEY_DOWN = -23449; + static final int DROID_IMPL_KEY_FIRE = -23450; + static final int DROID_IMPL_KEY_MENU = -23451; + static final int DROID_IMPL_KEY_BACK = -23452; + static final int DROID_IMPL_KEY_BACKSPACE = -23453; + static final int DROID_IMPL_KEY_CLEAR = -23454; + static final int DROID_IMPL_KEY_SEARCH = -23455; + static final int DROID_IMPL_KEY_CALL = -23456; + static final int DROID_IMPL_KEY_VOLUME_UP = -23457; + static final int DROID_IMPL_KEY_VOLUME_DOWN = -23458; + static final int DROID_IMPL_KEY_MUTE = -23459; + static final int DROID_IMPL_KEY_ENTER = -23460; + static final int DROID_IMPL_KEY_TAB = -23461; + static final int DROID_IMPL_KEY_ESCAPE = -23462; + static final int DROID_IMPL_KEY_HOME = -23463; + static final int DROID_IMPL_KEY_END = -23464; + static final int DROID_IMPL_KEY_PAGE_UP = -23465; + static final int DROID_IMPL_KEY_PAGE_DOWN = -23466; + static final int DROID_IMPL_KEY_INSERT = -23467; + static final int DROID_IMPL_KEY_FORWARD_DEL = -23468; + static final int DROID_IMPL_KEY_F1 = -23469; + static final int DROID_IMPL_KEY_F2 = -23470; + static final int DROID_IMPL_KEY_F3 = -23471; + static final int DROID_IMPL_KEY_F4 = -23472; + static final int DROID_IMPL_KEY_F5 = -23473; + static final int DROID_IMPL_KEY_F6 = -23474; + static final int DROID_IMPL_KEY_F7 = -23475; + static final int DROID_IMPL_KEY_F8 = -23476; + static final int DROID_IMPL_KEY_F9 = -23477; + static final int DROID_IMPL_KEY_F10 = -23478; + static final int DROID_IMPL_KEY_F11 = -23479; + static final int DROID_IMPL_KEY_F12 = -23480; + static int[] leftSK = new int[]{DROID_IMPL_KEY_MENU}; + + /** + * @return the activity + */ + public static CodenameOneActivity getActivity() { + return activity; + } + + // ---- low level text input source (pure Codename One editors) ---- + + private static volatile com.codename1.ui.TextInputClient activeInputClient; + private static volatile com.codename1.ui.TextInputState activeInputState; + private static volatile com.codename1.ui.TextInputConfig activeInputConfig; + /// Synchronous mirror of edits the input connection has posted but the EDT has not yet + /// applied and echoed back. IMEs (notably Gboard) commit text and immediately re-read the + /// surrounding text; without this mirror they would see pre-commit text and desync their + /// suggestion model. Cleared when the authoritative state from the EDT has caught up with + /// every posted edit (the seq pair below). + private static volatile com.codename1.ui.TextInputState pendingInputState; + /// Generation of the last edit the input connection posted (written on the IME thread). + private static volatile int pendingPostedSeq; + /// Generation of the last posted edit the EDT applied (written on the EDT). + private static volatile int pendingAppliedSeq; + + /// Returns the editing state as the IME must see it right now: the pending synchronous + /// mirror when an edit is in flight, otherwise the last state pushed from the EDT. + static com.codename1.ui.TextInputState currentInputState() { + com.codename1.ui.TextInputState pending = pendingInputState; + return pending != null ? pending : activeInputState; + } + + /// Records the input connection's synchronous mirror of an in-flight edit and returns the + /// edit's generation; the connection marks it applied from the EDT runnable that delivers + /// the edit to the client. + static int setPendingInputState(com.codename1.ui.TextInputState state) { + pendingInputState = state; + return ++pendingPostedSeq; + } + + /// Marks a posted edit as applied on the EDT (called right before the client mutation whose + /// state push may then retire the mirror). + static void markPendingApplied(int seq) { + pendingAppliedSeq = seq; + } + + /// Routes a hardware (Bluetooth / Chromebook) key event to the bound text input client. + /// Hardware keys bypass the IME entirely, and the pure editor's raw key path is disabled + /// while a platform session is active, so without this they would be silently dropped. + /// Returns true when the event was consumed for the client (including the matching key-up + /// of a consumed key-down); false leaves the event to the regular Codename One pipeline + /// (BACK, D-pad game keys on non-editor forms, ...). + static boolean routeHardwareKeyToActiveClient(boolean down, android.view.KeyEvent event) { + com.codename1.ui.TextInputClient client = activeInputClient; + if (client == null || event == null) { + return false; + } + return CN1TextInputConnection.deliverHardwareKey(client, event, down); + } + + /// Re-requests the soft keyboard for the bound text input client. Called on every tap so a + /// keyboard the user dismissed (back gesture) returns when the editor is tapped again, the + /// same behavior a native EditText has. No-op when no client is bound. + static void showSoftInputForActiveClient() { + if (activeInputClient == null) { + return; + } + final CodenameOneActivity a = getActivity(); + final CodenameOneSurface view = instance != null ? instance.myView : null; + if (a == null || view == null) { + return; + } + a.runOnUiThread(new Runnable() { + public void run() { + if (activeInputClient == null) { + return; + } + android.view.View v = view.getAndroidView(); + v.requestFocus(); + android.view.inputmethod.InputMethodManager imm = (android.view.inputmethod.InputMethodManager) + a.getSystemService(android.content.Context.INPUT_METHOD_SERVICE); + if (imm != null) { + imm.showSoftInput(v, 0); + } + } + }); + } + + static com.codename1.ui.TextInputConfig currentInputConfig() { + return activeInputConfig; + } + + /// Called by the rendering view's `onCreateInputConnection` to supply the custom input connection + /// when a pure editor is bound. Returns null when no client is active so the view keeps its default + /// behavior. + static android.view.inputmethod.InputConnection createEditorInputConnection(android.view.View view, android.view.inputmethod.EditorInfo editorInfo) { + com.codename1.ui.TextInputClient client = activeInputClient; + if (client == null) { + return null; + } + configureEditorInfo(editorInfo, activeInputConfig); + return new CN1TextInputConnection(view, client); + } + + /// True when a pure editor text input client is currently bound. + static boolean hasActiveInputClient() { + return activeInputClient != null; + } + + /// The Android autofill hint for a one-time code, spelled out rather than referenced as + /// `View.AUTOFILL_HINT_SMS_OTP` because the constant is newer than the SDK this port + /// compiles against. The string is the contract: it is what an autofill service matches on. + private static final String AUTOFILL_HINT_SMS_OTP = "smsOTPCode"; + + /// What the platform may fill into the currently bound field, or null when it is not a field + /// the platform can fill. + /// + /// Only the one-time code is offered. The rendering surface is a single view standing in for + /// whichever field is being edited, so claiming a hint puts the whole surface forward as that + /// kind of field -- true only while the code field holds the session, which is why the hint is + /// applied when a session starts and dropped when it ends. + private static String[] editorAutofillHints() { + com.codename1.ui.TextInputConfig cfg = activeInputConfig; + if (cfg != null && (cfg.getConstraint() & com.codename1.ui.TextArea.ONE_TIME_CODE) != 0) { + return new String[]{AUTOFILL_HINT_SMS_OTP}; + } + return null; + } + + /// Puts the surface forward as an autofillable field, or withdraws it, to match the field the + /// input session is bound to. Called on the UI thread as a session starts and stops. + /// + /// #### Parameters + /// + /// - `v`: the rendering view + /// + /// - `sessionActive`: true while a client is bound + static void updateEditorAutofill(android.view.View v, boolean sessionActive) { + if (v == null || android.os.Build.VERSION.SDK_INT < 26) { + return; + } + android.view.autofill.AutofillManager afm = + (android.view.autofill.AutofillManager) v.getContext() + .getSystemService(android.view.autofill.AutofillManager.class); + String[] hints = sessionActive ? editorAutofillHints() : null; + if (hints == null) { + v.setImportantForAutofill(android.view.View.IMPORTANT_FOR_AUTOFILL_NO); + v.setAutofillHints((String[]) null); + if (afm != null) { + afm.notifyViewExited(v); + } + return; + } + v.setAutofillHints(hints); + v.setImportantForAutofill(android.view.View.IMPORTANT_FOR_AUTOFILL_YES); + if (afm != null) { + // the session only starts once the framework is told the view was entered; a view + // that merely carries hints is never offered anything + afm.notifyViewEntered(v); + } + } + + /// Applies a value the platform filled in, replacing whatever the field held. Called by the + /// rendering view on the UI thread; the edit itself belongs to the EDT. + /// + /// #### Parameters + /// + /// - `value`: the value the autofill service supplied + /// + /// #### Returns + /// + /// true when the value was taken + static boolean autofillEditor(android.view.autofill.AutofillValue value) { + final com.codename1.ui.TextInputClient client = activeInputClient; + if (client == null || value == null || !value.isText()) { + return false; + } + // Only into a field that asked for this. The hint lives on the surface and is put + // there and taken away on Android's UI thread, while the session it describes changes + // on the EDT, so for a moment after the user moves from a code field to an ordinary + // one the view still advertises smsOTPCode while the session behind it is something + // else. A fill delivered in that gap would otherwise land a code in whatever the user + // tapped into. Asking what the CURRENT session advertises closes it: the answer is + // read from the same field the identity check below uses. + if (editorAutofillHints() == null) { + return false; + } + com.codename1.ui.Display.getInstance().callSerially( + new ApplyAutofilledText(client, value.getTextValue().toString())); + return true; + } + + private static final class ApplyAutofilledText implements Runnable { + private final com.codename1.ui.TextInputClient client; + private final String text; + + ApplyAutofilledText(com.codename1.ui.TextInputClient client, String text) { + this.client = client; + this.text = text; + } + + public void run() { + // The session may be gone: the platform fills on the UI thread and this runs a hop + // later on the EDT, and in between the user can have moved to another field or left + // the screen. Applying it then would edit a field nothing is bound to any more and + // fire its listeners -- and an OtpField's completion listener submits a code, so a + // late fill would verify one for a flow the user has already left. The rest of this + // bridge guards its callbacks the same way. + if (client != activeInputClient || editorAutofillHints() == null) { + return; + } + // A filled value replaces the field rather than being inserted at the caret: the + // platform is answering "the value is this", not typing into what is there. It + // still arrives as a commit rather than a raw range replacement, because a field + // filters what it accepts and a filled value has no more right to bypass that + // than a typed one -- an OTP field asked for six digits and can be handed + // "123-456" by an autofill service that kept the separator, and a replacement + // would leave the field holding a value it would never have let anyone type, + // never reaching the length that completes it. + // Ending any composition first. A commit replaces the composed range in + // preference to the selection, so selecting the whole field is not enough to + // replace the whole field while an input method is mid-word: the filled value + // would land inside the composition and leave whatever surrounded it, which + // for a code field means a full-length wrong code that submits itself. + client.finishComposing(); + client.setSelectionRange(0, client.getTextLength()); + client.commitText(text); + } + } + + /// The value the platform should see for the bound field, or null when nothing is bound. + /// + /// Answered from the state snapshot rather than the editor itself. This runs on Android's UI + /// thread whenever an autofill service asks what the field holds, while the document belongs + /// to the EDT, and reading a length and then a range out of a document another thread is + /// editing is two reads of something that can change in between. Clamped offsets would not + /// rescue it either, since the buffer underneath can be restructured mid-read. The snapshot + /// is immutable and is what the rest of this bridge already uses to answer the platform + /// across that boundary; a value one edit out of date is the correct trade against a crash + /// inside somebody else's autofill query. + static android.view.autofill.AutofillValue editorAutofillValue() { + // Read the state AFTER the guards and confirm the session did not move under it. + // The three fields are assigned separately on the EDT, so taking the state first + // and validating afterwards can pair one field's text with the next field's + // configuration -- and the pairing that matters is a password field's text with a + // code field's hint. One session snapshot would express this better than three + // fields and a re-check, but that is the whole input bridge's shape rather than + // this method's, and the property needed here is only that nothing is returned + // for a session other than the one that was checked. + // + // Gated the same way the write path is, and for a sharper reason: between the EDT + // moving to another field and the UI thread taking the hint off the view, the + // surface still looks like a code field over a session that is something else -- + // and answering this query then would hand that field's text to an SMS autofill + // service. The field after a code field is as likely to be a password as anything. + com.codename1.ui.TextInputClient client = activeInputClient; + if (client == null || editorAutofillHints() == null) { + return null; + } + com.codename1.ui.TextInputState state = activeInputState; + if (state == null || client != activeInputClient) { + return null; + } + String text = state.getText(); + return android.view.autofill.AutofillValue.forText(text == null ? "" : text); + } + + private static void configureEditorInfo(android.view.inputmethod.EditorInfo editorInfo, com.codename1.ui.TextInputConfig cfg) { + int constraint = cfg == null ? 0 : cfg.getConstraint(); + int inputType; + switch (constraint & 0xffff) { + case com.codename1.ui.TextArea.NUMERIC: + inputType = android.text.InputType.TYPE_CLASS_NUMBER + | android.text.InputType.TYPE_NUMBER_FLAG_SIGNED; + break; + case com.codename1.ui.TextArea.DECIMAL: + inputType = android.text.InputType.TYPE_CLASS_NUMBER + | android.text.InputType.TYPE_NUMBER_FLAG_SIGNED + | android.text.InputType.TYPE_NUMBER_FLAG_DECIMAL; + break; + case com.codename1.ui.TextArea.PHONENUMBER: + inputType = android.text.InputType.TYPE_CLASS_PHONE; + break; + case com.codename1.ui.TextArea.EMAILADDR: + inputType = android.text.InputType.TYPE_CLASS_TEXT + | android.text.InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS; + break; + case com.codename1.ui.TextArea.URL: + inputType = android.text.InputType.TYPE_CLASS_TEXT + | android.text.InputType.TYPE_TEXT_VARIATION_URI; + break; + default: + inputType = android.text.InputType.TYPE_CLASS_TEXT; + break; + } + boolean text = (inputType & android.text.InputType.TYPE_MASK_CLASS) == android.text.InputType.TYPE_CLASS_TEXT; + boolean password = (constraint & com.codename1.ui.TextArea.PASSWORD) != 0; + if (password) { + inputType = text + ? inputType | android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD + : android.text.InputType.TYPE_CLASS_NUMBER | android.text.InputType.TYPE_NUMBER_VARIATION_PASSWORD; + text = (inputType & android.text.InputType.TYPE_MASK_CLASS) == android.text.InputType.TYPE_CLASS_TEXT; + } + boolean multiline = cfg == null || cfg.isMultiline(); + if (text) { + if (multiline) { + inputType |= android.text.InputType.TYPE_TEXT_FLAG_MULTI_LINE; + } + if (password || (cfg != null && !cfg.isAutoCorrect())) { + inputType |= android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS; + } + if (!password && cfg != null && cfg.isAutoCapitalize()) { + inputType |= android.text.InputType.TYPE_TEXT_FLAG_CAP_SENTENCES; + } + } + if ((constraint & com.codename1.ui.TextArea.ONE_TIME_CODE) != 0 && text) { + // a code is not a word: prediction would offer completions for it and, worse, learn it + inputType |= android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS; + } + editorInfo.inputType = inputType; + editorInfo.imeOptions = android.view.inputmethod.EditorInfo.IME_FLAG_NO_EXTRACT_UI; + if (multiline) { + editorInfo.imeOptions |= android.view.inputmethod.EditorInfo.IME_ACTION_NONE; + } else { + editorInfo.imeOptions |= imeActionFor(cfg == null + ? com.codename1.ui.TextInputConfig.ACTION_DEFAULT : cfg.getActionType()); + } + editorInfo.initialSelStart = activeInputState != null ? activeInputState.getSelectionStart() : 0; + editorInfo.initialSelEnd = activeInputState != null ? activeInputState.getSelectionEnd() : 0; + } + + private static int imeActionFor(int actionType) { + switch (actionType) { + case com.codename1.ui.TextInputConfig.ACTION_NEXT: + return android.view.inputmethod.EditorInfo.IME_ACTION_NEXT; + case com.codename1.ui.TextInputConfig.ACTION_SEARCH: + return android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH; + case com.codename1.ui.TextInputConfig.ACTION_SEND: + return android.view.inputmethod.EditorInfo.IME_ACTION_SEND; + case com.codename1.ui.TextInputConfig.ACTION_DONE: + default: + return android.view.inputmethod.EditorInfo.IME_ACTION_DONE; + } + } + + /// Maps an Android `EditorInfo.IME_ACTION_*` code back to the `TextInputConfig` action constant + /// delivered to `TextInputClient.onEditorAction`. + static int textInputActionFor(int imeActionCode) { + switch (imeActionCode) { + case android.view.inputmethod.EditorInfo.IME_ACTION_NEXT: + return com.codename1.ui.TextInputConfig.ACTION_NEXT; + case android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH: + return com.codename1.ui.TextInputConfig.ACTION_SEARCH; + case android.view.inputmethod.EditorInfo.IME_ACTION_SEND: + return com.codename1.ui.TextInputConfig.ACTION_SEND; + case android.view.inputmethod.EditorInfo.IME_ACTION_DONE: + return com.codename1.ui.TextInputConfig.ACTION_DONE; + default: + return com.codename1.ui.TextInputConfig.ACTION_DEFAULT; + } + } + + @Override + public boolean isTextInputSupported() { + return true; + } + + @Override + public Object startTextInput(com.codename1.ui.TextInputClient client, com.codename1.ui.TextInputConfig config) { + activeInputClient = client; + activeInputConfig = config; + activeInputState = client.getEditingState(); + pendingInputState = null; + final CodenameOneActivity a = getActivity(); + final CodenameOneSurface view = myView; + if (a == null || view == null) { + return client; + } + a.runOnUiThread(new Runnable() { + public void run() { + android.view.View v = view.getAndroidView(); + v.setFocusable(true); + v.setFocusableInTouchMode(true); + v.requestFocus(); + android.view.inputmethod.InputMethodManager imm = (android.view.inputmethod.InputMethodManager) + a.getSystemService(android.content.Context.INPUT_METHOD_SERVICE); + if (imm != null) { + imm.restartInput(v); + imm.showSoftInput(v, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT); + } + updateEditorAutofill(v, true); + } + }); + return client; + } + + @Override + public void updateTextInputState(Object handle, com.codename1.ui.TextInputState state) { + if (handle == null || handle != activeInputClient || state == null) { + // a stale handle (an unbalanced session that was already replaced) must not + // disturb the currently bound client + return; + } + activeInputState = state; + // retire the connection's synchronous mirror only when this push reflects every posted + // edit; clearing early would hide an in-flight edit from the IME's immediate re-reads + if (pendingAppliedSeq == pendingPostedSeq) { + pendingInputState = null; + } + final CodenameOneActivity a = getActivity(); + final CodenameOneSurface view = myView; + if (a == null || view == null) { + return; + } + a.runOnUiThread(new Runnable() { + public void run() { + android.view.inputmethod.InputMethodManager imm = (android.view.inputmethod.InputMethodManager) + a.getSystemService(android.content.Context.INPUT_METHOD_SERVICE); + if (imm != null && activeInputClient != null) { + com.codename1.ui.TextInputState s = activeInputState; + imm.updateSelection(view.getAndroidView(), s.getSelectionStart(), s.getSelectionEnd(), + s.getComposingStart(), s.getComposingEnd()); + } + } + }); + } + + @Override + public void stopTextInput(Object handle) { + if (handle == null || handle != activeInputClient) { + return; + } + activeInputClient = null; + activeInputState = null; + activeInputConfig = null; + pendingInputState = null; + final CodenameOneActivity a = getActivity(); + final CodenameOneSurface view = myView; + if (a == null || view == null) { + return; + } + a.runOnUiThread(new Runnable() { + public void run() { + android.view.inputmethod.InputMethodManager imm = (android.view.inputmethod.InputMethodManager) + a.getSystemService(android.content.Context.INPUT_METHOD_SERVICE); + if (imm != null) { + imm.hideSoftInputFromWindow(view.getAndroidView().getWindowToken(), 0); + imm.restartInput(view.getAndroidView()); + } + updateEditorAutofill(view.getAndroidView(), false); + } + }); + } + + + @Override + public void setDisableScreenshots(final boolean disable) { + final CodenameOneActivity a = getActivity(); + if (a == null || a.getWindow() == null) { + return; + } + a.runOnUiThread(new Runnable() { + @Override + public void run() { + if (disable) { + a.getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE); + } else { + a.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE); + } + } + }); + } + + /** + * @param aActivity the activity to set + */ + public static void setActivity(CodenameOneActivity aActivity) { + activity = aActivity; + if (activity != null) { + activityComponentName = activity.getComponentName(); + } + + } + CodenameOneSurface myView = null; + private AndroidAccessibilityProvider accessibilityProvider; + private volatile boolean accessibilityTreeUpdateRequired; + CodenameOneTextPaint defaultFont; + private final char[] tmpchar = new char[1]; + private final Rect tmprect = new Rect(); + protected int defaultFontHeight; + private Vibrator v = null; + private boolean vibrateInitialized = false; + private int displayWidth; + private int displayHeight; + static CodenameOneActivity activity; + static ComponentName activityComponentName; + private static PowerManager.WakeLock pushWakeLock; + public static synchronized void acquirePushWakeLock(long timeout) { + if (getContext() == null) return; + try { + if (pushWakeLock == null) { + PowerManager pm = (PowerManager) getContext().getSystemService(Context.POWER_SERVICE); + pushWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "CN1:PushWakeLock"); + } + pushWakeLock.acquire(timeout); + } catch (Exception ex) { + com.codename1.io.Log.e(ex); + } + } + + private static Context context; + private static PermissionPromptCallback permissionPromptCallback; + RelativeLayout relativeLayout; + final Vector nativePeers = new Vector(); + int lastDirectionalKeyEventReceivedByWrapper; + private EventDispatcher callback; + private int timeout = -1; + private CodeScannerImpl scannerInstance; + private HashMap apIds; + private static View viewBelow; + private static View viewAbove; + private static int aboveSpacing; + private static int belowSpacing; + public static boolean asyncView = false; + public static boolean textureView = false; + private AudioService background; + private boolean asyncEditMode = false; + private boolean compatPaintMode; + private MediaRecorder recorder = null; + + private boolean statusBarHidden; + private boolean superPeerMode = true; + + + private ValueCallback mUploadMessage; + public ValueCallback uploadMessage; + + /** + * Keeps track of running contexts. + * @see #startContext(Context) + * @see #stopContext(Context) + */ + private static HashSet activeContexts = new HashSet(); + + /** + * A method to be called when a Context begins its execution. This adds the + * context to the context set. When the contenxt's execution completes, it should + * call {@link #stopContext} to clear up resources. + * @param ctx The context that is starting. + * @see #stopContext(Context) + */ + public static void startContext(Context ctx) { + + while (deinitializingEdt) { + // It is possible that deinitialize was called just before the + // last context was destroyed so there is a pending deinitialize + // working its way through the system. Give it some time + // before forcing the deinitialize + System.out.println("Waiting for deinitializing to complete before starting a new initialization"); + Util.sleep(30); + } + if (deinitializing && instance != null) { + instance.deinitialize(); + } + synchronized(activeContexts) { + activeContexts.add(ctx); + if (instance == null) { + // If this is our first rodeo, just call Display.init() as that should + // be sufficient to set everything up. + Display.init(ctx); + } else { + // If we've initialized before, we should "re-initialize" the implementation + // Reinitializing will force views to be created even if the EDT was already + // running in background mode. + reinit(ctx); + } + } + } + + /** + * Cleans up resources in the given context. This method should be called by + * any Activity or Service that called startContext() when it started. + * @param ctx The context to stop. + * + * @see #startContext(Context) + */ + public static void stopContext(Context ctx) { + synchronized(activeContexts) { + activeContexts.remove(ctx); + if (activeContexts.isEmpty()) { + // If we are the last context, we should deinitialize + syncDeinitialize(); + } else { + if (instance != null && getActivity() != null) { + // if this is an activity, then we should clean up + // our UI resources anyways because the last context + // to be cleaned up might not have access to the UI thread. + instance.deinitialize(); + } + } + } + } + + @Override + public void screenshot(SuccessCallback callback) { + final Activity activity = (Activity) getContext(); + final AndroidScreenshotTask task = new AndroidScreenshotTask(myView, activity, callback); + activity.runOnUiThread(task); + } + + @Override + public void setPlatformHint(String key, String value) { + if(key.equals("platformHint.compatPaintMode")) { + compatPaintMode = value.equalsIgnoreCase("true"); + return; + } + if(key.equals("platformHint.legacyPaint")) { + AndroidAsyncView.legacyPaintLogic = value.equalsIgnoreCase("true");; + } + } + + + /** + * This method in used internally for ads + * @param above shown above the view + * @param below shown below the view + */ + public static void setViewAboveBelow(View above, View below, int spacingAbove, int spacingBelow) { + viewBelow = below; + viewAbove = above; + aboveSpacing = spacingAbove; + belowSpacing = spacingBelow; + } + + static boolean hasViewAboveBelow(){ + return viewBelow != null || viewAbove != null; + } + + /** + * Copy the input stream into the output stream, closes both streams when finishing or in + * a case of an exception + * + * @param i source + * @param o destination + */ + private static void copy(InputStream i, OutputStream o) throws IOException { + copy(i, o, 8192); + } + + /** + * Copy the input stream into the output stream, closes both streams when finishing or in + * a case of an exception + * + * @param i source + * @param o destination + * @param bufferSize the size of the buffer, which should be a power of 2 large enoguh + */ + private static void copy(InputStream i, OutputStream o, int bufferSize) throws IOException { + try { + byte[] buffer = new byte[bufferSize]; + int size = i.read(buffer); + while(size > -1) { + o.write(buffer, 0, size); + size = i.read(buffer); + } + } finally { + sCleanup(o); + sCleanup(i); + } + } + + private static void sCleanup(Object o) { + try { + if(o != null) { + if(o instanceof InputStream) { + ((InputStream)o).close(); + return; + } + if(o instanceof OutputStream) { + ((OutputStream)o).close(); + return; + } + } + } catch(Throwable t) {} + } + + /** + * Copied here since the cleanup method in util would crash append notification that runs when the app isn't in the foreground + */ + private static byte[] readInputStream(InputStream i) throws IOException { + ByteArrayOutputStream b = new ByteArrayOutputStream(); + copy(i, b); + return b.toByteArray(); + } + + + public static void appendNotification(String type, String body, Context a) { + appendNotification(type, body, null, null, a); + } + + /** Receives the managed typed envelope from FCM without applying legacy push decoding. */ + public static void handleV3Push(final String envelope, Context context, + boolean appRunning, Class appStubClass) { + if (appRunning && Display.isInitialized() + && com.codename1.push.PushClient.hasActiveClient()) { + Display.getInstance().callSerially(new Runnable() { + public void run() { + com.codename1.push.PushClient.dispatch(envelope); + } + }); + return; + } + try { + org.json.JSONObject message = new org.json.JSONObject(envelope); + // The pending-push file explicitly encodes whether a legacy type is present. + // A missing type is the sentinel for a typed V3 envelope and is replayed intact. + appendNotification(null, envelope, context); + if (message.optBoolean("silent", false)) { + return; + } + String title = message.optString("title", ""); + String body = message.optString("body", ""); + String image = message.optString("image", ""); + if (title.length() == 0 && body.length() == 0 && image.length() == 0) { + return; + } + if (title.length() == 0) { + title = context.getApplicationInfo().loadLabel(context.getPackageManager()).toString(); + } + Intent intent = new Intent(context, appStubClass); + PendingIntent contentIntent = createPendingIntent(context, 0, intent); + int smallIcon = context.getResources().getIdentifier("ic_stat_notify", "drawable", + context.getPackageName()); + if (smallIcon == 0) { + smallIcon = context.getApplicationInfo().icon; + } + NotificationCompat.Builder builder = new NotificationCompat.Builder(context) + .setContentTitle(title) + .setContentText(body) + .setSmallIcon(smallIcon) + .setContentIntent(contentIntent) + .setAutoCancel(true) + .setWhen(System.currentTimeMillis()); + NotificationManager manager = (NotificationManager) + context.getSystemService(Context.NOTIFICATION_SERVICE); + setNotificationChannel(manager, builder, context); + String collapseKey = message.optString("collapseKey", null); + String messageId = message.optString("id", null); + String notificationTag; + if (collapseKey != null && collapseKey.length() > 0) { + notificationTag = v3NotificationTag("CN1_PUSH_V3_COLLAPSE:", collapseKey); + } else if (messageId != null && messageId.length() > 0) { + notificationTag = v3NotificationTag("CN1_PUSH_V3_MESSAGE:", messageId); + } else { + notificationTag = "CN1_PUSH_V3_EPHEMERAL:" + System.currentTimeMillis() + + ":" + V3_NOTIFICATION_SEQUENCE.incrementAndGet(); + } + manager.notify(notificationTag, 0, builder.build()); + } catch (Exception error) { + Log.e("Codename One", "Failed to handle a Push V3 envelope", error); + } + } + + private static String v3NotificationTag(String prefix, String value) { + if (prefix.length() + value.length() <= 128) { + return prefix + value; + } + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder out = new StringBuilder(prefix.length() + digest.length * 2); + out.append(prefix); + for (byte item : digest) { + int unsigned = item & 0xff; + if (unsigned < 0x10) { + out.append('0'); + } + out.append(Integer.toHexString(unsigned)); + } + return out.toString(); + } catch (Exception error) { + return prefix + Integer.toHexString(value.hashCode()); + } + } + + public static void appendNotification(String type, String body, String image, String category, Context a) { + try { + String[] fileList = a.fileList(); + byte[] data = null; + for (int iter = 0; iter < fileList.length; iter++) { + if (fileList[iter].equals("CN1$AndroidPendingNotifications")) { + InputStream is = a.openFileInput("CN1$AndroidPendingNotifications"); + if(is != null) { + data = readInputStream(is); + sCleanup(a); + break; + } + } + } + DataOutputStream os = new DataOutputStream(a.openFileOutput("CN1$AndroidPendingNotifications", 0)); + if(data != null) { + data[0]++; + os.write(data); + } else { + os.writeByte(1); + } + String bodyType = type; + if (image != null || category != null) { + type = "99"; + } + if(type != null) { + os.writeBoolean(true); + os.writeUTF(type); + } else { + os.writeBoolean(false); + } + if ("99".equals(type)) { + String msg = "body="+java.net.URLEncoder.encode(body, "UTF-8") + +"&type="+java.net.URLEncoder.encode(bodyType, "UTF-8"); + if (category != null) { + msg += "&category="+java.net.URLEncoder.encode(category, "UTF-8"); + } + if (image != null) { + msg += "&image="+java.net.URLEncoder.encode(image, "UTF-8"); + } + os.writeUTF(msg); + + } else { + os.writeUTF(body); + } + os.writeLong(System.currentTimeMillis()); + } catch(IOException err) { + err.printStackTrace(); + } + } + + private static Map splitQuery(String urlencodeQueryString) { + String[] parts = urlencodeQueryString.split("&"); + Map out = new HashMap(); + for (String part : parts) { + int pos = part.indexOf("="); + String k,v; + if (pos > 0) { + k = part.substring(0, pos); + v = part.substring(pos+1); + } else { + k = part; + v = ""; + } + try { + k = java.net.URLDecoder.decode(k, "UTF-8"); + v = java.net.URLDecoder.decode(v, "UTF-8"); + } catch (UnsupportedEncodingException ex) { + // won't happen + com.codename1.io.Log.e(ex); + } + out.put(k, v); + } + return out; + } + + public String getStackTrace(Thread parentThread, Throwable t) { + System.out.println("CN1SS:ERR:Invoking getStackTrace in AndroidImplementation"); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + PrintWriter w = new PrintWriter(new OutputStreamWriter(bos, StandardCharsets.UTF_8)); + t.printStackTrace(w); + w.close(); + System.out.println("CN1SS:ERR:AndroidImplementation getStackTrace completed"); + return new String(bos.toByteArray(), StandardCharsets.UTF_8); + } + + public static void initPushContent(String message, String image, String messageType, String category, Context context) { + com.codename1.push.PushContent.reset(); + + int iMessageType = 1; + try {iMessageType = Integer.parseInt(messageType);}catch(Throwable t){} + + String actionId = null; + String reply = null; + boolean cancel = true; + if (context instanceof Activity) { + Activity activity = (Activity)context; + Bundle extras = activity.getIntent().getExtras(); + if (extras != null) { + actionId = extras.getString("pushActionId"); + extras.remove("pushActionId"); + + if (actionId != null && RemoteInputWrapper.isSupported()) { + Bundle textExtras = RemoteInputWrapper.getResultsFromIntent(activity.getIntent()); + if (textExtras != null) { + CharSequence cs = textExtras.getCharSequence(actionId + "$Result"); + if (cs != null) { + reply = cs.toString(); + } + } + + + } + } + + } + if (cancel) { + PushNotificationService.cancelNotification(context); + } + com.codename1.push.PushContent.setType(iMessageType); + com.codename1.push.PushContent.setCategory(category); + if (actionId != null) { + com.codename1.push.PushContent.setActionId(actionId); + } + if (reply != null) { + com.codename1.push.PushContent.setTextResponse(reply); + } + switch (iMessageType) { + case 1: + case 5: + com.codename1.push.PushContent.setBody(message);break; + case 2: com.codename1.push.PushContent.setMetaData(message);break; + case 3: { + String[] parts = message.split(";"); + com.codename1.push.PushContent.setMetaData(parts[1]); + com.codename1.push.PushContent.setBody(parts[0]); + break; + } + case 4: { + String[] parts = message.split(";"); + com.codename1.push.PushContent.setTitle(parts[0]); + com.codename1.push.PushContent.setBody(parts[1]); + break; + } + case 101: { + com.codename1.push.PushContent.setBody(message.substring(message.indexOf(" ") + 1)); + com.codename1.push.PushContent.setType(1); + break; + } + case 102: { + String[] parts = message.split(";"); + com.codename1.push.PushContent.setTitle(parts[1]); + com.codename1.push.PushContent.setBody(parts[2]); + com.codename1.push.PushContent.setType(2); + break; + } + } + } + + // Name of file where we install the push notification categories as an XML file + // if the main class implements PushActiosProvider + private static String FILE_NAME_NOTIFICATION_CATEGORIES = "CN1$AndroidNotificationCategories"; + + + + /** + * Action categories are defined on the Main class by implementing the PushActionsProvider, however + * the main class may not be available to the push receiver, so we need to save these categories + * to the file system when the app is installed, then the push receiver can load these actions + * when it sends a push while the app isn't running. + * @param provider A reference to the App's main class + * @throws IOException + */ + public static void installNotificationActionCategories(PushActionsProvider provider) throws IOException { + // Assume that CN1 is running... this will run when the app starts + // up + Context context = getContext(); + boolean requiresUpdate = false; + + File categoriesFile = new File(activity.getFilesDir().getAbsolutePath() + "/" + FILE_NAME_NOTIFICATION_CATEGORIES); + if (!categoriesFile.exists()) { + requiresUpdate = true; + } + if (!requiresUpdate) { + try { + PackageInfo packageInfo = context.getPackageManager().getPackageInfo(context.getApplicationContext().getPackageName(), PackageManager.GET_PERMISSIONS); + if (packageInfo.lastUpdateTime > categoriesFile.lastModified()) { + requiresUpdate = true; + } + } catch (Exception ex) { + ex.printStackTrace(); + } + } + + if (!requiresUpdate) { + return; + } + + OutputStream os = getContext().openFileOutput(FILE_NAME_NOTIFICATION_CATEGORIES, 0); + PushActionCategory[] categories = provider.getPushActionCategories(); + javax.xml.parsers.DocumentBuilderFactory docFactory = javax.xml.parsers.DocumentBuilderFactory.newInstance(); + javax.xml.parsers.DocumentBuilder docBuilder; + try { + docBuilder = docFactory.newDocumentBuilder(); + } catch (ParserConfigurationException ex) { + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + throw new IOException("Faield to create document builder for creating notification categories XML document", ex); + } + + // root elements + org.w3c.dom.Document doc = docBuilder.newDocument(); + org.w3c.dom.Element root = (org.w3c.dom.Element)doc.createElement("categories"); + doc.appendChild(root); + for (PushActionCategory category : categories) { + org.w3c.dom.Element categoryEl = (org.w3c.dom.Element)doc.createElement("category"); + org.w3c.dom.Attr idAttr = doc.createAttribute("id"); + idAttr.setValue(category.getId()); + categoryEl.setAttributeNode(idAttr); + + for (PushAction action : category.getActions()) { + org.w3c.dom.Element actionEl = (org.w3c.dom.Element)doc.createElement("action"); + org.w3c.dom.Attr actionIdAttr = doc.createAttribute("id"); + actionIdAttr.setValue(action.getId()); + actionEl.setAttributeNode(actionIdAttr); + + + org.w3c.dom.Attr actionTitleAttr = doc.createAttribute("title"); + if (action.getTitle() != null) { + actionTitleAttr.setValue(action.getTitle()); + } else { + actionTitleAttr.setValue(action.getId()); + } + actionEl.setAttributeNode(actionTitleAttr); + + if (action.getIcon() != null) { + org.w3c.dom.Attr actionIconAttr = doc.createAttribute("icon"); + String iconVal = action.getIcon(); + try { + // We'll store the resource IDs for the icon + // rather than the icon name because that is what + // the push notifications require. + iconVal = ""+context.getResources().getIdentifier(iconVal, "drawable", context.getPackageName()); + actionIconAttr.setValue(iconVal); + actionEl.setAttributeNode(actionIconAttr); + } catch (Exception ex) { + ex.printStackTrace(); + + } + + } + + if (action.getTextInputPlaceholder() != null) { + org.w3c.dom.Attr textInputPlaceholderAttr = doc.createAttribute("textInputPlaceholder"); + textInputPlaceholderAttr.setValue(action.getTextInputPlaceholder()); + actionEl.setAttributeNode(textInputPlaceholderAttr); + } + if (action.getTextInputButtonText() != null) { + org.w3c.dom.Attr textInputButtonTextAttr = doc.createAttribute("textInputButtonText"); + textInputButtonTextAttr.setValue(action.getTextInputButtonText()); + actionEl.setAttributeNode(textInputButtonTextAttr); + } + categoryEl.appendChild(actionEl); + } + root.appendChild(categoryEl); + + } + try { + javax.xml.transform.TransformerFactory transformerFactory = javax.xml.transform.TransformerFactory.newInstance(); + javax.xml.transform.Transformer transformer = transformerFactory.newTransformer(); + javax.xml.transform.dom.DOMSource source = new javax.xml.transform.dom.DOMSource(doc); + javax.xml.transform.stream.StreamResult result = new javax.xml.transform.stream.StreamResult(os); + transformer.transform(source, result); + + } catch (Exception ex) { + throw new IOException("Failed to save notification categories as XML.", ex); + } + + } + + /** + * Retrieves the app's available push action categories from the XML file in which they + * should have been installed on the first load. + * @param context + * @return + * @throws IOException + */ + private static PushActionCategory[] getInstalledPushActionCategories(Context context) throws IOException { + // NOTE: This method may be called from the PushReceiver when the app isn't running so we can't access + // the main activity context, display properties, or any CN1 stuff. Just native android + + File categoriesFile = new File(context.getFilesDir().getAbsolutePath() + "/" + FILE_NAME_NOTIFICATION_CATEGORIES); + if (!categoriesFile.exists()) { + return new PushActionCategory[0]; + } + javax.xml.parsers.DocumentBuilderFactory docFactory = javax.xml.parsers.DocumentBuilderFactory.newInstance(); + javax.xml.parsers.DocumentBuilder docBuilder; + try { + docBuilder = docFactory.newDocumentBuilder(); + } catch (ParserConfigurationException ex) { + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + throw new IOException("Faield to create document builder for creating notification categories XML document", ex); + } + org.w3c.dom.Document doc; + try { + doc = docBuilder.parse(context.openFileInput(FILE_NAME_NOTIFICATION_CATEGORIES)); + } catch (SAXException ex) { + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + throw new IOException("Failed to parse instaled push action categories", ex); + } + org.w3c.dom.Element root = doc.getDocumentElement(); + java.util.List out = new ArrayList(); + org.w3c.dom.NodeList l = root.getElementsByTagName("category"); + int len = l.getLength(); + for (int i=0; i actions = new ArrayList(); + org.w3c.dom.NodeList al = el.getElementsByTagName("action"); + int alen = al.getLength(); + for (int j=0; j= 23) { + return PendingIntent.getActivity(ctx, value, intent, FLAG_IMMUTABLE); + } else { + return PendingIntent.getActivity(ctx, value, intent, PendingIntent.FLAG_CANCEL_CURRENT); + } + } + + public static PendingIntent createMutablePendingIntent(Context ctx, int value, Intent intent) { + if (android.os.Build.VERSION.SDK_INT >= 23) { + return PendingIntent.getActivity(ctx, value, intent, FLAG_MUTABLE); + } else { + return PendingIntent.getActivity(ctx, value, intent, PendingIntent.FLAG_CANCEL_CURRENT); + } + } + + public static PendingIntent getPendingIntent(Context ctx, int value, Intent intent) { + if (android.os.Build.VERSION.SDK_INT >= 23) { + return PendingIntent.getService(ctx, value, intent, FLAG_IMMUTABLE); + } else { + return PendingIntent.getService(ctx, value, intent, PendingIntent.FLAG_CANCEL_CURRENT); + } + } + + public static PendingIntent getBroadcastPendingIntent(Context ctx, int value, Intent intent) { + if (android.os.Build.VERSION.SDK_INT >= 23) { + // PendingIntent.FLAG_IMMUTABLE + return PendingIntent.getBroadcast(ctx, value, intent, 67108864); + } else { + return PendingIntent.getBroadcast(ctx, value, intent, PendingIntent.FLAG_CANCEL_CURRENT); + } + } + + /** + * Adds actions to a push notification. This is called by the Push broadcast receiver probably before + * Codename One is initialized + * @param provider Reference to the app's main class which implements PushActionsProvider + * @param categoryId The category ID of the push notification. + * @param builder The builder for the push notification. + * @param targetIntent The target intent... this should go to the app's main Activity. + * @param context The current context (inside the Broadcast receiver). + * @throws IOException + */ + public static void addActionsToNotification(PushActionsProvider provider, String categoryId, NotificationCompat.Builder builder, Intent targetIntent, Context context) throws IOException { + // NOTE: THis will likely run when the main activity isn't running so we won't have + // access to any display properties... just native Android APIs will be accessible. + + PushActionCategory category = null; + PushActionCategory[] categories; + if (provider != null) { + categories = provider.getPushActionCategories(); + } else { + categories = getInstalledPushActionCategories(context); + } + for (PushActionCategory candidateCategory : categories) { + if (categoryId.equals(candidateCategory.getId())) { + category = candidateCategory; + break; + } + } + if (category == null) { + return; + } + + int requestCode = 1; + for (PushAction action : category.getActions()) { + Intent newIntent = (Intent)targetIntent.clone(); + newIntent.putExtra("pushActionId", action.getId()); + PendingIntent contentIntent = createMutablePendingIntent(context, requestCode++, newIntent); + try { + int iconId; + try { + iconId = Integer.parseInt(action.getIcon()); + } catch (NumberFormatException ex) { + iconId = 0; + } + if (ActionWrapper.BuilderWrapper.isSupported()) { + // We need to take this abstracted "wrapper" approach because the Action.Builder class, and RemoteInput class + // aren't available until API 22. + // These classes use reflection to provide support for these classes safely. + ActionWrapper.BuilderWrapper actionBuilder = new ActionWrapper.BuilderWrapper(iconId, action.getTitle(), contentIntent); + if (action.getTextInputPlaceholder() != null && RemoteInputWrapper.isSupported()) { + RemoteInputWrapper.BuilderWrapper remoteInputBuilder = new RemoteInputWrapper.BuilderWrapper(action.getId()+"$Result"); + remoteInputBuilder.setLabel(action.getTextInputPlaceholder()); + + RemoteInputWrapper remoteInput = remoteInputBuilder.build(); + actionBuilder.addRemoteInput(remoteInput); + } + ActionWrapper actionWrapper = actionBuilder.build(); + new NotificationCompatWrapper.BuilderWrapper(builder).addAction(actionWrapper); + } else { + builder.addAction(iconId, action.getTitle(), contentIntent); + } + } catch (Exception ex) { + ex.printStackTrace(); + } + } + + } + + public static void firePendingPushes(final PushCallback c, final Context a) { + try { + if(c != null) { + InputStream i = a.openFileInput("CN1$AndroidPendingNotifications"); + if(i == null) { + return; + } + DataInputStream is = new DataInputStream(i); + int count = is.readByte(); + for(int iter = 0 ; iter < count ; iter++) { + boolean hasType = is.readBoolean(); + String actualType = null; + if(hasType) { + actualType = is.readUTF(); + } + final String t; + final String b; + final String category; + final String image; + if ("99".equals(actualType)) { + // This was a rich push + Map vals = splitQuery(is.readUTF()); + t = vals.get("type"); + b = vals.get("body"); + category = vals.get("category"); + image = vals.get("image"); + } else { + t = actualType; + b = is.readUTF(); + category = null; + image = null; + } + long s = is.readLong(); + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + Display.getInstance().setProperty("pendingPush", "true"); + Display.getInstance().setProperty("pushType", t); + initPushContent(b, image, t, category, a); + if(t != null && ("3".equals(t) || "6".equals(t))) { + String[] a = b.split(";"); + c.push(a[0]); + c.push(a[1]); + } else if (t != null && ("101".equals(t))) { + c.push(b.substring(b.indexOf(" ")+1)); + } else { + c.push(b); + } + Display.getInstance().setProperty("pendingPush", null); + } + }); + } + a.deleteFile("CN1$AndroidPendingNotifications"); + } + } catch(IOException err) { + } + } + + public static String[] getPendingPush(String type, Context a) { + InputStream i = null; + try { + i = a.openFileInput("CN1$AndroidPendingNotifications"); + if (i == null) { + return null; + } + DataInputStream is = new DataInputStream(i); + int count = is.readByte(); + Vector v = new Vector(); + for (int iter = 0; iter < count; iter++) { + boolean hasType = is.readBoolean(); + String actualType = null; + if (hasType) { + actualType = is.readUTF(); + } + + final String t; + final String b; + if ("99".equals(actualType)) { + // This was a rich push + Map vals = splitQuery(is.readUTF()); + t = vals.get("type"); + b = vals.get("body"); + //category = vals.get("category"); + //image = vals.get("image"); + } else { + t = actualType; + b = is.readUTF(); + //category = null; + //image = null; + } + long s = is.readLong(); + if(t != null && ("3".equals(t) || "6".equals(t))) { + String[] m = b.split(";"); + v.add(m[0]); + } else if(t != null && "4".equals(t)){ + String[] m = b.split(";"); + v.add(m[1]); + } else if(t != null && "2".equals(t)){ + continue; + }else if (t != null && "101".equals(t)) { + v.add(b.substring(b.indexOf(" ")+1)); + }else{ + v.add(b); + } + } + String [] retVal = new String[v.size()]; + for (int j = 0; j < retVal.length; j++) { + retVal[j] = (String)v.get(j); + } + return retVal; + + } catch (Exception ex) { + ex.printStackTrace(); + } finally { + try { + if(i != null){ + i.close(); + } + } catch (IOException ex) { + } + } + return null; + } + + private static AndroidImplementation instance; + private static final String INTENT_PROPERTY_PREFIX = "android.intent."; + private static final String INTENT_EXTRA_PROPERTY_PREFIX = "android.intent.extra."; + private static final Set intentPropertyKeys = new HashSet(); + private static final Object intentPropertyLock = new Object(); + private static Intent lastPublishedIntent; + + public static AndroidImplementation getInstance() { + return instance; + } + + public static void clearAppArg() { + if (instance != null) { + instance.setAppArg(null); + clearIntentProperties(); + } + } + + /// Delivers a link that arrived at an already-running activity, so the + /// router sees it on Android as it already does on iOS. + /// + /// The two ports were asymmetric here, and silently so. iOS routes every + /// deep link through `Display.setProperty("AppArg", url)`, which fires + /// [com.codename1.router.Navigation#dispatchExternalUrl]. Android's + /// `onNewIntent` only stored the intent, and [#getAppArg] then derived + /// the value lazily through the implementation's own setter -- so + /// `setProperty` never ran and the router never fired. Anything built on + /// `@Route` therefore worked on iOS and did nothing on Android, which + /// reads as a feature that "just doesn't convert" on the platform rather + /// than as a bug. + /// + /// Deliberately narrow. Only `ACTION_VIEW` with an http or https scheme + /// goes through here; `EXTRA_TEXT` shares, `content://` attachments and + /// `EXTRA_STREAM` payloads keep their existing lazy path. Dispatching for + /// every intent would double-fire against the `setAppArg` inside + /// [#getAppArg] and would change behaviour for every share-target + /// application in the field. + /// + /// #### Parameters + /// + /// - `intent`: the intent delivered to the running activity + static void dispatchNewIntentUrl(Intent intent) { + if (intent == null || instance == null || !Display.isInitialized()) { + return; + } + try { + if (!Intent.ACTION_VIEW.equals(intent.getAction())) { + return; + } + android.net.Uri data = intent.getData(); + if (data == null) { + return; + } + String scheme = data.getScheme(); + if (!"http".equals(scheme) && !"https".equals(scheme)) { + return; + } + // Cleared first so the value below is what getAppArg() reports, + // rather than whatever the previous intent left cached. + instance.setAppArg(null); + clearIntentProperties(); + // The intent is stored UNMODIFIED, and the url is marked as delivered by + // remembering the intent's identity instead of by erasing its data. + // + // Two earlier shapes were both wrong. Clearing the data on the intent + // passed in broke the ordinary way to extend onNewIntent() -- + // super.onNewIntent(intent) followed by the subclass reading + // intent.getData(), which had just been nulled underneath it. Storing a + // data-less COPY fixed that one and broke two more readers: the + // documented `android.intent.data` property is published from whatever + // the activity has stored, and native integrations read + // getActivity().getIntent().getData() after onNewIntent(). Both saw a + // warm deep link as no deep link at all while cold links still carried + // it -- an asymmetry an application has no way to work around. + // + // What actually has to be suppressed is narrower than the data: only + // getAppArg()'s rebuilding of the url from the stored intent, because + // CodenameOneActivity.onStop() clears the app arg and the next read + // after a resume would otherwise report the same deep link a second + // time and open one tapped invite twice. + getActivity().setIntent(intent); + markAppArgDelivered(intent); + // Published here rather than left to getAppArg(), since the properties + // for the previous intent were just cleared and the reader that used to + // repopulate them lazily is exactly the one now suppressed. + publishIntentProperties(getActivity(), intent); + Display.getInstance().setProperty("AppArg", data.toString()); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + /// Identity of the intent whose url [#dispatchNewIntentUrl] already delivered as + /// the app arg. Weak because it needs to outlive nothing: the activity holds the + /// intent, and once it stores a different one this reference is free to go. + private static java.lang.ref.WeakReference deliveredAppArgIntent; + + private static void markAppArgDelivered(Intent intent) { + synchronized (intentPropertyLock) { + deliveredAppArgIntent = new java.lang.ref.WeakReference(intent); + } + } + + private static boolean isAppArgDelivered(Intent intent) { + synchronized (intentPropertyLock) { + return deliveredAppArgIntent != null && deliveredAppArgIntent.get() == intent; + } + } + + private static void clearIntentProperties() { + synchronized (intentPropertyLock) { + if (Display.isInitialized()) { + for (String key : new ArrayList(intentPropertyKeys)) { + Display.getInstance().setProperty(key, null); + } + } + intentPropertyKeys.clear(); + lastPublishedIntent = null; + } + } + + private static void publishIntentProperties(Activity activity, Intent intent) { + if (intent == null) { + return; + } + + synchronized (intentPropertyLock) { + if (intent == lastPublishedIntent) { + return; + } + + Map nextProperties = new HashMap(); + nextProperties.put(INTENT_PROPERTY_PREFIX + "action", intent.getAction()); + nextProperties.put(INTENT_PROPERTY_PREFIX + "data", intent.getDataString()); + nextProperties.put(INTENT_PROPERTY_PREFIX + "type", intent.getType()); + + // Only getCallingPackage() is a verified caller identity. Referrer values are caller-controlled. + String callerPackage = activity.getCallingPackage(); + nextProperties.put(INTENT_PROPERTY_PREFIX + "caller", callerPackage); + nextProperties.put(INTENT_PROPERTY_PREFIX + "caller.verified", callerPackage != null ? "true" : "false"); + + Bundle extras = intent.getExtras(); + if (extras != null) { + for (String key : extras.keySet()) { + Object value = extras.get(key); + String propertyKey = key.startsWith(INTENT_EXTRA_PROPERTY_PREFIX) ? key : INTENT_EXTRA_PROPERTY_PREFIX + key; + nextProperties.put(propertyKey, value == null ? null : String.valueOf(value)); + } + } + + if (Display.isInitialized()) { + ArrayList keysToRemove = new ArrayList(); + for (String key : intentPropertyKeys) { + if (!nextProperties.containsKey(key)) { + keysToRemove.add(key); + } + } + for (String key : keysToRemove) { + Display.getInstance().setProperty(key, null); + intentPropertyKeys.remove(key); + } + for (Map.Entry entry : nextProperties.entrySet()) { + Display.getInstance().setProperty(entry.getKey(), entry.getValue()); + intentPropertyKeys.add(entry.getKey()); + } + } else { + intentPropertyKeys.clear(); + intentPropertyKeys.addAll(nextProperties.keySet()); + } + + lastPublishedIntent = intent; + } + } + + public static Context getContext() { + Context out = getActivity(); + if (out != null) { + return out; + } + return context; + } + + public void setContext(Context c) { + context = c; + } + + @Override + public void init(Object m) { + // NOTE: Do not explicitly set the PlayServices instance to anything other than + // an instance of the base PlayServices class. The Build Server will automatically + // swap this for the appropriate subclass depending on the playServicesVersion of + // the build. + PlayServices.setInstance(new PlayServices()); // <---- DO NOT CHANGE - Build server will replace with appropriate subclass instance + if (m instanceof CodenameOneActivity) { + setContext(null); + setActivity((CodenameOneActivity) m); + } else { + setActivity(null); + setContext((Context)m); + } + // The nearby bridge is cached for the life of the process while + // Android recreates the activity freely -- a configuration change, + // or "Don't keep activities". An association chooser opened by the + // old activity delivers its result to the NEW one, where the + // backend's result listener is not installed, so the association + // resource never settled and every later association answered BUSY. + // Told here because this is the one place that knows it changed. + if (nearbyBridge != null) { + nearbyBridge.onActivityChanged(); + } + + instance = this; + if(getActivity() != null && getActivity().hasUI()){ + if (!hasActionBar()) { + try { + getActivity().requestWindowFeature(Window.FEATURE_NO_TITLE); + } catch (Exception e) { + com.codename1.io.Log.p("requestWindowFeature FEATURE_NO_TITLE threw exception: " + e.toString()); + } + } else { + getActivity().invalidateOptionsMenu(); + try { + getActivity().requestWindowFeature(Window.FEATURE_ACTION_BAR); + getActivity().requestWindowFeature(Window.FEATURE_PROGRESS); + + if(android.os.Build.VERSION.SDK_INT >= 21){ + //WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS + getActivity().getWindow().addFlags(-2147483648); + } + } catch (Exception e) { + //Log.d("Codename One", "No idea why this throws a Runtime Error", e); + } + NotifyActionBar notify = new NotifyActionBar(getActivity(), false); + notify.run(); + } + + if(statusBarHidden) { + getActivity().getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN); + getActivity().getWindow().setStatusBarColor(android.graphics.Color.TRANSPARENT); + } + + if(Display.getInstance().getProperty("StatusbarHidden", "").equals("true")){ + getActivity().getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN); + } + + if(Display.getInstance().getProperty("KeepScreenOn", "").equals("true")){ + getActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + + if(Display.getInstance().getProperty("DisableScreenshots", "").equals("true")){ + getActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE); + } + + if (m instanceof CodenameOneActivity) { + ((CodenameOneActivity) m).setDefaultIntentResultListener(this); + ((CodenameOneActivity) m).setIntentResultListener(this); + } + + /** + * translate our default font height depending on the screen density. + * this is required for new high resolution devices. otherwise + * everything looks awfully small. + * + * we use our default font height value of 16 and go from there. i + * thought about using new Paint().getTextSize() for this value but if + * some new version of android suddenly returns values already tranlated + * to the screen then we might end up with too large fonts. the + * documentation is not very precise on that. + */ + final int defaultFontPixelHeight = 16; + this.defaultFontHeight = this.translatePixelForDPI(defaultFontPixelHeight); + + + this.defaultFont = (CodenameOneTextPaint) ((NativeFont) this.createFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_MEDIUM)).font; + Display.getInstance().setTransitionYield(-1); + + initSurface(); + /** + * devices are extremely sensitive so dragging should start a little + * later than suggested by default implementation. + */ + this.setDragStartPercentage(1); + VirtualKeyboardInterface vkb = new AndroidKeyboard(this); + Display.getInstance().registerVirtualKeyboard(vkb); + Display.getInstance().setDefaultVirtualKeyboard(vkb); + + InPlaceEditView.endEdit(); + + getActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN); + + if (nativePeers.size() > 0) { + for (int i = 0; i < nativePeers.size(); i++) { + ((AndroidImplementation.AndroidPeer) nativePeers.elementAt(i)).init(); + } + } + } else { + /** + * translate our default font height depending on the screen density. + * this is required for new high resolution devices. otherwise + * everything looks awfully small. + * + * we use our default font height value of 16 and go from there. i + * thought about using new Paint().getTextSize() for this value but if + * some new version of android suddenly returns values already tranlated + * to the screen then we might end up with too large fonts. the + * documentation is not very precise on that. + */ + final int defaultFontPixelHeight = 16; + this.defaultFontHeight = this.translatePixelForDPI(defaultFontPixelHeight); + + + this.defaultFont = (CodenameOneTextPaint) ((NativeFont) this.createFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_MEDIUM)).font; + } + HttpURLConnection.setFollowRedirects(false); + CookieHandler.setDefault(null); + VideoCaptureConstraints.init(new AndroidVideoCaptureConstraintsCompiler()); + } + + + + @Override + public boolean isInitialized(){ +// Removing the check for null view to prevent strange things from happening when +// calling from a Service context. +// if(getActivity() != null && myView == null){ +// //if the view is null deinitialize the Display +// if(super.isInitialized()){ +// syncDeinitialize(); +// } +// return false; +// } + return super.isInitialized(); + } + + /** + * Reinitializes CN1. + * @param i Context to initialize it with. + * + * @see #startContext(Context) + */ + private static void reinit(Object i) { + if (instance != null && ((i instanceof CodenameOneActivity) || instance.myView == null)) { + instance.init(i); + } + Display.init(i); + + // This is a hack to fix an issue that caused the screen to appear blank when + // the app is loaded from memory after being unloaded. + + // This issue only seems to occur when the Activity had been unloaded + // so to test this you'll need to check the "Don't keep activities" checkbox under/ + // Developer options. + // Developer options. + Display.getInstance().callSerially(new Runnable() { + public void run() { + Display.getInstance().invokeAndBlock(new Runnable(){ public void run(){ + Util.sleep(50); + }}); + if (!Display.isInitialized() || Display.getInstance().isMinimized()) { + return; + } + Form cur = Display.getInstance().getCurrent(); + if (cur != null) { + cur.forceRevalidate(); + } + } + + }); + } + + private static class InvalidateOptionsMenuImpl implements Runnable { + private Activity activity; + + public InvalidateOptionsMenuImpl(Activity activity) { + this.activity = activity; + } + + @Override + public void run() { + activity.invalidateOptionsMenu(); + } + } + + @Override + public Boolean isDarkMode() { + try { + int nightModeFlags = getActivity().getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; + switch (nightModeFlags) { + case Configuration.UI_MODE_NIGHT_YES: + return true; + case Configuration.UI_MODE_NIGHT_NO: + return false; + default: + return null; + } + } catch(Throwable t) { + return null; + } + } + + @Override + public boolean isLargerTextEnabled() { + return getLargerTextScale() > 1.0f; + } + + @Override + public float getLargerTextScale() { + try { + Configuration configuration; + if (getActivity() != null) { + configuration = getActivity().getResources().getConfiguration(); + } else { + configuration = getContext().getResources().getConfiguration(); + } + return configuration.fontScale; + } catch (Throwable t) { + return 1.0f; + } + } + + + private boolean hasActionBar() { + return android.os.Build.VERSION.SDK_INT >= 11; + } + + public int translatePixelForDPI(int pixel) { + return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, pixel, + getContext().getResources().getDisplayMetrics()); + } + + /** + * Returns the platform EDT thread priority + */ + public int getEDTThreadPriority(){ + return Thread.NORM_PRIORITY; + } + + /// Android reports this directly as DisplayMetrics.density, so there is no + /// need to make callers derive it from the density bucket -- the bucket is a + /// coarse DPI band and rounds to a different number than the scale the + /// platform itself lays out with. + /// + /// Read the same way getDeviceDensity does, preferring the activity's own + /// display, because a multi-display device can have a different scale per + /// display and the resources copy is the default one. + @Override + public float getDevicePixelRatio() { + DisplayMetrics metrics = new DisplayMetrics(); + if (getActivity() != null) { + getActivity().getWindowManager().getDefaultDisplay().getMetrics(metrics); + } else if (getContext() != null) { + metrics = getContext().getResources().getDisplayMetrics(); + } else { + return super.getDevicePixelRatio(); + } + // 0 means "not reported", which is what the portable contract expects. + return metrics.density > 0 ? metrics.density : super.getDevicePixelRatio(); + } + + @Override + public int getDeviceDensity() { + DisplayMetrics metrics = new DisplayMetrics(); + if (getActivity() != null) { + getActivity().getWindowManager().getDefaultDisplay().getMetrics(metrics); + } else { + metrics = getContext().getResources().getDisplayMetrics(); + } + + int dpi = metrics.densityDpi; + if (dpi < DisplayMetrics.DENSITY_MEDIUM) { + return Display.DENSITY_LOW; + } + if (dpi < 213) { + return Display.DENSITY_MEDIUM; + } + // 213 == TV + if (dpi <= DisplayMetrics.DENSITY_HIGH) { + return Display.DENSITY_HIGH; + } + if (dpi < 400) { + return Display.DENSITY_VERY_HIGH; + } + if (dpi < 560) { + return Display.DENSITY_HD; + } + if (dpi <= 640) { + return Display.DENSITY_2HD; + } + return Display.DENSITY_4K; + } + + public static boolean isImmersive() { + if (getActivity() == null) { + return false; + } + return isImmersive(getActivity().getWindow()); + } + public static boolean isImmersive(Window window) { + if (Build.VERSION.SDK_INT >= 35) { + // Android 15+ is always immersive (overlay mode by default) + return true; + } + // On Android 34 and below, we can't detect decorFitsSystemWindows + // reliably at runtime. So the app must make the decision explicitly. + return false; + } + public static Rect getSystemBarInsets(final View rootView) { + final Rect result = new Rect(0, 0, 0, 0); + try { + Object insets = View.class + .getMethod("getRootWindowInsets") + .invoke(rootView); + if (insets == null) return result; + // Get android.view.WindowInsets$Type.systemBars() + Class typeClass = Class.forName("android.view.WindowInsets$Type"); + int systemBarsMask = ((Integer) typeClass + .getMethod("systemBars") + .invoke(null)).intValue(); + // Call insets.getInsets(int) + Object insetsObject = insets.getClass() + .getMethod("getInsets", new Class[]{int.class}) + .invoke(insets, new Object[]{systemBarsMask}); + if (insetsObject == null) return result; + Class insetsClass = insetsObject.getClass(); + int left = ((Integer) insetsClass.getField("left").get(insetsObject)).intValue(); + int top = ((Integer) insetsClass.getField("top").get(insetsObject)).intValue(); + int right = ((Integer) insetsClass.getField("right").get(insetsObject)).intValue(); + int bottom = ((Integer) insetsClass.getField("bottom").get(insetsObject)).intValue(); + // Include mandatory gesture insets (e.g. gesture navigation handle area). + // Some devices expose a larger interaction-protected bottom region here + // than in plain system bar insets. + try { + int mandatoryGesturesMask = ((Integer) typeClass + .getMethod("mandatorySystemGestures") + .invoke(null)).intValue(); + Object mandatoryInsetsObject = insets.getClass() + .getMethod("getInsets", new Class[]{int.class}) + .invoke(insets, new Object[]{mandatoryGesturesMask}); + if (mandatoryInsetsObject != null) { + Class mandatoryInsetsClass = mandatoryInsetsObject.getClass(); + left = Math.max(left, ((Integer) mandatoryInsetsClass.getField("left").get(mandatoryInsetsObject)).intValue()); + top = Math.max(top, ((Integer) mandatoryInsetsClass.getField("top").get(mandatoryInsetsObject)).intValue()); + right = Math.max(right, ((Integer) mandatoryInsetsClass.getField("right").get(mandatoryInsetsObject)).intValue()); + bottom = Math.max(bottom, ((Integer) mandatoryInsetsClass.getField("bottom").get(mandatoryInsetsObject)).intValue()); + } + } catch (Throwable t) { + // Ignore if mandatory gesture insets are unavailable. + } + result.set(left, top, right, bottom); + } catch (Throwable t) { + t.printStackTrace(); // Optional: log this or suppress if expected + } + return result; + } + + + public Rectangle getDisplaySafeArea(Rectangle rect) { + if (rect == null) { + rect = new Rectangle(); + } + if (getProperty("android.useSafeAreaInsets", "true").equals("false")) { + return super.getDisplaySafeArea(rect); + } + if (this.myView != null) { + rect.setBounds( + this.myView.getSafeAreaInsets().left, + this.myView.getSafeAreaInsets().top, + getDisplayWidth() - this.myView.getSafeAreaInsets().right - this.myView.getSafeAreaInsets().left, + getDisplayHeight() - this.myView.getSafeAreaInsets().top - this.myView.getSafeAreaInsets().bottom + ); + return rect; + } + + return super.getDisplaySafeArea(rect); + } + + /** + * A status flag to indicate that CN1 is in the process of deinitializing. + */ + private static boolean deinitializing; + private static boolean deinitializingEdt; + + public static void syncDeinitialize() { + if (deinitializingEdt){ + return; + } + deinitializingEdt = true; // This will get unset in {@link #deinitialize()} + deinitializing = true; + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + Display.deinitialize(); + deinitializingEdt = false; + } + }); + } + + public void deinitialize() { + //activity.getWindowManager().removeView(relativeLayout); + super.deinitialize(); + if (getActivity() != null) { + + Runnable r = new Runnable() { + public void run() { + synchronized (AndroidImplementation.this) { + if (!deinitializing) { + return; + } + deinitializing = false; + } + if (nativePeers.size() > 0) { + for (int i = 0; i < nativePeers.size(); i++) { + ((AndroidImplementation.AndroidPeer) nativePeers.elementAt(i)).deinit(); + } + } + if (accessibilityProvider != null) { + accessibilityProvider.dispose(); + accessibilityProvider = null; + } + if (relativeLayout != null) { + relativeLayout.removeAllViews(); + } + relativeLayout = null; + myView = null; + } + }; + + if (Looper.getMainLooper().getThread() == Thread.currentThread()) { + deinitializing = true; + r.run(); + } else { + deinitializing = true; + getActivity().runOnUiThread(r); + } + } else { + deinitializing = false; + } + } + + /** + * init view. a lot of back and forth between this thread and the UI thread. + */ + private void initSurface() { + if (getActivity() != null && myView == null) { + relativeLayout= new RelativeLayout(getActivity()); + relativeLayout.setLayoutParams(new RelativeLayout.LayoutParams( + RelativeLayout.LayoutParams.FILL_PARENT, + RelativeLayout.LayoutParams.FILL_PARENT)); + relativeLayout.setFocusable(false); + + getActivity().getWindow().setBackgroundDrawable(null); + if(asyncView) { + if(android.os.Build.VERSION.SDK_INT < 14){ + myView = new AndroidSurfaceView(getActivity(), AndroidImplementation.this); + } else { + int hardwareAcceleration = 16777216; + getActivity().getWindow().setFlags(hardwareAcceleration, hardwareAcceleration); + myView = new AndroidAsyncView(getActivity(), AndroidImplementation.this); + } + } else { + int hardwareAcceleration = 16777216; + getActivity().getWindow().setFlags(hardwareAcceleration, hardwareAcceleration); + superPeerMode = true; + myView = new AndroidAsyncView(getActivity(), AndroidImplementation.this); + } + myView.getAndroidView().setVisibility(View.VISIBLE); + // Makes the surface an Android drop target, so a drag from another application -- + // or from elsewhere in this one -- reaches the components that asked for it. + AndroidNativeDragAndDrop.install(this, myView.getAndroidView()); + + if (hideOverlayWindowsRequested) { + setHideOverlayWindows(true); + } + + if (Build.VERSION.SDK_INT >= 16) { + final View semanticHost = myView.getAndroidView(); + accessibilityProvider = new AndroidAccessibilityProvider(semanticHost, this); + semanticHost.setAccessibilityDelegate(new View.AccessibilityDelegate() { + @Override + public android.view.accessibility.AccessibilityNodeProvider getAccessibilityNodeProvider(View host) { + return accessibilityProvider; + } + }); + } + + relativeLayout.addView(myView.getAndroidView()); + myView.getAndroidView().setVisibility(View.VISIBLE); + + int id = getActivity().getResources().getIdentifier("main", "layout", getActivity().getApplicationInfo().packageName); + RelativeLayout root = (RelativeLayout) LayoutInflater.from(getActivity()).inflate(id, null); + if(viewAbove != null) { + RelativeLayout.LayoutParams lp = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT); + lp.addRule(RelativeLayout.ALIGN_PARENT_TOP); + lp.addRule(RelativeLayout.CENTER_HORIZONTAL); + + RelativeLayout.LayoutParams lp2 = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT); + lp2.setMargins(0, 0, aboveSpacing, 0); + relativeLayout.setLayoutParams(lp2); + root.addView(viewAbove, lp); + } + root.addView(relativeLayout); + if(viewBelow != null) { + RelativeLayout.LayoutParams lp = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT); + lp.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM); + lp.addRule(RelativeLayout.CENTER_HORIZONTAL); + + RelativeLayout.LayoutParams lp2 = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT); + lp2.setMargins(0, 0, 0, belowSpacing); + relativeLayout.setLayoutParams(lp2); + root.addView(viewBelow, lp); + } + getActivity().setContentView(root); + if (!myView.getAndroidView().hasFocus()) { + myView.getAndroidView().requestFocus(); + } + } + } + + @Override + public void confirmControlView() { + if(myView == null){ + return; + } + myView.getAndroidView().setVisibility(View.VISIBLE); + //ugly workaround for a bug where on some android versions the async view + //came back black from the background. + if(myView instanceof AndroidAsyncView){ + final AndroidAsyncView finalView = (AndroidAsyncView)myView; + new Thread(new Runnable() { + @Override + public void run() { + Util.sleep(1000); + finalView.setPaintViewOnBuffer(false); + } + }).start(); + } + } + + public void hideNotifyPublic() { + super.hideNotify(); + saveTextEditingState(); + } + + public void showNotifyPublic() { + super.showNotify(); + } + + @Override + public boolean isMinimized() { + return getActivity() == null || ((CodenameOneActivity)getActivity()).isBackground(); + } + + @Override + public boolean minimizeApplication() { + Activity activity = getActivity(); + if (activity != null) { + // Move the app task to background instead of explicitly launching HOME. + // Some OEM launchers are no longer exported and can throw SecurityException + // when invoked via an ACTION_MAIN/CATEGORY_HOME intent. + if (activity.moveTaskToBack(true)) { + return true; + } + } + + // Fallback for edge-cases where there is no active activity/task. + Intent startMain = new Intent(Intent.ACTION_MAIN); + startMain.addCategory(Intent.CATEGORY_HOME); + startMain.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startMain.putExtra("WaitForResult", Boolean.FALSE); + try { + getContext().startActivity(startMain); + return true; + } catch (SecurityException ex) { + Log.e("Codename One", "Unable to minimize application", ex); + return false; + } + } + + @Override + public void restoreMinimizedApplication() { + if (getActivity() != null) { + Intent i = new Intent(getActivity(), getActivity().getClass()); + i.setAction(Intent.ACTION_MAIN); + i.addCategory(Intent.CATEGORY_LAUNCHER); + getContext().startActivity(i); + } + } + + @Override + public boolean isNativeInputImmediate() { + return true; + } + + public void editString(final Component cmp, int maxSize, final int constraint, String text, int keyCode) { + InPlaceEditView.edit(this, cmp, constraint); + } + + protected boolean editInProgress() { + return InPlaceEditView.isEditing(); + } + + @Override + public boolean isAsyncEditMode() { + return asyncEditMode; + } + + void setAsyncEditMode(boolean async) { + asyncEditMode = async; + } + + void callHideTextEditor() { + super.hideTextEditor(); + } + + @Override + public void hideTextEditor() { + InPlaceEditView.hideActiveTextEditor(); + } + + @Override + public boolean isNativeEditorVisible(Component c) { + return super.isNativeEditorVisible(c) && !InPlaceEditView.isActiveTextEditorHidden(); + } + + public static void stopEditing() { + stopEditing(false); + } + + public static void stopEditing(final boolean forceVKBClose){ + if (getActivity() == null) { + return; + } + final boolean[] flag = new boolean[]{false}; + + // InPlaceEditView.endEdit must be called from the UI thread. + // We must wait for this call to be over, otherwise Codename One's painting + // of the next form will be garbled. + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + // Must be called from the UI thread + InPlaceEditView.stopEdit(forceVKBClose); + + synchronized (flag) { + flag[0] = true; + flag.notify(); + } + } + }); + + if (!flag[0]) { + // Wait (if necessary) for the asynchronous runOnUiThread to do its work + synchronized (flag) { + + try { + flag.wait(); + } catch (InterruptedException e) { + } + } + } + } + + @Override + public void saveTextEditingState() { + stopEditing(true); + } + + @Override + public void stopTextEditing() { + saveTextEditingState(); + } + + @Override + public void stopTextEditing(final Runnable onFinish) { + final Form f = Display.getInstance().getCurrent(); + f.addSizeChangedListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + f.removeSizeChangedListener(this); + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + onFinish.run(); + } + }); + } + }); + stopEditing(true); + } + + + protected void setLastSizeChangedWH(int w, int h) { + // not used? + //this.lastSizeChangeW = w; + //this.lastSizeChangeH = h; + } + + /*@Override + public boolean handleEDTException(final Throwable err) { + + final boolean[] messageComplete = new boolean[]{false}; + + Log.e("Codename One", "Err on EDT", err); + + activity.runOnUiThread(new Runnable() { + @Override + public void run() { + UIManager m = UIManager.getInstance(); + final FrameLayout frameLayout = new FrameLayout( + activity); + final TextView textView = new TextView( + activity); + textView.setGravity(Gravity.CENTER); + frameLayout.addView(textView, new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.FILL_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT)); + textView.setText("An internal application error occurred: " + err.toString()); + AlertDialog.Builder bob = new AlertDialog.Builder( + activity); + bob.setView(frameLayout); + bob.setTitle(""); + bob.setPositiveButton(m.localize("ok", "OK"), + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface d, int which) { + d.dismiss(); + synchronized (messageComplete) { + messageComplete[0] = true; + messageComplete.notify(); + } + } + }); + AlertDialog editDialog = bob.create(); + editDialog.show(); + } + }); + + synchronized (messageComplete) { + if (messageComplete[0]) { + return true; + } + try { + messageComplete.wait(); + } catch (Exception ignored) { + ; + } + } + return true; + }*/ + + @Override + public InputStream getResourceAsStream(Class cls, String resource) { + try { + if (resource.startsWith("/")) { + resource = resource.substring(1); + } + return getContext().getAssets().open(resource); + } catch (IOException ex) { + Log.i("Codename One", "Resource not found: " + resource); + return null; + } + } + + @Override + protected void pointerPressed(final int x, final int y) { + super.pointerPressed(x, y); + } + + @Override + protected void pointerPressed(final int[] x, final int[] y) { + super.pointerPressed(x, y); + } + + @Override + protected void pointerReleased(final int x, final int y) { + super.pointerReleased(x, y); + } + + @Override + protected void pointerReleased(final int[] x, final int[] y) { + super.pointerReleased(x, y); + } + + @Override + protected void pointerDragged(int x, int y) { + super.pointerDragged(x, y); + } + + @Override + protected void pointerDragged(int[] x, int[] y) { + super.pointerDragged(x, y); + } + + @Override + protected void pointerHover(int x, int y) { + super.pointerHover(x, y); + } + + @Override + protected void pointerHover(int[] x, int[] y) { + super.pointerHover(x, y); + } + + @Override + protected void pointerHoverPressed(int x, int y) { + super.pointerHoverPressed(x, y); + } + + @Override + protected void pointerHoverPressed(int[] x, int[] y) { + super.pointerHoverPressed(x, y); + } + + @Override + protected void pointerHoverReleased(int x, int y) { + super.pointerHoverReleased(x, y); + } + + @Override + protected void pointerHoverReleased(int[] x, int[] y) { + super.pointerHoverReleased(x, y); + } + + @Override + protected int getDragAutoActivationThreshold() { + return 1000000; + } + + @Override + public void flushGraphics() { + if (myView != null) { + myView.flushGraphics(); + } + + } + + @Override + public void flushGraphics(int x, int y, int width, int height) { + this.tmprect.set(x, y, x + width, y + height); + if (myView != null) { + myView.flushGraphics(this.tmprect); + } + } + + @Override + public int charWidth(Object nativeFont, char ch) { + this.tmpchar[0] = ch; + float w = (nativeFont == null ? this.defaultFont + : (Paint) ((NativeFont) nativeFont).font).measureText(this.tmpchar, 0, 1); + if (w - (int) w > 0) { + return (int) (w + 1); + } + return (int) w; + } + + @Override + public int charsWidth(Object nativeFont, char[] ch, int offset, int length) { + float w = (nativeFont == null ? this.defaultFont + : (Paint) ((NativeFont) nativeFont).font).measureText(ch, offset, length); + if (w - (int) w > 0) { + return (int) (w + 1); + } + return (int) w; + } + + @Override + public int stringWidth(Object nativeFont, String str) { + float w = (nativeFont == null ? this.defaultFont + : (Paint) ((NativeFont) nativeFont).font).measureText(str); + if (w - (int) w > 0) { + return (int) (w + 1); + } + return (int) w; + } + + @Override + public void setNativeFont(Object graphics, Object font) { + if (font == null) { + font = this.defaultFont; + } + if (font instanceof NativeFont) { + ((AndroidGraphics) graphics).setFont((CodenameOneTextPaint) ((NativeFont) font).font); + } else { + ((AndroidGraphics) graphics).setFont((CodenameOneTextPaint) font); + } + } + + @Override + public int getHeight(Object nativeFont) { + CodenameOneTextPaint font = (nativeFont == null ? this.defaultFont + : (CodenameOneTextPaint) ((NativeFont) nativeFont).font); + if(font.fontHeight < 0) { + Paint.FontMetrics fm = font.getFontMetrics(); + font.fontHeight = (int)Math.ceil(fm.bottom - fm.top); + } + return font.fontHeight; + } + + @Override + public int getFontAscent(Object nativeFont) { + Paint font = (nativeFont == null ? this.defaultFont + : (Paint) ((NativeFont) nativeFont).font); + return -Math.round(font.getFontMetrics().ascent); + } + + @Override + public int getFontDescent(Object nativeFont) { + Paint font = (nativeFont == null ? this.defaultFont + : (Paint) ((NativeFont) nativeFont).font); + return Math.abs(Math.round(font.getFontMetrics().descent)); + } + + @Override + public boolean isBaselineTextSupported() { + return true; + } + + + + + + + public int getFace(Object nativeFont) { + if (nativeFont == null) { + return Font.FACE_SYSTEM; + } + return ((NativeFont) nativeFont).face; + } + + public int getStyle(Object nativeFont) { + if (nativeFont == null) { + return Font.STYLE_PLAIN; + } + return ((NativeFont) nativeFont).style; + } + + @Override + public int getSize(Object nativeFont) { + if (nativeFont == null) { + return Font.SIZE_MEDIUM; + } + return ((NativeFont) nativeFont).size; + } + + @Override + public boolean isTrueTypeSupported() { + return true; + } + + @Override + public boolean isNativeFontSchemeSupported() { + return true; + } + + private Typeface fontToRoboto(String fontName) { + if("native:MainThin".equals(fontName)) { + return Typeface.create("sans-serif-thin", Typeface.NORMAL); + } + if("native:MainLight".equals(fontName)) { + return Typeface.create("sans-serif-light", Typeface.NORMAL); + } + if("native:MainRegular".equals(fontName)) { + return Typeface.create("sans-serif", Typeface.NORMAL); + } + + if("native:MainBold".equals(fontName)) { + return Typeface.create("sans-serif-condensed", Typeface.BOLD); + } + + if("native:MainBlack".equals(fontName)) { + return Typeface.create("sans-serif-black", Typeface.BOLD); + } + + if("native:ItalicThin".equals(fontName)) { + return Typeface.create("sans-serif-thin", Typeface.ITALIC); + } + + if("native:ItalicLight".equals(fontName)) { + return Typeface.create("sans-serif-thin", Typeface.ITALIC); + } + + if("native:ItalicRegular".equals(fontName)) { + return Typeface.create("sans-serif", Typeface.ITALIC); + } + + if("native:ItalicBold".equals(fontName)) { + return Typeface.create("sans-serif-condensed", Typeface.BOLD_ITALIC); + } + + if("native:ItalicBlack".equals(fontName)) { + return Typeface.create("sans-serif-black", Typeface.BOLD_ITALIC); + } + + throw new IllegalArgumentException("Unsupported native font type: " + fontName); + } + + @Override + public Object loadTrueTypeFont(String fontName, String fileName) { + if(fontName.startsWith("native:")) { + Typeface t = fontToRoboto(fontName); + int fontStyle = com.codename1.ui.Font.STYLE_PLAIN; + if(t.isBold()) { + fontStyle |= com.codename1.ui.Font.STYLE_BOLD; + } + if(t.isItalic()) { + fontStyle |= com.codename1.ui.Font.STYLE_ITALIC; + } + CodenameOneTextPaint newPaint = new CodenameOneTextPaint(t); + newPaint.setAntiAlias(true); + newPaint.setSubpixelText(true); + return new NativeFont(com.codename1.ui.Font.FACE_SYSTEM, fontStyle, + com.codename1.ui.Font.SIZE_MEDIUM, newPaint, fileName, 0, 0); + } + Typeface t = Typeface.createFromAsset(getContext().getAssets(), fileName); + if(t == null) { + throw new RuntimeException("Font not found: " + fileName); + } + CodenameOneTextPaint newPaint = new CodenameOneTextPaint(t); + newPaint.setAntiAlias(true); + newPaint.setSubpixelText(true); + return new NativeFont(com.codename1.ui.Font.FACE_SYSTEM, + com.codename1.ui.Font.STYLE_PLAIN, com.codename1.ui.Font.SIZE_MEDIUM, newPaint, fileName, 0, 0); + } + + public static class NativeFont { + int face; + int style; + int size; + public Object font; + String fileName; + float height; + int weight; + + public NativeFont(int face, int style, int size, Object font, String fileName, float height, int weight) { + this(face, style, size, font); + this.fileName = fileName; + this.height = height; + this.weight = weight; + } + + public NativeFont(int face, int style, int size, Object font) { + this.face = face; + this.style = style; + this.size = size; + this.font = font; + } + + public boolean equals(Object o) { + if(o == null) { + return false; + } + NativeFont n = ((NativeFont)o); + if(fileName != null) { + return n.fileName != null && fileName.equals(n.fileName) && n.height == height && n.weight == weight; + } + return n.face == face && n.style == style && n.size == size && font.equals(n.font); + } + + public int hashCode() { + return face | style | size; + } + } + + /// Returns a copy of the given native font with its paint's letter spacing set + /// to the supplied value (Android letter spacing is in EM units, independent of + /// font size). Used by Style.letterSpacing so a per-UIID spacing -- matching the + /// Material text-appearance for each component -- is baked into the SAME paint + /// that does both measureText (layout) and drawText (render), keeping advances + /// consistent. Other ports get the default no-op. + @Override + public Object deriveTrueTypeFontWithLetterSpacing(Object font, float letterSpacing) { + NativeFont fnt = (NativeFont) font; + CodenameOneTextPaint copy = new CodenameOneTextPaint((CodenameOneTextPaint) fnt.font); + copy.setLetterSpacing(letterSpacing); + return new NativeFont(fnt.face, fnt.style, fnt.size, copy, fnt.fileName, fnt.height, fnt.weight); + } + + @Override + public Object deriveTrueTypeFont(Object font, float size, int weight) { + NativeFont fnt = (NativeFont)font; + CodenameOneTextPaint paint = (CodenameOneTextPaint)fnt.font; + paint.setAntiAlias(true); + Typeface type = paint.getTypeface(); + int fontstyle = Typeface.NORMAL; + if ((weight & Font.STYLE_BOLD) != 0 || type.isBold()) { + fontstyle |= Typeface.BOLD; + } + if ((weight & Font.STYLE_ITALIC) != 0 || type.isItalic()) { + fontstyle |= Typeface.ITALIC; + } + type = Typeface.create(type, fontstyle); + CodenameOneTextPaint newPaint = new CodenameOneTextPaint(type); + newPaint.setTextSize(size); + newPaint.setAntiAlias(true); + // preserve any letter spacing already configured on the source paint + newPaint.setLetterSpacing(paint.getLetterSpacing()); + NativeFont n = new NativeFont(com.codename1.ui.Font.FACE_SYSTEM, weight, com.codename1.ui.Font.SIZE_MEDIUM, newPaint, fnt.fileName, size, weight); + return n; + } + + @Override + public Object createFont(int face, int style, int size) { + Typeface typeface = null; + switch (face) { + case Font.FACE_MONOSPACE: + typeface = Typeface.MONOSPACE; + break; + default: + typeface = Typeface.DEFAULT; + break; + } + + int fontstyle = Typeface.NORMAL; + if ((style & Font.STYLE_BOLD) != 0) { + fontstyle |= Typeface.BOLD; + } + if ((style & Font.STYLE_ITALIC) != 0) { + fontstyle |= Typeface.ITALIC; + } + + + int height = this.defaultFontHeight; + int diff = height / 3; + + switch (size) { + case Font.SIZE_SMALL: + height -= diff; + break; + case Font.SIZE_LARGE: + height += diff; + break; + } + + Paint font = new CodenameOneTextPaint(Typeface.create(typeface, fontstyle)); + font.setAntiAlias(true); + font.setUnderlineText((style & Font.STYLE_UNDERLINED) != 0); + font.setTextSize(height); + return new NativeFont(face, style, size, font); + + } + + /** + * Loads a native font based on a lookup for a font name and attributes. + * Font lookup values can be separated by commas and thus allow fallback if + * the primary font isn't supported by the platform. + * + * @param lookup string describing the font + * @return the native font object + */ + public Object loadNativeFont(String lookup) { + try { + lookup = lookup.split(";")[0]; + int typeface = Typeface.NORMAL; + String familyName = lookup.substring(0, lookup.indexOf("-")); + String style = lookup.substring(lookup.indexOf("-") + 1, lookup.lastIndexOf("-")); + String size = lookup.substring(lookup.lastIndexOf("-") + 1, lookup.length()); + + if (style.equals("bolditalic")) { + typeface = Typeface.BOLD_ITALIC; + } else if (style.equals("italic")) { + typeface = Typeface.ITALIC; + } else if (style.equals("bold")) { + typeface = Typeface.BOLD; + } + Paint font = new CodenameOneTextPaint(Typeface.create(familyName, typeface)); + font.setAntiAlias(true); + font.setTextSize(Integer.parseInt(size)); + return new NativeFont(0, 0, 0, font); + } catch (Exception err) { + return null; + } + } + + /** + * Indicates whether loading a font by a string is supported by the platform + * + * @return true if the platform supports font lookup + */ + @Override + public boolean isLookupFontSupported() { + return true; + } + + @Override + public boolean isAntiAliasedTextSupported() { + return true; + } + + @Override + public void setAntiAliasedText(Object graphics, boolean a) { + android.graphics.Paint p = ((AndroidGraphics) graphics).getFont(); + if(p != null) { + p.setAntiAlias(a); + } + } + + @Override + public Object getDefaultFont() { + CodenameOneTextPaint paint = new CodenameOneTextPaint(this.defaultFont); + return new NativeFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_MEDIUM, paint); + } + + + private AndroidGraphics nullGraphics; + + private AndroidGraphics getNullGraphics() { + if (nullGraphics == null) { + Bitmap bitmap = Bitmap.createBitmap(getDisplayWidth()==0?100:getDisplayWidth(), getDisplayHeight()==0?100:getDisplayHeight(), + Bitmap.Config.ARGB_8888); + nullGraphics = (AndroidGraphics) this.getNativeGraphics(bitmap); + } + return nullGraphics; + } + + + @Override + public Object getNativeGraphics() { + if(myView != null){ + nullGraphics = null; + return myView.getGraphics(); + }else{ + return getNullGraphics(); + } + } + + @Override + public Object getNativeGraphics(Object image) { + AndroidGraphics g = new AndroidGraphics(this, new Canvas((Bitmap) image), true); + g.underlyingBitmap = (Bitmap) image; + g.setClip(0, 0, ((Bitmap)image).getWidth(), ((Bitmap)image).getHeight()); + return g; + } + + @Override + public void getRGB(Object nativeImage, int[] arr, int offset, int x, int y, + int width, int height) { + ((Bitmap) nativeImage).getPixels(arr, offset, width, x, y, width, + height); + } + + private int sampleSizeOverride = -1; + + @Override + public Object createImage(String path) throws IOException { + int IMAGE_MAX_SIZE = getDisplayHeight(); + if (exists(path)) { + Bitmap b = null; + try { + //Decode image size + BitmapFactory.Options o = new BitmapFactory.Options(); + o.inJustDecodeBounds = true; + o.inPreferredConfig = Bitmap.Config.ARGB_8888; + + InputStream fis = createFileInputStream(path); + BitmapFactory.decodeStream(fis, null, o); + fis.close(); + + int scale = 1; + if (o.outHeight > IMAGE_MAX_SIZE || o.outWidth > IMAGE_MAX_SIZE) { + scale = (int) Math.pow(2, (int) Math.round(Math.log(IMAGE_MAX_SIZE / (double) Math.max(o.outHeight, o.outWidth)) / Math.log(0.5))); + } + + //Decode with inSampleSize + BitmapFactory.Options o2 = new BitmapFactory.Options(); + o2.inPreferredConfig = Bitmap.Config.ARGB_8888; + + if(sampleSizeOverride != -1) { + o2.inSampleSize = sampleSizeOverride; + } else { + String sampleSize = Display.getInstance().getProperty("android.sampleSize", null); + if(sampleSize != null) { + o2.inSampleSize = Integer.parseInt(sampleSize); + } else { + o2.inSampleSize = scale; + } + } + o2.inPurgeable = true; + o2.inInputShareable = true; + fis = createFileInputStream(path); + b = BitmapFactory.decodeStream(fis, null, o2); + fis.close(); + + //fix rotation + ExifInterface exif = new ExifInterface(removeFilePrefix(path)); + int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); + + int angle = 0; + switch (orientation) { + case ExifInterface.ORIENTATION_ROTATE_90: + angle = 90; + break; + case ExifInterface.ORIENTATION_ROTATE_180: + angle = 180; + break; + case ExifInterface.ORIENTATION_ROTATE_270: + angle = 270; + break; + } + + if (sampleSizeOverride < 0 && angle != 0) { + Matrix mat = new Matrix(); + mat.postRotate(angle); + Bitmap correctBmp = Bitmap.createBitmap(b, 0, 0, b.getWidth(), b.getHeight(), mat, true); + b.recycle(); + b = correctBmp; + } + } catch (IOException e) { + } + return b; + } else { + InputStream in = this.getResourceAsStream(getClass(), path); + if (in == null) { + throw new IOException("Resource not found. " + path); + } + try { + return this.createImage(in); + } finally { + if (in != null) { + try { + in.close(); + } catch (Exception ignored) { + ; + } + } + } + } + } + + @Override + public boolean areMutableImagesFast() { + if (myView == null) return false; + return !myView.alwaysRepaintAll(); + } + + @Override + public void repaint(Animation cmp) { + if(myView != null && myView.alwaysRepaintAll()) { + if(cmp instanceof Component) { + Component c = (Component)cmp; + c.setDirtyRegion(null); + if(c.getParent() != null) { + cmp = c.getComponentForm(); + } else { + Form f = getCurrentForm(); + if(f != null) { + cmp = f; + } + } + } else { + // make sure the form is repainted for standalone anims e.g. in the case + // of replace animation + Form f = getCurrentForm(); + if(f != null) { + super.repaint(f); + } + } + } + super.repaint(cmp); + } + + @Override + public Object createImage(InputStream i) throws IOException { + BitmapFactory.Options opts = new BitmapFactory.Options(); + opts.inPreferredConfig = Bitmap.Config.ARGB_8888; + return BitmapFactory.decodeStream(i, null, opts); + } + + @Override + public void releaseImage(Object image) { + Bitmap i = (Bitmap) image; + i.recycle(); + } + + @Override + public Object createImage(byte[] bytes, int offset, int len) { + BitmapFactory.Options opts = new BitmapFactory.Options(); + opts.inPreferredConfig = Bitmap.Config.ARGB_8888; + return BitmapFactory.decodeByteArray(bytes, offset, len, opts); + } + + @Override + public Object createImage(int[] rgb, int width, int height) { + return Bitmap.createBitmap(rgb, width, height, Bitmap.Config.ARGB_8888); + } + + @Override + public boolean isAlphaMutableImageSupported() { + return true; + } + + @Override + public Object scale(Object nativeImage, int width, int height) { + return Bitmap.createScaledBitmap((Bitmap) nativeImage, width, height, + false); + } + + // @Override +// public Object rotate(Object image, int degrees) { +// Matrix matrix = new Matrix(); +// matrix.postRotate(degrees); +// return Bitmap.createBitmap((Bitmap) image, 0, 0, ((Bitmap) image).getWidth(), ((Bitmap) image).getHeight(), matrix, true); +// } + @Override + public boolean isRotationDrawingSupported() { + return false; + } + + @Override + protected boolean cacheLinearGradients() { + return false; + } + + @Override + public boolean isNativeInputSupported() { + return true; + } + + /** + * Returns true if the underlying OS supports opening the native navigation + * application + * @return true if the underlying OS supports launch of native navigation app + */ + public boolean isOpenNativeNavigationAppSupported(){ + return true; + } + + /** + * Opens the native navigation app in the given coordinate. + * @param latitude + * @param longitude + */ + public void openNativeNavigationApp(double latitude, double longitude){ + execute("google.navigation:ll=" + latitude+ "," + longitude); + } + + + @Override + public void openNativeNavigationApp(String location) { + execute("google.navigation:q=" + Util.encodeUrl(location)); + } + + @Override + public Object createMutableImage(int width, int height, int fillColor) { + Bitmap bitmap = Bitmap.createBitmap(width, height, + Bitmap.Config.ARGB_8888); + AndroidGraphics graphics = (AndroidGraphics) this.getNativeGraphics(bitmap); + graphics.fillBitmap(fillColor); + return bitmap; + } + + @Override + public int getImageHeight(Object i) { + return ((Bitmap) i).getHeight(); + } + + @Override + public int getImageWidth(Object i) { + return ((Bitmap) i).getWidth(); + } + + @Override + public void drawImage(Object graphics, Object img, int x, int y) { + ((AndroidGraphics) graphics).drawImage(img, x, y); + } + + @Override + public void tileImage(Object graphics, Object img, int x, int y, int w, int h) { + ((AndroidGraphics) graphics).tileImage(img, x, y, w, h); + } + + public boolean isScaledImageDrawingSupported() { + return true; + } + + public void drawImage(Object graphics, Object img, int x, int y, int w, int h) { + ((AndroidGraphics) graphics).drawImage(img, x, y, w, h); + } + + @Override + public void drawLine(Object graphics, int x1, int y1, int x2, int y2) { + ((AndroidGraphics) graphics).drawLine(x1, y1, x2, y2); + } + + @Override + public boolean isAntiAliasingSupported() { + return true; + } + + @Override + public void setAntiAliased(Object graphics, boolean a) { + ((AndroidGraphics) graphics).getPaint().setAntiAlias(a); + } + + @Override + public void drawPolygon(Object graphics, int[] xPoints, int[] yPoints, int nPoints) { + ((AndroidGraphics) graphics).drawPolygon(xPoints, yPoints, nPoints); + } + + @Override + public void fillPolygon(Object graphics, int[] xPoints, int[] yPoints, int nPoints) { + ((AndroidGraphics) graphics).fillPolygon(xPoints, yPoints, nPoints); + } + + @Override + public void drawRGB(Object graphics, int[] rgbData, int offset, int x, + int y, int w, int h, boolean processAlpha) { + ((AndroidGraphics) graphics).drawRGB(rgbData, offset, x, y, w, h, processAlpha); + } + + @Override + public void drawRect(Object graphics, int x, int y, int width, int height) { + ((AndroidGraphics) graphics).drawRect(x, y, width, height); + } + + @Override + public void drawRoundRect(Object graphics, int x, int y, int width, + int height, int arcWidth, int arcHeight) { + ((AndroidGraphics) graphics).drawRoundRect(x, y, width, height, arcWidth, arcHeight); + } + + @Override + public void drawString(Object graphics, String str, int x, int y) { + ((AndroidGraphics) graphics).drawString(str, x, y); + } + + @Override + public void drawArc(Object graphics, int x, int y, int width, int height, + int startAngle, int arcAngle) { + ((AndroidGraphics) graphics).drawArc(x, y, width, height, startAngle, arcAngle); + } + + @Override + public void fillArc(Object graphics, int x, int y, int width, int height, + int startAngle, int arcAngle) { + ((AndroidGraphics) graphics).fillArc(x, y, width, height, startAngle, arcAngle); + } + + @Override + public void fillRect(Object graphics, int x, int y, int width, int height) { + ((AndroidGraphics) graphics).fillRect(x, y, width, height); + } + + @Override + public void fillRect(Object graphics, int x, int y, int w, int h, byte alpha) { + ((AndroidGraphics) graphics).fillRect(x, y, w, h, alpha); + } + + @Override + public void paintComponentBackground(Object graphics, int x, int y, int width, int height, Style s) { + if((!asyncView) || compatPaintMode ) { + super.paintComponentBackground(graphics, x, y, width, height, s); + return; + } + ((AndroidGraphics) graphics).paintComponentBackground(x, y, width, height, s); + } + + @Override + public void fillLinearGradient(Object graphics, int startColor, int endColor, int x, int y, int width, int height, boolean horizontal) { + if(!asyncView) { + super.fillLinearGradient(graphics, startColor, endColor, x, y, width, height, horizontal); + return; + } + ((AndroidGraphics)graphics).fillLinearGradient(startColor, endColor, x, y, width, height, horizontal); + } + + @Override + public void fillRectRadialGradient(Object graphics, int startColor, int endColor, int x, int y, int width, int height, float relativeX, float relativeY, float relativeSize) { + if(!asyncView) { + super.fillRectRadialGradient(graphics, startColor, endColor, x, y, width, height, relativeX, relativeY, relativeSize); + return; + } + ((AndroidGraphics)graphics).fillRectRadialGradient(startColor, endColor, x, y, width, height, relativeX, relativeY, relativeSize); + } + + @Override + public void fillRadialGradient(Object graphics, int startColor, int endColor, int x, int y, int width, int height) { + ((AndroidGraphics)graphics).fillRadialGradient(startColor, endColor, x, y, width, height); + } + + @Override + public void fillRadialGradient(Object graphics, int startColor, int endColor, int x, int y, int width, int height, int startAngle, int arcAngle) { + ((AndroidGraphics)graphics).fillRadialGradient(startColor, endColor, x, y, width, height, startAngle, arcAngle); + } + + @Override + public void fillGradient(Object graphics, com.codename1.ui.Gradient gradient, + int x, int y, int width, int height) { + // Always route Android multi-stop gradients through the native Shader + // path - the software rasterizer in the base impl would otherwise + // allocate a per-call ARGB buffer on the Bitmap-graphics path used by + // mutable images, which on Android emulator hardware GCs heavily for + // conic / large fills (the case that hung the instrumentation suite). + ((AndroidGraphics) graphics).fillGradient(gradient, x, y, width, height); + } + + @Override + public void drawLabelComponent(Object nativeGraphics, int cmpX, int cmpY, int cmpHeight, int cmpWidth, Style style, String text, Object icon, Object stateIcon, int preserveSpaceForState, int gap, boolean rtl, boolean isOppositeSide, int textPosition, int stringWidth, boolean isTickerRunning, int tickerShiftText, boolean endsWith3Points, int valign) { + if(AndroidAsyncView.legacyPaintLogic) { + super.drawLabelComponent(nativeGraphics, cmpX, cmpY, cmpHeight, cmpWidth, style, text, icon, stateIcon, preserveSpaceForState, gap, rtl, isOppositeSide, textPosition, stringWidth, isTickerRunning, tickerShiftText, endsWith3Points, valign); + return; + } + ((AndroidGraphics)nativeGraphics).drawLabelComponent(cmpX, cmpY, cmpHeight, cmpWidth, style, text, + (Bitmap)icon, (Bitmap)stateIcon, preserveSpaceForState, gap, rtl, isOppositeSide, textPosition, stringWidth, + isTickerRunning, tickerShiftText, endsWith3Points, valign); + } + + + @Override + public void fillRoundRect(Object graphics, int x, int y, int width, + int height, int arcWidth, int arcHeight) { + ((AndroidGraphics) graphics).fillRoundRect(x, y, width, height, arcWidth, arcHeight); + } + + @Override + public int getAlpha(Object graphics) { + return ((AndroidGraphics) graphics).getAlpha(); + } + + @Override + public void setAlpha(Object graphics, int alpha) { + ((AndroidGraphics) graphics).setAlpha(alpha); + } + + @Override + public boolean isAlphaGlobal() { + return true; + } + + @Override + public void setColor(Object graphics, int RGB) { + ((AndroidGraphics) graphics).setColor((getColor(graphics) & 0xff000000) | RGB); + } + + @Override + public int getBackKeyCode() { + return DROID_IMPL_KEY_BACK; + } + + @Override + public int getBackspaceKeyCode() { + return DROID_IMPL_KEY_BACKSPACE; + } + + @Override + public int getClearKeyCode() { + return DROID_IMPL_KEY_CLEAR; + } + + @Override + public int getClipHeight(Object graphics) { + return ((AndroidGraphics) graphics).getClipHeight(); + } + + @Override + public int getClipWidth(Object graphics) { + return ((AndroidGraphics) graphics).getClipWidth(); + } + + @Override + public int getClipX(Object graphics) { + return ((AndroidGraphics) graphics).getClipX(); + } + + @Override + public int getClipY(Object graphics) { + return ((AndroidGraphics) graphics).getClipY(); + } + + @Override + public void setClip(Object graphics, int x, int y, int width, int height) { + ((AndroidGraphics) graphics).setClip(x, y, width, height); + } + + @Override + public boolean isShapeClipSupported(Object graphics){ + return Build.VERSION.SDK_INT > Build.VERSION_CODES.HONEYCOMB; + } + + @Override + public void setClip(Object graphics, Shape shape) { + //Path p = cn1ShapeToAndroidPath(shape); + ((AndroidGraphics) graphics).setClip(shape); + } + + + @Override + public void clipRect(Object graphics, int x, int y, int width, int height) { + ((AndroidGraphics) graphics).clipRect(x, y, width, height); + } + + @Override + public int getColor(Object graphics) { + return ((AndroidGraphics) graphics).getColor(); + } + + @Override + public int getDisplayHeight() { + if (this.myView != null) { + int h = this.myView.getViewHeight(); + displayHeight = h; + return h; + } + return displayHeight; + } + + @Override + public int getDisplayWidth() { + if (this.myView != null) { + int w = this.myView.getViewWidth(); + displayWidth = w; + return w; + } + return displayWidth; + } + + @Override + public int getActualDisplayHeight() { + DisplayMetrics dm = getContext().getResources().getDisplayMetrics(); + return dm.heightPixels; + } + + @Override + public int getGameAction(int keyCode) { + switch (keyCode) { + case DROID_IMPL_KEY_DOWN: + return Display.GAME_DOWN; + case DROID_IMPL_KEY_UP: + return Display.GAME_UP; + case DROID_IMPL_KEY_LEFT: + return Display.GAME_LEFT; + case DROID_IMPL_KEY_RIGHT: + return Display.GAME_RIGHT; + case DROID_IMPL_KEY_FIRE: + return Display.GAME_FIRE; + default: + return 0; + } + } + + @Override + public int getKeyCode(int gameAction) { + switch (gameAction) { + case Display.GAME_DOWN: + return DROID_IMPL_KEY_DOWN; + case Display.GAME_UP: + return DROID_IMPL_KEY_UP; + case Display.GAME_LEFT: + return DROID_IMPL_KEY_LEFT; + case Display.GAME_RIGHT: + return DROID_IMPL_KEY_RIGHT; + case Display.GAME_FIRE: + return DROID_IMPL_KEY_FIRE; + default: + return 0; + } + } + + @Override + public int[] getSoftkeyCode(int index) { + if (index == 0) { + return leftSK; + } + return null; + } + + @Override + public int getSoftkeyCount() { + /** + * one menu button only. we may have to stuff some code here as soon as + * there are devices that no longer have only a single menu button. + */ + return 1; + } + + @Override + public void vibrate(int duration) { + if (!this.vibrateInitialized) { + try { + v = (Vibrator) getContext().getSystemService(Context.VIBRATOR_SERVICE); + } catch (Throwable e) { + Log.e("Codename One", "problem with virbrator(0)", e); + } finally { + this.vibrateInitialized = true; + } + } + if (v != null) { + try { + v.vibrate(duration); + } catch (Throwable e) { + Log.e("Codename One", "problem with virbrator(1)", e); + } + } + } + + @Override + public boolean isTouchDevice() { + return getContext().getPackageManager().hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN); + } + + @Override + public boolean hasPendingPaints() { + //if the view is not visible make sure the edt won't wait. + if (myView != null && myView.getAndroidView().getVisibility() != View.VISIBLE) { + return true; + } else { + return super.hasPendingPaints(); + } + } + + public void revalidate() { + if (myView != null) { + myView.getAndroidView().setVisibility(View.VISIBLE); + Form form = getCurrentForm(); + if (form != null) { + form.revalidate(); + } + flushGraphics(); + } + + } + + @Override + public int getKeyboardType() { + if (Display.getInstance().getDefaultVirtualKeyboard().isVirtualKeyboardShowing()) { + return Display.KEYBOARD_TYPE_VIRTUAL; + } + /** + * can we detect this? but even if we could i think it is best to have + * this fixed to qwerty. we pass unicode values to Codename One in any + * case. check AndroidView.onKeyUpDown() method. and read comment below. + */ + return Display.KEYBOARD_TYPE_QWERTY; + /** + * some info from the MIDP docs about keycodes: + * + * "Applications receive keystroke events in which the individual keys + * are named within a space of key codes. Every key for which events are + * reported to MIDP applications is assigned a key code. The key code + * values are unique for each hardware key unless two keys are obvious + * synonyms for each other. MIDP defines the following key codes: + * KEY_NUM0, KEY_NUM1, KEY_NUM2, KEY_NUM3, KEY_NUM4, KEY_NUM5, KEY_NUM6, + * KEY_NUM7, KEY_NUM8, KEY_NUM9, KEY_STAR, and KEY_POUND. (These key + * codes correspond to keys on a ITU-T standard telephone keypad.) Other + * keys may be present on the keyboard, and they will generally have key + * codes distinct from those list above. In order to guarantee + * portability, applications should use only the standard key codes. + * + * The standard key codes values are equal to the Unicode encoding for + * the character that represents the key. If the device includes any + * other keys that have an obvious correspondence to a Unicode + * character, their key code values should equal the Unicode encoding + * for that character. For keys that have no corresponding Unicode + * character, the implementation must use negative values. Zero is + * defined to be an invalid key code." + * + * Because the MIDP implementation is our reference and that + * implementation does not interpret the given keycodes we behave alike + * and pass on the unicode values. + */ + } + + /** + * Exits the application... + */ + public void exitApplication() { + android.os.Process.killProcess(android.os.Process.myPid()); + } + + /** + * finishAndRemoveTask() arrived in Lollipop, and there is nothing to remove without an + * activity -- a push or background service process owns no task of its own. + */ + @Override + public boolean isExitAndClearTaskSupported() { + return Build.VERSION.SDK_INT >= 21 && getActivity() != null; + } + + @Override + public void exitApplicationAndClearTask() { + final CodenameOneActivity a = getActivity(); + if (a == null || Build.VERSION.SDK_INT < 21) { + exitApplication(); + return; + } + Runnable finishAndKill = new Runnable() { + public void run() { + try { + a.finishAndRemoveTask(); + } catch (Throwable t) { + // A task we failed to remove is still a task we must exit, so log and fall + // through to the kill rather than leaving the application running. + com.codename1.io.Log.e(t); + } + // Killing here is what makes this behave like exitApplication(), which never + // returns to its caller either. It does not race the removal: finishAndRemoveTask() + // is a blocking binder call into the activity manager, so the task is already off + // the recents list when it returns. Measured on an API 36 emulator with a probe + // that ran this exact sequence 29 times -- the task was gone from + // "dumpsys activity recents" every time, while the control that only killed the + // process (what exitApplication() does) left it there every time. + android.os.Process.killProcess(android.os.Process.myPid()); + } + }; + if (Looper.getMainLooper().getThread() == Thread.currentThread()) { + finishAndKill.run(); + } else { + a.runOnUiThread(finishAndKill); + } + } + + @Override + public void notifyPushCompletion() { + if (pushWakeLock != null && pushWakeLock.isHeld()) { + try { + pushWakeLock.release(); + } catch (Exception ex) { + com.codename1.io.Log.e(ex); + } + } + } + + @Override + public void notifyCommandBehavior(int commandBehavior) { + if (commandBehavior == Display.COMMAND_BEHAVIOR_NATIVE) { + if (getActivity() instanceof CodenameOneActivity) { + ((CodenameOneActivity) getActivity()).enableNativeMenu(true); + } + } + } + + private static class NotifyActionBar implements Runnable { + private Activity activity; + private boolean show; + + public NotifyActionBar(Activity activity, int commandBehavior) { + this.activity = activity; + show = commandBehavior == Display.COMMAND_BEHAVIOR_NATIVE; + } + + public NotifyActionBar(Activity activity, boolean show) { + this.activity = activity; + this.show = show; + } + + @Override + public void run() { + activity.invalidateOptionsMenu(); + if (activity.getActionBar() == null) { + return; + } + if (show) { + activity.getActionBar().show(); + } else { + activity.getActionBar().hide(); + } + } + } + + @Override + public String getAppArg() { + if (super.getAppArg() != null) { + // This just maintains backward compatibility in case people are manually + // setting the AppArg in their properties. It reproduces the general + // behaviour the existed when AppArg was just another Display property. + return super.getAppArg(); + } + if (getActivity() == null) { + return null; + } + + android.content.Intent intent = getActivity().getIntent(); + if (intent != null) { + publishIntentProperties(getActivity(), intent); + String sharedText = intent.getStringExtra(Intent.EXTRA_TEXT); + intent.removeExtra(Intent.EXTRA_TEXT); + Uri u = intent.getData(); + String scheme = intent.getScheme(); + if (u != null && isAppArgDelivered(intent)) { + // dispatchNewIntentUrl() already handed this url over as the app arg + // on the warm path. The data stays on the intent for the readers that + // want it -- `android.intent.data` above, and native code asking the + // activity for its intent -- and only the second delivery is dropped. + u = null; + } + if (u == null && intent.getExtras() != null) { + if (intent.getExtras().keySet().contains("android.intent.extra.STREAM")) { + try { + u = (Uri)intent.getParcelableExtra("android.intent.extra.STREAM"); + scheme = u.getScheme(); + System.out.println("u="+u); + } catch (Exception ex) { + Log.d("Codename One", "Failed to load parcelable extra from intent: "+ex.getMessage()); + } + } + + } + if (u != null) { + //String scheme = intent.getScheme(); + intent.setData(null); + if ("content".equals(scheme)) { + try { + InputStream attachment = getActivity().getContentResolver().openInputStream(u); + if (attachment != null) { + String name = getContentName(getActivity().getContentResolver(), u); + if (name != null) { + String filePath = getAppHomePath() + + getFileSystemSeparator() + name; + if(filePath.startsWith("file:")) { + filePath = filePath.substring(5); + } + File f = new File(filePath); + OutputStream tmp = createFileOuputStream(f); + byte[] buffer = new byte[1024]; + int read = -1; + while ((read = attachment.read(buffer)) > -1) { + tmp.write(buffer, 0, read); + } + tmp.close(); + attachment.close(); + setAppArg(addFile(filePath)); + return addFile(filePath); + } + } + } catch (FileNotFoundException e) { + e.printStackTrace(); + return null; + } catch (IOException e) { + e.printStackTrace(); + return null; + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } else { + + /* + // Why do we need this special case? u.toString() + // will include the full URL including query string. + // This special case causes urls like myscheme://part1/part2 + // to only return "/part2" which is obviously problematic and + // is inconsistent with iOS. Is this special case necessary + // in some versions of Android? + String encodedPath = u.getEncodedPath(); + if (encodedPath != null && encodedPath.length() > 0) { + String query = u.getQuery(); + if(query != null && query.length() > 0){ + encodedPath += "?" + query; + } + setAppArg(encodedPath); + return encodedPath; + } + */ + if (sharedText != null) { + setAppArg(sharedText); + return sharedText; + } else { + setAppArg(u.toString()); + return u.toString(); + } + + } + } else if (sharedText != null) { + setAppArg(sharedText); + return sharedText; + } + } + return null; + } + + // taken from https://stackoverflow.com/a/70380413/756809 + private boolean isRunningOnAndroidStudioEmulator() { + return Build.FINGERPRINT.startsWith("google/sdk_gphone") + && Build.FINGERPRINT.endsWith(":user/release-keys") + && "Google".equals(Build.MANUFACTURER) && Build.PRODUCT.startsWith("sdk_gphone") && "google".equals(Build.BRAND) + && Build.MODEL.startsWith("sdk_gphone"); + } + + // taken from https://stackoverflow.com/a/57960169/756809 + private boolean isEmulator() { + return isRunningOnAndroidStudioEmulator() || + ((Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) + || Build.FINGERPRINT.startsWith("generic") + || Build.FINGERPRINT.startsWith("unknown") + || Build.HARDWARE.contains("goldfish") + || Build.HARDWARE.contains("ranchu") + || Build.MODEL.contains("google_sdk") + || Build.MODEL.contains("Emulator") + || Build.MODEL.contains("Android SDK built for x86") + || Build.MODEL.contains("VirtualBox") + || Build.MANUFACTURER.contains("Genymotion") + || Build.PRODUCT.contains("sdk_google") + || Build.PRODUCT.contains("google_sdk") + || Build.PRODUCT.contains("sdk") + || Build.PRODUCT.contains("sdk_x86") + || Build.PRODUCT.contains("vbox86p") + || Build.PRODUCT.contains("emulator") + || Build.PRODUCT.contains("simulator")); + } + + + /** + * @inheritDoc + */ + @Override + public boolean canDial() { + return getContext().getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELEPHONY); + } + + /** + * @inheritDoc + */ + private static String cn1DistributionChannel; + private static boolean cn1DistributionChannelResolved; + /** Codename One channel id-value pair id in the APK Signing Block ('c','n','1','C'). */ + private static final int CN1_CHANNEL_PAIR_ID = 0x636E3143; + + /** + * The distribution channel (app store) stamped into this APK's Signing Block by + * the build server's channel packages, or null for a normal build. Read once and + * cached. Mirrors the daemon's {@code ApkChannelWriter}: locate the signing block + * before the central directory and return the Codename One channel pair's value. + */ + private String readDistributionChannel() { + if (cn1DistributionChannelResolved) { + return cn1DistributionChannel; + } + cn1DistributionChannelResolved = true; + try { + cn1DistributionChannel = cn1ReadChannelFromApk(getContext().getApplicationInfo().sourceDir); + } catch (Throwable t) { + cn1DistributionChannel = null; + } + return cn1DistributionChannel; + } + + private static String cn1ReadChannelFromApk(String path) throws java.io.IOException { + java.io.RandomAccessFile f = new java.io.RandomAccessFile(path, "r"); + try { + long len = f.length(); + long eocd = -1; + long maxBack = Math.min(len, 22 + 0xFFFF); + for (long i = len - 22; i >= len - maxBack && i >= 0; i--) { + if (cn1U32(f, i) == 0x06054b50L) { + eocd = i; + break; + } + } + if (eocd < 0) { + return null; + } + long cdOffset = cn1U32(f, eocd + 16); + if (cdOffset < 24 || cdOffset == 0xFFFFFFFFL) { + return null; + } + byte[] magic = "APK Sig Block 42".getBytes("US-ASCII"); + byte[] m = new byte[magic.length]; + f.seek(cdOffset - 16); + f.readFully(m); + for (int i = 0; i < magic.length; i++) { + if (m[i] != magic[i]) { + return null; + } + } + long sizeOfBlock = cn1U64(f, cdOffset - 24); + long blockStart = cdOffset - 8 - sizeOfBlock; + if (blockStart < 0) { + return null; + } + long p = blockStart + 8, to = cdOffset - 24; + while (p < to) { + long pairLen = cn1U64(f, p); + p += 8; + if (pairLen < 4 || p + pairLen > to + 8) { + break; + } + if ((int) cn1U32(f, p) == CN1_CHANNEL_PAIR_ID) { + byte[] v = new byte[(int) (pairLen - 4)]; + f.seek(p + 4); + f.readFully(v); + return new String(v, "UTF-8"); + } + p += pairLen; + } + return null; + } finally { + f.close(); + } + } + + private static long cn1U32(java.io.RandomAccessFile f, long at) throws java.io.IOException { + f.seek(at); + int b0 = f.read(), b1 = f.read(), b2 = f.read(), b3 = f.read(); + return (b0 & 0xFFL) | ((b1 & 0xFFL) << 8) | ((b2 & 0xFFL) << 16) | ((b3 & 0xFFL) << 24); + } + + private static long cn1U64(java.io.RandomAccessFile f, long at) throws java.io.IOException { + f.seek(at); + long v = 0; + for (int i = 0; i < 8; i++) { + v |= (f.read() & 0xFFL) << (8 * i); + } + return v; + } + + public String getProperty(String key, String defaultValue) { + if(key.equalsIgnoreCase("cn1_push_prefix")) { + /*if(!checkForPermission(Manifest.permission.READ_PHONE_STATE, "This is required to get notifications")){ + return ""; + }*/ + boolean has = hasAndroidMarket(); + if(has) { + return "gcm"; + } + return defaultValue; + } + if ("OS".equals(key)) { + return "Android"; + } + if ("DistributionChannel".equalsIgnoreCase(key) || "cn1.channel".equalsIgnoreCase(key)) { + // The app store this build was distributed through, stamped into the APK + // Signing Block by the Codename One build server's channel packages + // (android.distributionChannels). Empty for a normal Google Play build. + String ch = readDistributionChannel(); + return ch != null ? ch : defaultValue; + } + + // It's possible that this is triggering a Google Play data collection verification error + /*if ("androidId".equals(key)) { + return Settings.Secure.getString(getContext().getContentResolver(), Settings.Secure.ANDROID_ID); + }*/ + + /*if ("cellId".equals(key)) { + try { + if(!checkForPermission(Manifest.permission.READ_PHONE_STATE, "This is required to get the cellId")){ + return defaultValue; + } + String serviceName = Context.TELEPHONY_SERVICE; + TelephonyManager telephonyManager = (TelephonyManager) getContext().getSystemService(serviceName); + int cellId = ((GsmCellLocation) telephonyManager.getCellLocation()).getCid(); + return "" + cellId; + } catch (Throwable t) { + return defaultValue; + } + }*/ + if ("AppName".equals(key)) { + + final PackageManager pm = getContext().getPackageManager(); + ApplicationInfo ai; + try { + ai = pm.getApplicationInfo(getContext().getPackageName(), 0); + } catch (NameNotFoundException e) { + ai = null; + } + String applicationName = (String) (ai != null ? pm.getApplicationLabel(ai) : null); + if(applicationName == null){ + return defaultValue; + } + return applicationName; + } + if ("AppVersion".equals(key)) { + try { + PackageInfo i = getContext().getPackageManager().getPackageInfo(getContext().getApplicationInfo().packageName, 0); + return i.versionName; + } catch (NameNotFoundException ex) { + ex.printStackTrace(); + } + return defaultValue; + } + if ("Platform".equals(key)) { + String p = System.getProperty("platform"); + if(p == null) { + return defaultValue; + } + return p; + } + if ("User-Agent".equals(key)) { + String ua = getUserAgent(); + if(ua == null) { + return defaultValue; + } + return ua; + } + if("OSVer".equals(key)) { + return "" + android.os.Build.VERSION.RELEASE; + } + if("DeviceName".equals(key)) { + return "" + android.os.Build.MODEL; + } + if("DeviceHardwareModel".equals(key)) { + return "" + android.os.Build.MODEL; + } + if("DeviceManufacturer".equals(key)) { + return "" + android.os.Build.MANUFACTURER; + } + if("Emulator".equals(key)) { + return "" + isEmulator(); + } + /*try { + if ("IMEI".equals(key) || "UDID".equals(key)) { + if(!checkForPermission(Manifest.permission.READ_PHONE_STATE, "This is required to get the device ID")){ + return ""; + } + TelephonyManager tm = (TelephonyManager) getContext().getSystemService(Context.TELEPHONY_SERVICE); + String imei = null; + if (tm!=null && tm.getDeviceId() != null) { + // for phones or 3g tablets + imei = tm.getDeviceId(); + } else { + try { + imei = Secure.getString(getContext().getContentResolver(), Secure.ANDROID_ID); + } catch(Throwable t) { + com.codename1.io.Log.e(t); + } + } + return imei; + } + if ("MSISDN".equals(key)) { + if(!checkForPermission(Manifest.permission.READ_PHONE_STATE, "This is required to get the device ID")){ + return ""; + } + TelephonyManager tm = (TelephonyManager) getContext().getSystemService(Context.TELEPHONY_SERVICE); + return tm.getLine1Number(); + } + } catch(Throwable t) { + // will be caused by no permissions. + return defaultValue; + }*/ + + if (getActivity() != null) { + android.content.Intent intent = getActivity().getIntent(); + if(intent != null){ + Bundle extras = intent.getExtras(); + if (extras != null) { + String value = extras.getString(key); + if(value != null) { + return value; + } + } + } + } + + if(!key.startsWith("android.permission")) { + //these keys/values are from the Application Resources (strings values) + try { + int id = getContext().getResources().getIdentifier(key, "string", getContext().getApplicationInfo().packageName); + if (id != 0) { + String val = getContext().getResources().getString(id); + return val; + } + } catch (Exception e) { + } + } + return System.getProperty(key, super.getProperty(key, defaultValue)); + } + + private String getContentName(ContentResolver resolver, Uri uri) { + Cursor cursor = resolver.query(uri, null, null, null, null); + cursor.moveToFirst(); + int nameIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME); + if (nameIndex >= 0) { + String name = cursor.getString(nameIndex); + cursor.close(); + return name; + } + return null; + } + + private String getUserAgent() { + try { + String userAgent = System.getProperty("http.agent"); + if(userAgent != null){ + return userAgent; + } + } catch (Exception e) { + } + if (getActivity() == null) { + return "Android-CN1"; + } + try { + Constructor constructor = WebSettings.class.getDeclaredConstructor(Context.class, WebView.class); + constructor.setAccessible(true); + try { + WebSettings settings = constructor.newInstance(getActivity(), null); + return settings.getUserAgentString(); + } finally { + constructor.setAccessible(false); + } + } catch (Exception e) { + final StringBuffer ua = new StringBuffer(); + if (Thread.currentThread().getName().equalsIgnoreCase("main")) { + WebView m_webview = new WebView(getActivity()); + ua.append(m_webview.getSettings().getUserAgentString()); + m_webview.destroy(); + } else { + final boolean[] flag = new boolean[1]; + Thread thread = new Thread() { + public void run() { + Looper.prepare(); + WebView m_webview = new WebView(getActivity()); + ua.append(m_webview.getSettings().getUserAgentString()); + m_webview.destroy(); + Looper.loop(); + flag[0] = true; + synchronized (flag) { + flag.notify(); + } + } + }; + thread.setUncaughtExceptionHandler(AndroidImplementation.exceptionHandler); + thread.start(); + while (!flag[0]) { + synchronized (flag) { + try { + flag.wait(100); + } catch (InterruptedException ex) { + } + } + } + } + return ua.toString(); + } + } + + private String getMimeType(String url){ + String type = null; + String extension = MimeTypeMap.getFileExtensionFromUrl(url); + if (extension != null) { + MimeTypeMap mime = MimeTypeMap.getSingleton(); + + type = mime.getMimeTypeFromExtension(extension); + } + if (type == null) { + try { + Uri uri = Uri.parse(url); + ContentResolver cr = getContext().getContentResolver(); + type = cr.getType(uri); + } catch (Throwable t) { + t.printStackTrace(); + } + } + return type; + } + + public static void copy(File src, File dst) throws IOException { + InputStream in = new FileInputStream(src); + try { + OutputStream out = new FileOutputStream(dst); + try { + // Transfer bytes from in to out + byte[] buf = new byte[8096]; + int len; + while ((len = in.read(buf)) > 0) { + out.write(buf, 0, len); + } + } finally { + out.close(); + } + } finally { + in.close(); + } + } + + private static File makeTempCacheCopy(File file) throws IOException { + File cacheDir = new File(getContext().getCacheDir(), "intent_files"); + + // Create the storage directory if it does not exist + if (!cacheDir.exists()) { + if (!cacheDir.mkdirs()) { + Log.d(Display.getInstance().getProperty("AppName", "CodenameOne"), "failed to create directory"); + return null; + } + } + + File copy = new File(cacheDir, "tmp-"+System.currentTimeMillis()+file.getName()); + copy(file, copy); + return copy; + + } + + + + private Intent createIntentForURL(String url) { + Intent intent; + Uri uri; + try { + if (url.startsWith("intent")) { + intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME); + } else { + if(url.startsWith("/") || url.startsWith("file:")) { + if (PermissionsHelper.requiresExternalStoragePermissionForMediaAccess()) { + if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to open the file")){ + return null; + } + } + + } + intent = new Intent(); + intent.setAction(Intent.ACTION_VIEW); + if (url.startsWith("/")) { + File f = new File(url); + Uri furi = null; + try { + furi = FileProvider.getUriForFile(getContext(), getContext().getPackageName()+".provider", f); + } catch (Exception ex) { + f = makeTempCacheCopy(f); + furi = FileProvider.getUriForFile(getContext(), getContext().getPackageName()+".provider", f); + } + + + if (Build.VERSION.SDK_INT < 21) { + List resInfoList = getContext().getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); + for (ResolveInfo resolveInfo : resInfoList) { + String packageName = resolveInfo.activityInfo.packageName; + getContext().grantUriPermission(packageName, furi, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + } + + uri = furi; + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK|Intent.FLAG_GRANT_READ_URI_PERMISSION); + }else{ + + if (url.startsWith("file:")) { + File f = new File(removeFilePrefix(url)); + System.out.println("File size: "+f.length()); + + Uri furi = null; + try { + furi = FileProvider.getUriForFile(getContext(), getContext().getPackageName()+".provider", f); + } catch (Exception ex) { + f = makeTempCacheCopy(f); + furi = FileProvider.getUriForFile(getContext(), getContext().getPackageName()+".provider", f); + } + + + if (Build.VERSION.SDK_INT < 21) { + List resInfoList = getContext().getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); + for (ResolveInfo resolveInfo : resInfoList) { + String packageName = resolveInfo.activityInfo.packageName; + getContext().grantUriPermission(packageName, furi, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + } + uri = furi; + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK|Intent.FLAG_GRANT_READ_URI_PERMISSION); + + + } else { + uri = Uri.parse(url); + } + } + String mimeType = getMimeType(url); + if(mimeType != null){ + intent.setDataAndType(uri, mimeType); + }else{ + intent.setData(uri); + } + } + + return intent; + } catch(Exception err) { + com.codename1.io.Log.e(err); + return null; + } + } + + @Override + public Boolean canExecute(String url) { + try { + Intent it = createIntentForURL(url); + if(it == null) { + return false; + } + final PackageManager mgr = getContext().getPackageManager(); + List list = mgr.queryIntentActivities(it, PackageManager.MATCH_DEFAULT_ONLY); + return list.size() > 0; + } catch(Exception err) { + com.codename1.io.Log.e(err); + return false; + } + } + + + public void execute(String url, ActionListener response) { + if (response != null) { + callback = new EventDispatcher(); + callback.addListener(response); + } + + try { + Intent intent = createIntentForURL(url); + if(intent == null) { + return; + } + if(response != null && getActivity() != null){ + getActivity().startActivityForResult(intent, IntentResultListener.URI_SCHEME); + }else { + getContext().startActivity(intent); + } + return; + } catch (Exception ex) { + com.codename1.io.Log.e(ex); + } + + try { + if(editInProgress()) { + stopEditing(true); + } + getContext().startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url))); + } catch (Exception e) { + e.printStackTrace(); + } + } + + + /** + * @inheritDoc + */ + @Override + public void execute(String url) { + execute(url, null); + } + + /** + * @inheritDoc + */ + public void playBuiltinSound(String soundIdentifier) { + if (getActivity() != null && Display.SOUND_TYPE_BUTTON_PRESS.equals(soundIdentifier)) { + getActivity().runOnUiThread(new Runnable() { + public void run() { + if (myView != null) { + myView.getAndroidView().playSoundEffect(AudioManager.FX_KEY_CLICK); + } + } + }); + } + } + + /** + * @inheritDoc + */ + protected void playNativeBuiltinSound(Object data) { + } + + /** + * @inheritDoc + */ + public boolean isBuiltinSoundAvailable(String soundIdentifier) { + return false; + } + + /** + * @inheritDoc + */ + @Override + public boolean isNativeVideoPlayerControlsIncluded() { + return true; + } + + private static final int STATE_PAUSED = 0; + private static final int STATE_PLAYING = 1; + + private int mCurrentState; + + private MediaBrowserCompat mMediaBrowserCompat; + private android.support.v4.media.session.MediaControllerCompat mMediaControllerCompat; + + private android.support.v4.media.session.MediaControllerCompat.Callback mMediaControllerCompatCallback = new android.support.v4.media.session.MediaControllerCompat.Callback() { + + @Override + public void onPlaybackStateChanged(PlaybackStateCompat state) { + super.onPlaybackStateChanged(state); + if( state == null ) { + return; + } + + switch( state.getState() ) { + case PlaybackStateCompat.STATE_PLAYING: { + mCurrentState = STATE_PLAYING; + break; + } + case PlaybackStateCompat.STATE_PAUSED: { + mCurrentState = STATE_PAUSED; + break; + } + } + } + }; + + private MediaBrowserCompat.ConnectionCallback mMediaBrowserCompatConnectionCallback = new MediaBrowserCompat.ConnectionCallback() { + + @Override + public void onConnected() { + super.onConnected(); + try { + mMediaControllerCompat = new MediaControllerCompat(getActivity(), mMediaBrowserCompat.getSessionToken()); + mMediaControllerCompat.registerCallback(mMediaControllerCompatCallback); + MediaControllerCompat.setMediaController(getActivity(), mMediaControllerCompat); + MediaControllerCompat.getMediaController(getActivity()).getTransportControls().play(); + + } catch( RemoteException e ) { + e.printStackTrace(); + } + } + }; + + //BackgroundAudioService remoteControl; + + @Override + public void startRemoteControl() { + super.startRemoteControl(); + getActivity().runOnUiThread(new Runnable() { + public void run() { + mMediaBrowserCompat = new MediaBrowserCompat(getActivity(), new ComponentName(getActivity(), BackgroundAudioService.class), + mMediaBrowserCompatConnectionCallback, getActivity().getIntent().getExtras()); + + mMediaBrowserCompat.connect(); + AndroidNativeUtil.addLifecycleListener(new LifecycleListener() { + @Override + public void onCreate(Bundle savedInstanceState) { + + } + + @Override + public void onResume() { + + } + + @Override + public void onPause() { + + } + + @Override + public void onDestroy() { + if (mMediaBrowserCompat != null) { + if( MediaControllerCompat.getMediaController(getActivity()).getPlaybackState().getState() == PlaybackStateCompat.STATE_PLAYING ) { + MediaControllerCompat.getMediaController(getActivity()).getTransportControls().pause(); + } + + mMediaBrowserCompat.disconnect(); + mMediaBrowserCompat = null; + } + } + + @Override + public void onSaveInstanceState(Bundle b) { + + } + + @Override + public void onLowMemory() { + + } + }); + } + + }); + + } + + @Override + public void stopRemoteControl() { + super.stopRemoteControl(); + if (mMediaBrowserCompat != null) { + if( MediaControllerCompat.getMediaController(getActivity()).getPlaybackState().getState() == PlaybackStateCompat.STATE_PLAYING ) { + MediaControllerCompat.getMediaController(getActivity()).getTransportControls().pause(); + } + + mMediaBrowserCompat.disconnect(); + mMediaBrowserCompat = null; + } + } + + + @Override + public AsyncResource createBackgroundMediaAsync(final String uri) { + final AsyncResource out = new AsyncResource(); + new Thread(new Runnable() { + public void run() { + try { + out.complete(createBackgroundMedia(uri)); + } catch (IOException ex) { + out.error(ex); + } + } + }).start(); + + return out; + } + + private int nextMediaId; + private int backgroundMediaCount; + private ServiceConnection backgroundMediaServiceConnection; + @Override + public Media createBackgroundMedia(final String uri) throws IOException { + int mediaId = nextMediaId++; + backgroundMediaCount++; + + Intent serviceIntent = new Intent(getContext(), AudioService.class); + serviceIntent.putExtra("mediaLink", uri); + serviceIntent.putExtra("mediaId", mediaId); + if (background == null) { + ServiceConnection mConnection = new ServiceConnection() { + + public void onServiceDisconnected(ComponentName name) { + + background = null; + backgroundMediaServiceConnection = null; + } + + public void onServiceConnected(ComponentName name, IBinder service) { + AudioService.LocalBinder mLocalBinder = (AudioService.LocalBinder) service; + AudioService svc = (AudioService)mLocalBinder.getService(); + background = svc; + } + }; + backgroundMediaServiceConnection = mConnection; + boolean boundSuccess = getContext().bindService(serviceIntent, mConnection, getContext().BIND_AUTO_CREATE); + if (!boundSuccess) { + throw new RuntimeException("Failed to bind background media service for uri "+uri); + } + ContextCompat.startForegroundService(getContext(), serviceIntent); + while (background == null) { + Display.getInstance().invokeAndBlock(new Runnable() { + @Override + public void run() { + Util.sleep(200); + } + }); + } + } else { + ContextCompat.startForegroundService(getContext(), serviceIntent); + } + + while (background.getMedia(mediaId) == null) { + Display.getInstance().invokeAndBlock(new Runnable() { + public void run() { + Util.sleep(200); + } + + }); + } + Media ret = new MediaProxy(background.getMedia(mediaId)) { + + + @Override + public void cleanup() { + super.cleanup(); + if (--backgroundMediaCount <= 0) { + if (backgroundMediaServiceConnection != null) { + try { + getContext().unbindService(backgroundMediaServiceConnection); + } catch (IllegalArgumentException ex) { + // This is thrown sometimes if the service has already been unbound + } + } + } + } + }; + + return ret; + + } + + + /** + * @inheritDoc + */ + @Override + public Media createMedia(final String uri, boolean isVideo, final Runnable onCompletion) throws IOException { + if (getActivity() == null) { + return null; + } + if (uri.startsWith("file://")) { + return createMedia(removeFilePrefix(uri), isVideo, onCompletion); + } + File file = null; + if (uri.indexOf(':') < 0) { + // use a file object to play to try and workaround this issue: + // http://code.google.com/p/android/issues/detail?id=4124 + file = new File(uri); + } + + Uri parsedUri = null; + boolean isContentUri = false; + if (file == null) { + parsedUri = Uri.parse(uri); + isContentUri = parsedUri != null && "content".equalsIgnoreCase(parsedUri.getScheme()); + } + + // The document picker grants temporary permissions for content URIs. Requesting + // READ_EXTERNAL_STORAGE again would surface a redundant prompt on Android 13+, so we only + // ask for classic file paths that require the legacy permission. MediaStore URIs still + // require an explicit permission grant, so they remain subject to the legacy check even + // though they also use the content:// scheme. + boolean requiresLegacyPermission = !uri.startsWith(FileSystemStorage.getInstance().getAppHomePath()); + if (isContentUri && parsedUri != null) { + String authority = parsedUri.getAuthority(); + if (authority != null) { + authority = authority.toLowerCase(); + if (!"media".equals(authority) && !authority.startsWith("media.")) { + if (!"com.android.providers.media.documents".equals(authority)) { + requiresLegacyPermission = false; + } + } + } else { + requiresLegacyPermission = false; + } + } + + if(requiresLegacyPermission) { + if(!PermissionsHelper.checkForPermission(isVideo ? DevicePermission.PERMISSION_READ_VIDEO : DevicePermission.PERMISSION_READ_AUDIO, "This is required to play media")){ + return null; + } + } + + Media retVal; + + if (isVideo) { + final AndroidImplementation.Video[] video = new AndroidImplementation.Video[1]; + final boolean[] flag = new boolean[1]; + final File f = file; + final Uri videoUri = parsedUri; + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + VideoView v = new VideoView(getActivity()); + v.setZOrderMediaOverlay(true); + if (f != null) { + v.setVideoURI(Uri.fromFile(f)); + } else { + v.setVideoURI(videoUri != null ? videoUri : Uri.parse(uri)); + } + video[0] = new AndroidImplementation.Video(v, getActivity(), onCompletion); + flag[0] = true; + synchronized (flag) { + flag.notify(); + } + } + }); + while (!flag[0]) { + synchronized (flag) { + try { + flag.wait(100); + } catch (InterruptedException ex) { + } + } + } + return video[0]; + } else { + MediaPlayer player; + if (file != null) { + FileInputStream is = new FileInputStream(file); + player = new MediaPlayer(); + player.setDataSource(is.getFD()); + player.prepare(); + } else { + player = MediaPlayer.create(getActivity(), parsedUri != null ? parsedUri : Uri.parse(uri)); + if (player == null && isContentUri) { + // Android 13+ introduces stricter access rules for content:// URIs returned + // from the system document picker. The picker grants our activity a + // persistable read permission, but some OEM builds still reject the URI when it + // is passed directly to MediaPlayer. Opening the descriptor ourselves keeps the + // same permission grant while avoiding the OEM bug. + ContentResolver resolver = getContext().getContentResolver(); + if (resolver != null && parsedUri != null) { + AssetFileDescriptor afd = null; + try { + afd = resolver.openAssetFileDescriptor(parsedUri, "r"); + if (afd != null) { + player = new MediaPlayer(); + player.setDataSource(afd.getFileDescriptor(), afd.getStartOffset(), afd.getLength()); + player.prepare(); + } + } finally { + if (afd != null) { + try { + afd.close(); + } catch (IOException ignore) { + } + } + } + } + } + } + if (player == null) { + throw new IOException("Unable to create media player for uri " + uri); + } + retVal = new Audio(getActivity(), player, null, onCompletion); + } + return retVal; + } + + @Override + public void addCompletionHandler(Media media, Runnable onCompletion) { + super.addCompletionHandler(media, onCompletion); + if (media instanceof Video) { + ((Video)media).addCompletionHandler(onCompletion); + } else if (media instanceof Audio) { + ((Audio)media).addCompletionHandler(onCompletion); + } else if (media instanceof MediaProxy) { + ((MediaProxy)media).addCompletionHandler(onCompletion); + } + } + + @Override + public void removeCompletionHandler(Media media, Runnable onCompletion) { + super.removeCompletionHandler(media, onCompletion); + if (media instanceof Video) { + ((Video)media).removeCompletionHandler(onCompletion); + } else if (media instanceof Audio) { + ((Audio)media).removeCompletionHandler(onCompletion); + } else if (media instanceof MediaProxy) { + ((MediaProxy)media).removeCompletionHandler(onCompletion); + } + } + + + + /** + * @inheritDoc + */ + @Override + public Media createMedia(InputStream stream, String mimeType, final Runnable onCompletion) throws IOException { + if (getActivity() == null) { + return null; + } + /*if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to play media")){ + return null; + }*/ + boolean isVideo = mimeType.contains("video"); + + if (!isVideo && stream instanceof FileInputStream) { + MediaPlayer player = new MediaPlayer(); + player.setDataSource(((FileInputStream) stream).getFD()); + player.prepare(); + return new Audio(getActivity(), player, stream, onCompletion); + } + String extension = MimeTypeMap.getFileExtensionFromUrl(mimeType); + final File temp = File.createTempFile("mtmp", extension == null ? "dat" : extension); + temp.deleteOnExit(); + OutputStream out = createFileOuputStream(temp); + + byte buf[] = new byte[256]; + int len = 0; + while ((len = stream.read(buf, 0, buf.length)) > -1) { + out.write(buf, 0, len); + } + out.close(); + stream.close(); + + final Runnable finish = new Runnable() { + + @Override + public void run() { + if(onCompletion != null){ + Display.getInstance().callSerially(onCompletion); + + // makes sure the file is only deleted after the onCompletion was invoked + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + temp.delete(); + } + }); + return; + } + temp.delete(); + } + }; + + if (isVideo) { + final AndroidImplementation.Video[] retVal = new AndroidImplementation.Video[1]; + final boolean[] flag = new boolean[1]; + + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + VideoView v = new VideoView(getActivity()); + v.setZOrderMediaOverlay(true); + v.setVideoURI(Uri.fromFile(temp)); + retVal[0] = new AndroidImplementation.Video(v, getActivity(), finish); + flag[0] = true; + synchronized (flag) { + flag.notify(); + } + } + }); + while (!flag[0]) { + synchronized (flag) { + try { + flag.wait(100); + } catch (InterruptedException ex) { + } + } + } + + return retVal[0]; + } else { + return createMedia(createFileInputStream(temp), mimeType, finish); + } + + } + + @Override + public boolean isSoundPoolSupported() { + return getContext() != null; + } + + @Override + public com.codename1.media.SoundPoolPeer createSoundPool(int maxStreams) { + if (getContext() == null) { + return null; + } + return new com.codename1.media.GameSoundPool(this, maxStreams); + } + + @Override + public Media createMediaRecorder(MediaRecorderBuilder builder) throws IOException { + return createMediaRecorder(builder.getPath(), builder.getMimeType(), builder.getSamplingRate(), builder.getBitRate(), builder.getAudioChannels(), 0, builder.isRedirectToAudioBuffer()); + } + + @Override + public Media createMediaRecorder(final String path, final String mimeType) throws IOException { + MediaRecorderBuilder builder = new MediaRecorderBuilder() + .path(path) + .mimeType(mimeType); + return createMediaRecorder(builder); + } + + + + private Media createMediaRecorder(final String path, final String mimeType, final int sampleRate, final int bitRate, final int audioChannels, final int maxDuration, final boolean redirectToAudioBuffer) throws IOException { + if (getActivity() == null) { + return null; + } + if(!checkForPermission(Manifest.permission.RECORD_AUDIO, "This is required to record audio")){ + return null; + } + final Media[] record = new Media[1]; + final IOException[] error = new IOException[1]; + + final Object lock = new Object(); + synchronized (lock) { + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + synchronized (lock) { + if (redirectToAudioBuffer) { + final int channelConfig =audioChannels == 1 ? android.media.AudioFormat.CHANNEL_IN_MONO + : audioChannels == 2 ? android.media.AudioFormat.CHANNEL_IN_STEREO + : android.media.AudioFormat.CHANNEL_IN_MONO; + final AudioRecord recorder = new AudioRecord( + MediaRecorder.AudioSource.MIC, + sampleRate, + channelConfig, + AudioFormat.ENCODING_PCM_16BIT, + AudioRecord.getMinBufferSize(sampleRate, channelConfig, AudioFormat.ENCODING_PCM_16BIT) + ); + + final com.codename1.media.AudioBuffer audioBuffer = com.codename1.media.MediaManager.getAudioBuffer(path, true, 64); + + record[0] = new AbstractMedia() { + private int lastTime; + private boolean isRecording; + @Override + protected void playImpl() { + if (isRecording) { + return; + } + isRecording = true; + recorder.startRecording(); + fireMediaStateChange(State.Playing); + new Thread(new Runnable() { + public void run() { + float[] audioData = new float[audioBuffer.getMaxSize()]; + short[] buffer = new short[AudioRecord.getMinBufferSize(recorder.getSampleRate(), recorder.getChannelCount(), AudioFormat.ENCODING_PCM_16BIT)]; + int read = -1; + int index = 0; + + while (isRecording && (read = recorder.read(buffer, 0, buffer.length)) >= 0) { + if (read > 0) { + for (int i=0; i= audioData.length) { + audioBuffer.copyFrom(sampleRate, audioChannels, audioData, 0, index); + index = 0; + } + } + if (index > 0) { + audioBuffer.copyFrom(sampleRate, audioChannels, audioData, 0, index); + index = 0; + } + } + } + + } + + }).start(); + } + + @Override + protected void pauseImpl() { + if (!isRecording) { + return; + } + isRecording = false; + recorder.stop(); + + + fireMediaStateChange(State.Paused); + } + + @Override + public void prepare() { + + } + + @Override + public void cleanup() { + pauseImpl(); + recorder.release(); + com.codename1.media.MediaManager.releaseAudioBuffer(path); + + } + + @Override + public int getTime() { + if (isRecording) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + AudioTimestamp ts = new AudioTimestamp(); + recorder.getTimestamp(ts, AudioTimestamp.TIMEBASE_MONOTONIC); + lastTime = (int) (ts.framePosition / ((float) sampleRate / 1000f)); + } + } + return lastTime; + } + + @Override + public void setTime(int time) { + + } + + @Override + public int getDuration() { + return getTime(); + } + + @Override + public void setVolume(int vol) { + + } + + @Override + public int getVolume() { + return 0; + } + + @Override + public boolean isPlaying() { + return recorder.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING; + } + + @Override + public Component getVideoComponent() { + return null; + } + + @Override + public boolean isVideo() { + return false; + } + + @Override + public boolean isFullScreen() { + return false; + } + + @Override + public void setFullScreen(boolean fullScreen) { + + } + + @Override + public void setNativePlayerMode(boolean nativePlayer) { + + } + + @Override + public boolean isNativePlayerMode() { + return false; + } + + @Override + public void setVariable(String key, Object value) { + + } + + @Override + public Object getVariable(String key) { + return null; + } + + }; + lock.notify(); + } else { + MediaRecorder recorder = new MediaRecorder(); + recorder.setAudioSource(MediaRecorder.AudioSource.MIC); + + if(mimeType.contains("amr")){ + recorder.setOutputFormat(MediaRecorder.OutputFormat.AMR_NB); + recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB); + }else{ + recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); + recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); + recorder.setAudioSamplingRate(sampleRate); + recorder.setAudioEncodingBitRate(bitRate); + } + if (audioChannels > 0) { + recorder.setAudioChannels(audioChannels); + } + if (maxDuration > 0) { + recorder.setMaxDuration(maxDuration); + } + recorder.setOutputFile(removeFilePrefix(path)); + try { + recorder.prepare(); + record[0] = new AndroidRecorder(recorder); + } catch (IllegalStateException ex) { + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + } catch (IOException ex) { + error[0] = ex; + } finally { + lock.notify(); + } + } + + + + } + } + }); + + try { + lock.wait(); + } catch (InterruptedException ex) { + ex.printStackTrace(); + } + + if (error[0] != null) { + throw error[0]; + } + + return record[0]; + } + } + + public String [] getAvailableRecordingMimeTypes(){ + // audio/aac and audio/mp4 result in the same thing + // AAC are wrapped in an mp4 container. + return new String[]{"audio/amr", "audio/aac", "audio/mp4"}; + } + + + /** + * @inheritDoc + */ + public Object createSoftWeakRef(Object o) { + return new SoftReference(o); + } + + /** + * @inheritDoc + */ + public Object extractHardRef(Object o) { + SoftReference w = (SoftReference) o; + if (w != null) { + return w.get(); + } + return null; + } + + /** + * @inheritDoc + */ + public PeerComponent createNativePeer(Object nativeComponent) { + if (!(nativeComponent instanceof View)) { + throw new IllegalArgumentException(nativeComponent.getClass().getName()); + } + return new AndroidImplementation.AndroidPeer((View) nativeComponent); + } + + private final java.util.Map glSurfaces = + new java.util.IdentityHashMap(); + + private final com.codename1.impl.gpu.GpuImplementation gpuImpl = + new com.codename1.impl.gpu.GpuImplementation() { + @Override + public PeerComponent createPeer(final com.codename1.gpu.RenderView view) { + final CodenameOneActivity a = getActivity(); + if (a == null) { + return null; + } + // The GLSurfaceView must be constructed on the UI thread; block until + // it exists so we can wrap and return its peer to the caller. + final AndroidGLSurface[] holder = new AndroidGLSurface[1]; + final java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1); + a.runOnUiThread(new Runnable() { + public void run() { + try { + holder[0] = new AndroidGLSurface(a, view); + } catch (Throwable t) { + t.printStackTrace(); + } finally { + latch.countDown(); + } + } + }); + try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + AndroidGLSurface surface = holder[0]; + if (surface == null) { + return null; + } + PeerComponent peer = createNativePeer(surface); + if (peer != null) { + glSurfaces.put(peer, surface); + } + return peer; + } + + @Override + public void setContinuous(PeerComponent peer, final boolean continuous) { + final AndroidGLSurface surface = glSurfaces.get(peer); + if (surface == null) { + return; + } + final CodenameOneActivity a = getActivity(); + if (a == null) { + return; + } + a.runOnUiThread(new Runnable() { + public void run() { + surface.setRenderMode(continuous + ? android.opengl.GLSurfaceView.RENDERMODE_CONTINUOUSLY + : android.opengl.GLSurfaceView.RENDERMODE_WHEN_DIRTY); + } + }); + } + + @Override + public void requestRender(PeerComponent peer) { + AndroidGLSurface surface = glSurfaces.get(peer); + if (surface != null) { + surface.requestRender(); + } + } + }; + + @Override + public com.codename1.impl.gpu.GpuImplementation getGpuImplementation() { + return gpuImpl; + } + + private void blockNativeFocusAll(boolean block) { + synchronized (this.nativePeers) { + final int size = this.nativePeers.size(); + for (int i = 0; i < size; i++) { + AndroidImplementation.AndroidPeer next = (AndroidImplementation.AndroidPeer) this.nativePeers.get(i); + next.blockNativeFocus(block); + } + } + } + + public void onFocusChange(View view, boolean bln) { + + if (bln) { + /** + * whenever the base view receives focus we automatically block + * possible native subviews from gaining focus. + */ + blockNativeFocusAll(true); + if (this.lastDirectionalKeyEventReceivedByWrapper != 0) { + /** + * because we also consume any key event in the OnKeyListener of + * the native wrappers, we have to simulate key events to make + * Codename One move the focus to the next component. + */ + if (myView == null) { + return; + } + if (!myView.getAndroidView().isInTouchMode()) { + switch (lastDirectionalKeyEventReceivedByWrapper) { + case AndroidImplementation.DROID_IMPL_KEY_LEFT: + case AndroidImplementation.DROID_IMPL_KEY_RIGHT: + case AndroidImplementation.DROID_IMPL_KEY_UP: + case AndroidImplementation.DROID_IMPL_KEY_DOWN: + Display.getInstance().keyPressed(lastDirectionalKeyEventReceivedByWrapper); + Display.getInstance().keyReleased(lastDirectionalKeyEventReceivedByWrapper); + break; + default: + Log.d("Codename One", "unexpected keycode: " + lastDirectionalKeyEventReceivedByWrapper); + break; + } + } else { + Log.d("Codename One", "base view gained focus but no key event to process."); + } + lastDirectionalKeyEventReceivedByWrapper = 0; + } + } + + } + + @Override + public void edtIdle(boolean enter) { + super.edtIdle(enter); + if(enter) { + // check if we have peers waiting for resize... + if(myView instanceof AndroidAsyncView) { + ((AndroidAsyncView)myView).resizeViews(); + } + } + } + + static final Map activePeers = new HashMap(); + + + /** + * wrapper component that capsules a native view object in a Codename One + * component. this involves A LOT of back and forth between the Codename One + * EDT and the Android UI thread. + * + * + * To use it you would: + * + * 1) create your native Android view(s). Make sure to work on the Android + * UI thread when constructing and modifying them. 2) create a Codename One + * peer component by calling: + * + * com.codename1.ui.PeerComponent.create(myAndroidView); + * + * 3) currently the view's size is not automatically calculated from the + * native view. so you should set the preferred size of the Codename One + * component manually. + * + * + */ + class AndroidPeer extends PeerComponent { + + private View v; + private AndroidImplementation.AndroidRelativeLayout layoutWrapper = null; + private int currentVisible = View.INVISIBLE; + private boolean lightweightMode; + + public AndroidPeer(View vv) { + super(vv); + this.v = vv; + if(!superPeerMode) { + v.measure(MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + } + } + + @Override + protected Image generatePeerImage() { + try { + Bitmap bmp = AndroidNativeUtil.renderViewOnBitmap(v, getWidth(), getHeight()); + if(bmp == null) { + return Image.createImage(5, 5); + } + Image image = new AndroidImplementation.NativeImage(bmp); + return image; + } catch(Throwable t) { + t.printStackTrace(); + return Image.createImage(5, 5); + } + } + + protected boolean shouldRenderPeerImage() { + return !superPeerMode && (lightweightMode || !isInitialized()); + } + + protected void setLightweightMode(boolean l) { + if(superPeerMode) { + if (l != lightweightMode) { + lightweightMode = l; + if (lightweightMode) { + Image img = generatePeerImage(); + if (img != null) { + peerImage = img; + } + } + + } + return; + } + doSetVisibility(!l); + if (lightweightMode == l) { + return; + } + lightweightMode = l; + } + + @Override + public void setVisible(boolean visible) { + super.setVisible(visible); + this.doSetVisibility(visible); + } + + void doSetVisibility(final boolean visible) { + if (getActivity() == null) { + return; + } + getActivity().runOnUiThread(new Runnable() { + public void run() { + currentVisible = visible ? View.VISIBLE : View.INVISIBLE; + v.setVisibility(currentVisible); + if (visible) { + v.bringToFront(); + } + } + }); + if(visible){ + layoutPeer(); + } + } + + private void doSetVisibilityInternal(final boolean visible) { + if (getActivity() == null) { + return; + } + getActivity().runOnUiThread(new Runnable() { + public void run() { + currentVisible = visible ? View.VISIBLE : View.INVISIBLE; + v.setVisibility(currentVisible); + if (visible) { + v.bringToFront(); + } + } + }); + } + + protected void deinitialize() { + if(!superPeerMode) { + Image i = generatePeerImage(); + setPeerImage(i); + super.deinitialize(); + synchronized (nativePeers) { + nativePeers.remove(this); + } + deinit(); + }else{ + Image img = generatePeerImage(); + if (img != null) { + peerImage = img; + } + + if(myView instanceof AndroidAsyncView){ + ((AndroidAsyncView)myView).removePeerView(v); + } + super.deinitialize(); + } + } + + public void deinit(){ + if (getActivity() == null) { + return; + } + if (peerImage == null) { + peerImage = generatePeerImage(); + } + final boolean [] removed = new boolean[1]; + getActivity().runOnUiThread(new Runnable() { + public void run() { + try { + if (layoutWrapper != null && AndroidImplementation.this.relativeLayout != null) { + AndroidImplementation.this.relativeLayout.removeView(layoutWrapper); + AndroidImplementation.this.relativeLayout.requestLayout(); + layoutWrapper = null; + } + } finally { + removed[0] = true; + } + } + }); + while (!removed[0]) { + Display.getInstance().invokeAndBlock(new Runnable() { + public void run() { + if (!removed[0]) { + try { + Thread.sleep(5); + } catch(InterruptedException er) {} + } + } + }); + } + } + + protected void initComponent() { + super.initComponent(); + if(!superPeerMode) { + synchronized (nativePeers) { + nativePeers.add(this); + } + init(); + setPeerImage(null); + } + } + + public void init(){ + if(superPeerMode || getActivity() == null) { + return; + } + runOnUiThreadAndBlock(new Runnable() { + public void run() { + if (layoutWrapper == null) { + /** + * wrap the native item in a layout that we can move + * around on the surface view as we like. + */ + layoutWrapper = new AndroidImplementation.AndroidRelativeLayout(activity, AndroidImplementation.AndroidPeer.this, v); + layoutWrapper.setBackgroundDrawable(null); + v.setVisibility(currentVisible); + v.setFocusable(AndroidImplementation.AndroidPeer.this.isFocusable()); + v.setFocusableInTouchMode(true); + ArrayList viewList = new ArrayList(); + viewList.add(layoutWrapper); + v.addFocusables(viewList, View.FOCUS_DOWN); + v.addFocusables(viewList, View.FOCUS_UP); + v.addFocusables(viewList, View.FOCUS_LEFT); + v.addFocusables(viewList, View.FOCUS_RIGHT); + if (v.isFocusable() || v.isFocusableInTouchMode()) { + if (AndroidImplementation.AndroidPeer.super.hasFocus()) { + AndroidImplementation.this.blockNativeFocusAll(true); + blockNativeFocus(false); + if (!v.hasFocus()) { + v.requestFocus(); + } + + } else { + blockNativeFocus(true); + } + layoutWrapper.setOnKeyListener(new View.OnKeyListener() { + public boolean onKey(View view, int i, KeyEvent ke) { + lastDirectionalKeyEventReceivedByWrapper = CodenameOneView.internalKeyCodeTranslate(ke.getKeyCode()); + + // move focus back to base view. + if (AndroidImplementation.this.myView == null) return false; + AndroidImplementation.this.myView.getAndroidView().requestFocus(); + + /** + * if the wrapper has focus, then only because + * the wrapped native component just lost focus. + * we consume whatever key events we receive, + * just to make sure no half press/release + * sequence reaches the base view (and therefore + * Codename One). + */ + return true; + } + }); + layoutWrapper.setOnFocusChangeListener(new View.OnFocusChangeListener() { + public void onFocusChange(View view, boolean bln) { + Log.d("Codename One", "on focus change. " + view.toString() + " focus:" + bln + " touchmode: " + v.isInTouchMode()); + } + }); + layoutWrapper.setOnTouchListener(new View.OnTouchListener() { + public boolean onTouch(View v, MotionEvent me) { + if (myView == null) return false; + return myView.getAndroidView().onTouchEvent(me); + } + }); + } + if(AndroidImplementation.this.relativeLayout != null){ + // not sure why this happens but we got an exception where add view was called with + // a layout that was already added... + if(layoutWrapper.getParent() != null) { + ((ViewGroup)layoutWrapper.getParent()).removeView(layoutWrapper); + } + AndroidImplementation.this.relativeLayout.addView(layoutWrapper); + } + } + } + }); + } + private Image peerImage; + public void paint(final Graphics g) { + if(superPeerMode) { + Object nativeGraphics = com.codename1.ui.Accessor.getNativeGraphics(g); + + Object o = v.getLayoutParams(); + AndroidAsyncView.LayoutParams lp; + if(o instanceof AndroidAsyncView.LayoutParams) { + lp = (AndroidAsyncView.LayoutParams) o; + if (lp == null) { + lp = new AndroidAsyncView.LayoutParams( + getX() + g.getTranslateX(), + getY() + g.getTranslateY(), + getWidth(), + getHeight(), AndroidPeer.this); + final AndroidAsyncView.LayoutParams finalLp = lp; + activity.runOnUiThread(new Runnable() { + @Override + public void run() { + v.setLayoutParams(finalLp); + } + }); + lp.dirty = true; + } else { + int x = getX() + g.getTranslateX(); + int y = getY() + g.getTranslateY(); + int w = getWidth(); + int h = getHeight(); + if (x != lp.x || y != lp.y || w != lp.w || h != lp.h) { + lp.dirty = true; + lp.x = x; + lp.y = y; + lp.w = w; + lp.h = h; + } + } + } else { + final AndroidAsyncView.LayoutParams finalLp = new AndroidAsyncView.LayoutParams( + getX() + g.getTranslateX(), + getY() + g.getTranslateY(), + getWidth(), + getHeight(), AndroidPeer.this); + activity.runOnUiThread(new Runnable() { + @Override + public void run() { + v.setLayoutParams(finalLp); + } + }); + finalLp.dirty = true; + lp = finalLp; + } + + // this is a mutable image or side menu etc. where the peer is drawn on a different form... + // Special case... + if(nativeGraphics.getClass() == AndroidGraphics.class) { + if(peerImage == null) { + peerImage = generatePeerImage(); + } + //systemOut("Drawing native image"); + g.drawImage(peerImage, getX(), getY()); + return; + } + synchronized(activePeers) { + activePeers.put(v, this); + } + ((AndroidGraphics) nativeGraphics).drawView(v, lp); + if (lightweightMode && peerImage != null) { + g.drawImage(peerImage, getX(), getY(), getWidth(), getHeight()); + } + } else { + super.paint(g); + } + } + + boolean _initialized() { + return isInitialized(); + } + + @Override + protected void onPositionSizeChange() { + if(!superPeerMode) { + Form f = getComponentForm(); + if (v.getVisibility() == View.INVISIBLE + && f != null + && Display.getInstance().getCurrent() == f) { + doSetVisibilityInternal(true); + return; + } + layoutPeer(); + } + } + + protected void layoutPeer(){ + if (getActivity() == null) { + return; + } + if(!superPeerMode) { + // called by Codename One EDT to position the native component. + activity.runOnUiThread(new Runnable() { + public void run() { + if (layoutWrapper != null) { + if (v.getVisibility() == View.VISIBLE) { + + RelativeLayout.LayoutParams layoutParams = layoutWrapper.createMyLayoutParams( + AndroidImplementation.AndroidPeer.this.getAbsoluteX(), + AndroidImplementation.AndroidPeer.this.getAbsoluteY(), + AndroidImplementation.AndroidPeer.this.getWidth(), + AndroidImplementation.AndroidPeer.this.getHeight()); + layoutWrapper.setLayoutParams(layoutParams); + if (AndroidImplementation.this.relativeLayout != null) { + AndroidImplementation.this.relativeLayout.requestLayout(); + } + + } + } + } + }); + } + } + + void blockNativeFocus(boolean block) { + if (layoutWrapper != null) { + layoutWrapper.setDescendantFocusability(block + ? ViewGroup.FOCUS_BLOCK_DESCENDANTS : ViewGroup.FOCUS_AFTER_DESCENDANTS); + } + } + + @Override + public boolean isFocusable() { + // EDT + if (v != null) { + return v.isFocusableInTouchMode() || v.isFocusable(); + } else { + return super.isFocusable(); + } + } + + @Override + public void onSetFocusable(final boolean focusable) { + // EDT + if (getActivity() == null) { + return; + } + getActivity().runOnUiThread(new Runnable() { + public void run() { + v.setFocusable(focusable); + } + }); + } + + @Override + protected void focusGained() { + Log.d("Codename One", "native focus gain"); + // EDT + super.focusGained(); + if (getActivity() == null) { + return; + } + getActivity().runOnUiThread(new Runnable() { + public void run() { + // allow this one to gain focus + blockNativeFocus(false); + if (!v.hasFocus()) { + if (v.isInTouchMode()) { + v.requestFocusFromTouch(); + } else { + v.requestFocus(); + } + } + } + }); + } + + @Override + protected void focusLost() { + Log.d("Codename One", "native focus loss"); + // EDT + super.focusLost(); + if (layoutWrapper != null && getActivity() != null) { + getActivity().runOnUiThread(new Runnable() { + public void run() { + if(isInitialized()) { + // request focus of the wrapper. that will trigger the + // android focus listener and move focus back to the + // base view. + layoutWrapper.requestFocus(); + } + } + }); + } + } + + public void release() { + deinitialize(); + } + + @Override + protected Dimension calcPreferredSize() { + int w = 1; + int h = 1; + Drawable d = v.getBackground(); + if (d != null) { + w = d.getMinimumWidth(); + h = d.getMinimumHeight(); + } + w = Math.max(v.getMeasuredWidth(), w); + h = Math.max(v.getMeasuredHeight(), h); + if (v instanceof TextView) { + TextView tv = (TextView)v; + w = (int) android.text.Layout.getDesiredWidth(((TextView) v).getText(), ((TextView) v).getPaint()); + int heightMeasureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED); + tv.measure(w, heightMeasureSpec); + h = (int)Math.max(h, tv.getMeasuredHeight()); + + + } + return new Dimension(w, h); + } + } + + /** + * inner class that wraps the native components. this is a useful thingy to + * handle focus stuff and buffering. + */ + class AndroidRelativeLayout extends RelativeLayout { + + private AndroidImplementation.AndroidPeer peer; + + public AndroidRelativeLayout(Context activity, AndroidImplementation.AndroidPeer peer, View v) { + super(activity); + + this.peer = peer; + this.setLayoutParams(createMyLayoutParams(peer.getAbsoluteX(), peer.getAbsoluteY(), + peer.getWidth(), peer.getHeight())); + if (v.getParent() != null) { + ((ViewGroup)v.getParent()).removeView(v); + } + this.addView(v, new RelativeLayout.LayoutParams( + RelativeLayout.LayoutParams.FILL_PARENT, + RelativeLayout.LayoutParams.FILL_PARENT)); + this.setDrawingCacheEnabled(false); + this.setAlwaysDrawnWithCacheEnabled(false); + this.setFocusable(true); + this.setFocusableInTouchMode(false); + this.setDescendantFocusability(ViewGroup.FOCUS_AFTER_DESCENDANTS); + + } + + /** + * create a layout parameter object that holds the native component's + * position. + * + * @return + */ + private RelativeLayout.LayoutParams createMyLayoutParams(int x, int y, int width, int height) { + RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams( + RelativeLayout.LayoutParams.WRAP_CONTENT, + RelativeLayout.LayoutParams.WRAP_CONTENT); + layoutParams.addRule(RelativeLayout.ALIGN_PARENT_LEFT); + layoutParams.addRule(RelativeLayout.ALIGN_PARENT_TOP); + layoutParams.width = width; + layoutParams.height = height; + layoutParams.leftMargin = x; + layoutParams.topMargin = y; + return layoutParams; + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + + int keycode = event.getKeyCode(); + keycode = CodenameOneView.internalKeyCodeTranslate(keycode); + if (keycode == AndroidImplementation.DROID_IMPL_KEY_BACK) { + switch (event.getAction()) { + case KeyEvent.ACTION_DOWN: + // Claim the gesture so the activity's + // OnBackInvokedCallback stands down; on Android 16 the + // platform can deliver both for one press. See + // PredictiveBackBridge. + PredictiveBackBridge.keyEventBackStarted(); + Display.getInstance().keyPressed(keycode); + break; + case KeyEvent.ACTION_UP: + PredictiveBackBridge.keyEventBackFinished(); + Display.getInstance().keyReleased(keycode); + break; + } + return true; + } else { + return super.dispatchKeyEvent(event); + } + } + + + } + + private boolean testedNativeTheme; + private boolean nativeThemeAvailable; + + public boolean hasNativeTheme() { + if (!testedNativeTheme) { + testedNativeTheme = true; + try { + InputStream is; + if (android.os.Build.VERSION.SDK_INT < 14 && !isTablet()) { + is = getResourceAsStream(getClass(), "/androidTheme.res"); + } else { + is = getResourceAsStream(getClass(), "/android_holo_light.res"); + } + nativeThemeAvailable = is != null; + if (is != null) { + is.close(); + } + } catch (IOException ex) { + ex.printStackTrace(); + } + } + return nativeThemeAvailable; + } + + /** + * Installs the native theme, this is only applicable if hasNativeTheme() + * returned true. Notice that this method might replace the + * DefaultLookAndFeel instance and the default transitions. + */ + public void installNativeTheme() { + hasNativeTheme(); + if (!nativeThemeAvailable) { + return; + } + try { + // Resolve desired theme flavor. and.themeMode is the per-platform + // hint (auto | modern | material | hololight | legacy); the legacy + // name cn1.androidTheme is still honored for back-compat. The + // cross-platform shortcut nativeTheme=modern/legacy (deprecated + // alias: cn1.nativeTheme) feeds in when no platform-specific hint + // is set. Default stays on android_holo_light - what master + // shipped and what existing screenshot goldens are anchored + // against. The ancient pre-Holo androidTheme.res is only reached + // via explicit and.hololight=true (historical back-compat) or + // and.themeMode=legacy. + Display d = Display.getInstance(); + String mode = d.getProperty("and.themeMode", + d.getProperty("cn1.androidTheme", null)); + if (mode == null) { + String shared = d.getProperty("nativeTheme", + d.getProperty("cn1.nativeTheme", null)); + if ("modern".equalsIgnoreCase(shared)) { + mode = "material"; + } else if ("legacy".equalsIgnoreCase(shared)) { + mode = "hololight"; + } else if ("true".equalsIgnoreCase(d.getProperty("and.hololight", "false"))) { + mode = "legacy"; + } else { + mode = "hololight"; + } + } else { + mode = mode.toLowerCase(); + } + + String resPath; + if ("material".equals(mode) || "modern".equals(mode) || "auto".equals(mode)) { + resPath = "/AndroidMaterialTheme.res"; + } else if ("hololight".equals(mode) || "holo".equals(mode)) { + resPath = "/android_holo_light.res"; + } else { + resPath = "/androidTheme.res"; + } + + InputStream is = getResourceAsStream(getClass(), resPath); + if (is == null) { + // Modern theme may not be in the apk if the framework build + // skipped native-themes generation. Fall back to Holo Light + // (master's default) so the app still boots with a known look. + is = getResourceAsStream(getClass(), "/android_holo_light.res"); + } + Resources r = Resources.open(is); + Hashtable h = r.getTheme(r.getThemeResourceNames()[0]); + h.put("@commandBehavior", "Native"); + UIManager.getInstance().setThemeProps(h); + is.close(); + Display.getInstance().setCommandBehavior(Display.COMMAND_BEHAVIOR_NATIVE); + } catch (IOException ex) { + ex.printStackTrace(); + } + } + + public boolean isNativeBrowserComponentSupported() { + return true; + } + + @Override + public void setNativeBrowserScrollingEnabled(final PeerComponent browserPeer, final boolean e) { + super.setNativeBrowserScrollingEnabled(browserPeer, e); + if (getActivity() == null) { + return; + } + getActivity().runOnUiThread(new Runnable() { + public void run() { + AndroidBrowserComponent bc = (AndroidBrowserComponent)browserPeer; + bc.setScrollingEnabled(e); + } + }); + } + + @Override + public void setPinchToZoomEnabled(final PeerComponent browserPeer, final boolean e) { + super.setPinchToZoomEnabled(browserPeer, e); + if (getActivity() == null) { + return; + } + getActivity().runOnUiThread(new Runnable() { + public void run() { + AndroidBrowserComponent bc = (AndroidBrowserComponent)browserPeer; + bc.setPinchZoomEnabled(e); + } + }); + } + + public PeerComponent createBrowserComponent(final Object parent) { + if (getActivity() == null) { + return null; + } + final AndroidImplementation.AndroidBrowserComponent[] bc = new AndroidImplementation.AndroidBrowserComponent[1]; + final Throwable[] error = new Throwable[1]; + final Object lock = new Object(); + + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + + synchronized (lock) { + try { + WebView wv = new WebView(getActivity()) { + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + + int keycode = event.getKeyCode(); + keycode = CodenameOneView.internalKeyCodeTranslate(keycode); + if (keycode == AndroidImplementation.DROID_IMPL_KEY_BACK || + (keycode == KeyEvent.KEYCODE_MENU && + Display.getInstance().getCommandBehavior() != Display.COMMAND_BEHAVIOR_NATIVE)) { + boolean backKey = + keycode == AndroidImplementation.DROID_IMPL_KEY_BACK; + switch (event.getAction()) { + case KeyEvent.ACTION_DOWN: + // Claim the gesture so the + // activity's OnBackInvokedCallback + // stands down; on Android 16 the + // platform can deliver both for one + // press. See PredictiveBackBridge. + if (backKey) { + PredictiveBackBridge.keyEventBackStarted(); + } + Display.getInstance().keyPressed(keycode); + break; + case KeyEvent.ACTION_UP: + if (backKey) { + PredictiveBackBridge.keyEventBackFinished(); + } + Display.getInstance().keyReleased(keycode); + break; + } + return true; + } else { + if(Display.getInstance().getProperty( + "android.propogateKeyEvents", "false"). + equalsIgnoreCase("true") && + myView instanceof AndroidAsyncView) { + switch (event.getAction()) { + case KeyEvent.ACTION_DOWN: + Display.getInstance().keyPressed(keycode); + break; + case KeyEvent.ACTION_UP: + Display.getInstance().keyReleased(keycode); + break; + } + return true; + } + + return super.dispatchKeyEvent(event); + } + } + }; + wv.setOnTouchListener(new View.OnTouchListener() { + + @Override + public boolean onTouch(View v, MotionEvent event) { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_UP: + if (!v.hasFocus()) { + v.requestFocus(); + } + break; + } + return false; + } + }); + + if (android.os.Build.VERSION.SDK_INT >= 19) { + if ("true".equals(Display.getInstance().getProperty("android.webContentsDebuggingEnabled", "false"))) { + wv.setWebContentsDebuggingEnabled(true); + } + } + wv.getSettings().setDomStorageEnabled(true); + wv.getSettings().setAllowFileAccess(true); + wv.getSettings().setAllowContentAccess(true); + wv.requestFocus(View.FOCUS_DOWN); + wv.setFocusableInTouchMode(true); + if (android.os.Build.VERSION.SDK_INT >= 17) { + wv.getSettings().setMediaPlaybackRequiresUserGesture(false); + } + bc[0] = new AndroidImplementation.AndroidBrowserComponent(wv, getActivity(), parent); + lock.notify(); + } catch (Throwable t) { + error[0] = t; + lock.notify(); + } + } + } + }); + while (bc[0] == null && error[0] == null) { + Display.getInstance().invokeAndBlock(new Runnable() { + public void run() { + synchronized (lock) { + if (bc[0] == null && error[0] == null) { + try { + lock.wait(20); + } catch (InterruptedException ex) { + ex.printStackTrace(); + } + } + } + } + + }); + } + if (error[0] != null) { + throw new RuntimeException(error[0]); + } + return bc[0]; + } + + public void setBrowserProperty(PeerComponent browserPeer, String key, Object value) { + ((AndroidImplementation.AndroidBrowserComponent) browserPeer).setProperty(key, value); + } + + public String getBrowserTitle(PeerComponent browserPeer) { + return ((AndroidImplementation.AndroidBrowserComponent) browserPeer).getTitle(); + } + + public String getBrowserURL(PeerComponent browserPeer) { + return ((AndroidImplementation.AndroidBrowserComponent) browserPeer).getURL(); + } + + @Override + public void setBrowserURL(PeerComponent browserPeer, String url, Map headers) { + if (url.startsWith("jar:")) { + url = url.substring(6); + if(url.indexOf("/") != 0) { + url = "/"+url; + } + + url = "file:///android_asset"+url; + } + AndroidImplementation.AndroidBrowserComponent bc = (AndroidImplementation.AndroidBrowserComponent) browserPeer; + if(bc.parent.fireBrowserNavigationCallbacks(url)) { + bc.setURL(url, headers); + } + } + + @Override + public boolean isURLWithCustomHeadersSupported() { + return true; + } + + @Override + public void setBrowserURL(PeerComponent browserPeer, String url) { + setBrowserURL(browserPeer, url, null); + } + + public void browserStop(PeerComponent browserPeer) { + ((AndroidImplementation.AndroidBrowserComponent) browserPeer).stop(); + } + + public void browserDestroy(PeerComponent browserPeer) { + ((AndroidImplementation.AndroidBrowserComponent) browserPeer).destroy(); + } + + /** + * Reload the current page + * + * @param browserPeer browser instance + */ + public void browserReload(PeerComponent browserPeer) { + ((AndroidImplementation.AndroidBrowserComponent) browserPeer).reload(); + } + + /** + * Indicates whether back is currently available + * + * @param browserPeer browser instance + * @return true if back should work + */ + public boolean browserHasBack(PeerComponent browserPeer) { + return ((AndroidImplementation.AndroidBrowserComponent) browserPeer).hasBack(); + } + + public boolean browserHasForward(PeerComponent browserPeer) { + return ((AndroidImplementation.AndroidBrowserComponent) browserPeer).hasForward(); + } + + public void browserBack(PeerComponent browserPeer) { + ((AndroidImplementation.AndroidBrowserComponent) browserPeer).back(); + } + + public void browserForward(PeerComponent browserPeer) { + ((AndroidImplementation.AndroidBrowserComponent) browserPeer).forward(); + } + + public void browserClearHistory(PeerComponent browserPeer) { + ((AndroidImplementation.AndroidBrowserComponent) browserPeer).clearHistory(); + } + + public void setBrowserPage(PeerComponent browserPeer, String html, String baseUrl) { + ((AndroidImplementation.AndroidBrowserComponent) browserPeer).setPage(html, baseUrl); + } + + public void browserExposeInJavaScript(PeerComponent browserPeer, Object o, String name) { + ((AndroidImplementation.AndroidBrowserComponent) browserPeer).exposeInJavaScript(o, name); + } + + private boolean useEvaluateJavascript() { + return android.os.Build.VERSION.SDK_INT >= 19; + } + + + private int jsCallbackIndex=0; + + private void execJSUnsafe(WebView web, String js) { + if (useEvaluateJavascript()) { + web.evaluateJavascript(js, null); + } else { + web.loadUrl("javascript:(function(){"+js+"})()"); + } + } + + private void execJSSafe(final WebView web, final String js) { + if (useJSDispatchThread()) { + runOnJSDispatchThread(new Runnable() { + public void run() { + getActivity().runOnUiThread(new Runnable() { + public void run() { + execJSUnsafe(web, js); + } + }); + } + }); + } else { + getActivity().runOnUiThread(new Runnable() { + public void run() { + execJSUnsafe(web, js); + } + }); + } + } + + private void execJSUnsafe(final AndroidBrowserComponent bc, final String javaScript, final ValueCallback resultCallback) { + if (useEvaluateJavascript()) { + try { + bc.web.evaluateJavascript(javaScript, resultCallback); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + resultCallback.onReceiveValue(null); + } + } else { + jsCallbackIndex = (++jsCallbackIndex) % 1024; + int index = jsCallbackIndex; + + // The jsCallback is a special java object exposed to javascript that we use + // to return values from javascript to java. + synchronized (bc.jsCallback){ + // Initialize the return value to null + while (!bc.jsCallback.isIndexAvailable(index)) { + index++; + } + jsCallbackIndex = index+1; + } + final int fIndex = index; + // We are placing the javascript inside eval() so we need to escape + // the input. + String escaped = StringUtil.replaceAll(javaScript, "\\", "\\\\"); + escaped = StringUtil.replaceAll(escaped, "'", "\\'"); + + final String js = "javascript:(function(){" + + + "try{" + +bc.jsCallback.jsInit() + +bc.jsCallback.jsCleanup() + + AndroidBrowserComponentCallback.JS_RETURNVAL_VARNAME+"["+index+"]" + + "=eval('"+escaped +"');} catch (e){console.log(e)};" + + AndroidBrowserComponentCallback.JS_VAR_NAME+".addReturnValue(" + index+", ''+" + + + AndroidBrowserComponentCallback.JS_RETURNVAL_VARNAME+"["+index+"]" + + ");})()"; + + // Send the Javascript string via SetURL. + // NOTE!! This is sent asynchronously so we will need to wait for + // the result to come in. + bc.setURL(js, null); + if (resultCallback == null) { + return; + } + Thread t = new Thread(new Runnable() { + public void run() { + int maxTries = 500; + int tryCounter = 0; + + // If we are not on the EDT, then it is safe to just loop and wait. + while (!bc.jsCallback.isValueSet(fIndex) && tryCounter++ < maxTries) { + synchronized(bc.jsCallback){ + Util.wait(bc.jsCallback, 20); + } + } + + if (bc.jsCallback.isValueSet(fIndex)) { + String retval = bc.jsCallback.getReturnValue(fIndex); + bc.jsCallback.remove(fIndex); + resultCallback.onReceiveValue(retval != null ? JSONObject.quote(retval) : null); + + } else { + com.codename1.io.Log.e(new RuntimeException("Failed to execute javascript "+js+" after maximum wait time.")); + resultCallback.onReceiveValue(null); + } + } + }); + t.start(); + + } + } + + private void execJSSafe(final AndroidBrowserComponent bc, final String javaScript, final ValueCallback resultCallback) { + if (useJSDispatchThread()) { + runOnJSDispatchThread(new Runnable() { + public void run() { + getActivity().runOnUiThread(new Runnable() { + public void run() { + execJSUnsafe(bc, javaScript, resultCallback); + } + }); + } + }); + } else { + getActivity().runOnUiThread(new Runnable() { + public void run() { + execJSUnsafe(bc, javaScript, resultCallback); + } + }); + } + } + + + + @Override + public void browserExecute(final PeerComponent browserPeer, final String javaScript) { + final AndroidImplementation.AndroidBrowserComponent bc = (AndroidImplementation.AndroidBrowserComponent) browserPeer; + execJSSafe(bc.web, javaScript); + } + + private com.codename1.util.EasyThread jsDispatchThread; + private com.codename1.util.EasyThread jsDispatchThread() { + if (jsDispatchThread == null) { + jsDispatchThread = com.codename1.util.EasyThread.start("JS Dispatch Thread"); + } + return jsDispatchThread; + } + + private boolean useJSDispatchThread() { + + // Before version 24, we need a separate JS dispatch thread to prevent deadlocks + return true;//Build.VERSION.SDK_INT < 24; + } + + public boolean isJSDispatchThread() { + if (useJSDispatchThread()) { + return jsDispatchThread().isThisIt(); + } else { + return (Looper.getMainLooper().getThread() == Thread.currentThread()); + } + } + + public boolean runOnJSDispatchThread(Runnable r) { + if (isJSDispatchThread()) { + r.run(); + return true; + } + if (useJSDispatchThread()) { + jsDispatchThread().run(r); + } else { + getActivity().runOnUiThread(r); + } + return false; + } + + /** + * Executes javascript and returns a string result where appropriate. + * @param browserPeer + * @param javaScript + * @return + */ + @Override + public String browserExecuteAndReturnString(final PeerComponent browserPeer, final String javaScript) { + final AndroidImplementation.AndroidBrowserComponent bc = (AndroidImplementation.AndroidBrowserComponent) browserPeer; + final String[] result = new String[1]; + final boolean[] complete = new boolean[1]; + + execJSSafe(bc, javaScript, new ValueCallback() { + @Override + public void onReceiveValue(String value) { + synchronized(result) { + complete[0] = true; + result[0] = value; + result.notify(); + } + } + }); + synchronized(result) { + if (!complete[0]) { + Util.wait(result, 10000); + } + } + if (result[0] == null) { + return null; + } else { + org.json.JSONTokener tok = new org.json.JSONTokener("{\"result\":"+result[0]+"}"); + try { + JSONObject jso = new JSONObject(tok); + return jso.getString("result"); + } catch (Throwable ex) { + com.codename1.io.Log.e(ex); + return null; + } + + } + + + } + + public boolean supportsBrowserExecuteAndReturnString(PeerComponent browserPeer) { + return true; + } + + public boolean canForceOrientation() { + return true; + } + + public void lockOrientation(boolean portrait) { + if (getActivity() == null) { + return; + } + if(portrait){ + getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); + }else{ + getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); + } + } + + public void unlockOrientation() { + if (getActivity() == null) { + return; + } + getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR); + } + + + + public boolean isAffineSupported() { + return true; + } + + public void resetAffine(Object nativeGraphics) { + ((AndroidGraphics) nativeGraphics).resetAffine(); + } + + public void scale(Object nativeGraphics, float x, float y) { + ((AndroidGraphics) nativeGraphics).scale(x, y); + } + + public void rotate(Object nativeGraphics, float angle) { + ((AndroidGraphics) nativeGraphics).rotate(angle); + } + + public void rotate(Object nativeGraphics, float angle, int x, int y) { + ((AndroidGraphics) nativeGraphics).rotate(angle, x, y); + } + + @Override + public void pushClip(Object graphics) { + ((AndroidGraphics) graphics).pushClip(); + } + + @Override + public void popClip(Object graphics) { + ((AndroidGraphics) graphics).popClip(); + } + + @Override + public boolean isTranslateMatrixSupported() { + return true; + } + + @Override + public void translateMatrix(Object nativeGraphics, float x, float y) { + ((AndroidGraphics) nativeGraphics).translateMatrix(x, y); + } + + public void shear(Object nativeGraphics, float x, float y) { + } + + public boolean isTablet() { + return (getContext().getResources().getConfiguration().screenLayout + & Configuration.SCREENLAYOUT_SIZE_MASK) + >= Configuration.SCREENLAYOUT_SIZE_LARGE; + } + + // Foldable / device posture, backed by androidx.window via reflection. The androidx.window + // dependency is only present when the app opts in with the android.foldableSupport build hint; + // when absent these all degrade safely to "not foldable". The tracker is started lazily so it + // only spins up for apps that query the posture APIs. + @Override + public boolean isFoldable() { + AndroidFoldablePosture.start(getActivity()); + return AndroidFoldablePosture.isFoldable(); + } + + @Override + public int getDevicePosture() { + AndroidFoldablePosture.start(getActivity()); + return AndroidFoldablePosture.getPosture(); + } + + @Override + public int getFoldOrientation() { + AndroidFoldablePosture.start(getActivity()); + return AndroidFoldablePosture.getFoldOrientation(); + } + + @Override + public boolean isPostureSeparating() { + AndroidFoldablePosture.start(getActivity()); + return AndroidFoldablePosture.isSeparating(); + } + + @Override + public com.codename1.ui.geom.Rectangle getFoldBounds(com.codename1.ui.geom.Rectangle rect) { + AndroidFoldablePosture.start(getActivity()); + return AndroidFoldablePosture.getFoldBounds(rect); + } + + private Boolean watchCache; + + @Override + public boolean isWatch() { + if(watchCache == null) { + // PackageManager.FEATURE_WATCH ("android.hardware.type.watch") is + // the canonical Wear OS marker; use the string literal so this + // compiles regardless of the configured minimum SDK level. + watchCache = getContext().getPackageManager() + .hasSystemFeature("android.hardware.type.watch"); + } + return watchCache; + } + + private Boolean tvCache; + + @Override + public boolean isTV() { + if(tvCache == null) { + // PackageManager.FEATURE_TELEVISION ("android.hardware.type.television") + // and FEATURE_LEANBACK ("android.software.leanback") are the canonical + // Android TV / Google TV markers; use the string literals so this + // compiles regardless of the configured minimum SDK level. + android.content.pm.PackageManager pm = getContext().getPackageManager(); + boolean tv = pm.hasSystemFeature("android.hardware.type.television") + || pm.hasSystemFeature("android.software.leanback"); + if(!tv) { + // Fall back to the runtime UI mode (covers emulators/devices that + // expose the TV ui-mode without declaring the hardware feature). + android.app.UiModeManager um = (android.app.UiModeManager) + getContext().getSystemService(Context.UI_MODE_SERVICE); + tv = um != null && um.getCurrentModeType() + == Configuration.UI_MODE_TYPE_TELEVISION; + } + tvCache = tv; + } + return tvCache; + } + + @Override + public com.codename1.car.spi.CarBridge getCarBridge() { + // The Android Auto glue (injected by the builder only when the app references + // com.codename1.car) registers its bridge here; null otherwise so the API no-ops. + return AndroidCarSupport.getBridge(); + } + + @Override + public boolean isCarConnected() { + com.codename1.car.spi.CarBridge b = AndroidCarSupport.getBridge(); + return b != null && b.isConnected(); + } + + @Override + public com.codename1.wearable.spi.WearableBridge getWearableBridge() { + // The Wearable Data Layer glue is injected by the builder only when the app references + // com.codename1.wearable; without it this is null and the API no-ops. + Context ctx = getContext(); + return ctx == null ? null : AndroidWearableSupport.getBridge(ctx); + } + + private com.codename1.surfaces.spi.SurfaceBridge surfaceBridge; + + @Override + public com.codename1.surfaces.spi.SurfaceBridge getSurfaceBridge() { + if (surfaceBridge == null) { + surfaceBridge = new com.codename1.impl.android.surfaces.AndroidSurfaceBridge(); + } + return surfaceBridge; + } + + private com.codename1.documents.spi.DocumentProviderBridge documentProviderBridge; + + @Override + public com.codename1.documents.spi.DocumentProviderBridge getDocumentProviderBridge() { + if (documentProviderBridge == null) { + documentProviderBridge = + new com.codename1.impl.android.documents.AndroidDocumentProviderBridge(); + } + return documentProviderBridge; + } + + private com.codename1.continuity.spi.ContinuityBridge continuityBridge; + + /// Returns the continuity bridge, which on Android exists for one job: + /// flushing the state checkpoint when the platform says the process may + /// be killed. Neither cross-device capability exists here and both report + /// themselves unsupported. + /// + /// Synchronized for the reason the intent bridge is: two callers arriving + /// together would each construct one, and each construction registers a + /// lifecycle listener -- so the loser's listener would stay registered and + /// the app would checkpoint twice on every save. + @Override + public synchronized com.codename1.continuity.spi.ContinuityBridge getContinuityBridge() { + if (continuityBridge == null) { + continuityBridge = + new com.codename1.impl.android.continuity.AndroidContinuityBridge(); + } + return continuityBridge; + } + + private com.codename1.intents.spi.IntentBridge intentBridge; + + @Override + // Synchronized for the same reason as the JavaSE bridge: two callers arriving together + // each see a null field and each construct one, and whichever loses the assignment keeps + // the donation or the indexed entities that were recorded through it. Nothing throws. + public synchronized com.codename1.intents.spi.IntentBridge getIntentBridge() { + if (intentBridge == null) { + intentBridge = new com.codename1.impl.android.intents.AndroidIntentBridge(); + } + return intentBridge; + } + + private AndroidHomeBridge homeBridge; + + /// Returns the smart-home bridge. Always returned rather than + /// conditionally null: the bridge answers honestly through + /// {@link AndroidSmartHomeSupport}, which is empty unless the builder + /// injected a delegate, so {@code SmartHome} reports NOT_SUPPORTED + /// without this getter needing to know how the app was built. + /// + /// Note that a delegate being present does not mean the graph is + /// readable. The ordinary Android answer is + /// {@code HomeAvailability.COMMISSIONING_ONLY}: Play services can add a + /// Matter accessory with no setup at all, while reading or controlling + /// one needs the Google Home APIs and a Google Cloud project only the + /// app's developer can create. + @Override + public com.codename1.home.spi.HomeBridge getHomeBridge() { + if (homeBridge == null) { + homeBridge = new AndroidHomeBridge(); + } + return homeBridge; + } + + /// Invoked once the app has started (from the generated stub, next to + /// `deliverPendingSharedContent`) to flush surface actions that arrived through the + /// `CN1SurfaceActionActivity` trampoline before the app instance existed. + public static void deliverPendingSurfaceActions() { + com.codename1.impl.android.surfaces.AndroidSurfaceBridge.deliverPendingActions(); + } + + /// Invoked once the app has started (from the generated stub, beside + /// `deliverPendingSurfaceActions`) to run intent requests the trampoline parked rather than + /// dispatched. + /// + /// A non-headless handler is allowed to touch a `Form`, so the launcher tap can only ask for + /// the app to be brought forward; running the handler has to wait until it is. + public static void deliverPendingIntentRequests() { + // Order matters. The generated bootstrap installs the dispatcher before startContext + // has produced a bridge, so publication is deferred -- and until it happens the bridge + // never sees registerIntents, which is what judges a request the trampoline parked at a + // cold start. Draining the foreground queue alone left such a shortcut opening the app + // and running nothing. + com.codename1.intents.Intents.publishPendingDeclarations(); + com.codename1.impl.android.intents.AndroidIntentBridge.deliverPendingForegroundRequests(); + } + + /** + * Executes r on the UI thread and blocks the EDT to completion + * @param r runnable to execute + */ + public static void runOnUiThreadAndBlock(final Runnable r) { + if (getActivity() == null) { + throw new RuntimeException("Cannot run on UI thread because getActivity() is null. This generally means we are running inside a service in the background so UI access is disabled."); + } + + final boolean[] completed = new boolean[1]; + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + try { + r.run(); + } catch(Throwable t) { + com.codename1.io.Log.e(t); + } + synchronized(completed) { + completed[0] = true; + completed.notify(); + } + } + }); + Display.getInstance().invokeAndBlock(new Runnable() { + @Override + public void run() { + synchronized(completed) { + while(!completed[0]) { + try { + completed.wait(); + } catch(InterruptedException err) {} + } + } + } + }); + } + + public static void runOnUiThreadSync(final Runnable r) { + if (getActivity() == null) { + throw new RuntimeException("Cannot run on UI thread because getActivity() is null. This generally means we are running inside a service in the background so UI access is disabled."); + } + + final boolean[] completed = new boolean[1]; + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + try { + r.run(); + } catch(Throwable t) { + com.codename1.io.Log.e(t); + } + synchronized(completed) { + completed[0] = true; + completed.notify(); + } + } + }); + synchronized(completed) { + while(!completed[0]) { + try { + completed.wait(); + } catch(InterruptedException err) {} + } + } + } + + + public int convertToPixels(int dipCount, boolean horizontal) { + DisplayMetrics dm = getContext().getResources().getDisplayMetrics(); + float ppi = dm.density * 160f; + return (int) (((float) dipCount) / 25.4f * ppi); + } + + public boolean isPortrait() { + int orientation = getContext().getResources().getConfiguration().orientation; + if (orientation == Configuration.ORIENTATION_UNDEFINED + || orientation == Configuration.ORIENTATION_SQUARE) { + return super.isPortrait(); + } + return orientation == Configuration.ORIENTATION_PORTRAIT; + } + + /** + * Checks if this platform supports sharing cookies between Native components (e.g. BrowserComponent) + * and ConnectionRequests. Currently only Android and iOS ports support this. + * @return + */ + @Override + public boolean isNativeCookieSharingSupported() { + return true; + } + + @Override + public void clearNativeCookies() { + CookieManager mgr = getCookieManager(); + mgr.removeAllCookie(); + } + private static CookieManager cookieManager; + private static synchronized CookieManager getCookieManager() { + if (android.os.Build.VERSION.SDK_INT > 28) { + return CookieManager.getInstance(); + } + if (cookieManager == null) { + CookieSyncManager.createInstance(getContext()); // Fixes a crash on Android 4.3 + // https://stackoverflow.com/a/20552998/2935174 + cookieManager = CookieManager.getInstance(); + } + return CookieManager.getInstance(); + } + + @Override + public Vector getCookiesForURL(String url) { + if (isUseNativeCookieStore()) { + try { + URI uri = new URI(url); + + + CookieManager mgr = getCookieManager(); + mgr.removeExpiredCookie(); + String domain = uri.getHost(); + String cookieStr = mgr.getCookie(url); + if (cookieStr != null) { + String[] cookies = cookieStr.split(";"); + int len = cookies.length; + Vector out = new Vector(); + for (int i = 0; i < len; i++) { + Cookie c = new Cookie(); + String[] parts = cookies[i].split("="); + c.setName(parts[0].trim()); + if (parts.length > 1) { + c.setValue(parts[1].trim()); + } else { + c.setValue(""); + } + c.setDomain(domain); + out.add(c); + } + return out; + } + } catch (Exception ex) { + com.codename1.io.Log.e(ex); + } + return new Vector(); + } + return super.getCookiesForURL(url); + } + + public class WebAppInterface { + BrowserComponent bc; + /** Instantiate the interface and set the context */ + WebAppInterface(BrowserComponent bc) { + this.bc = bc; + } + + @JavascriptInterface // must be added for API 17 or higher + public boolean shouldNavigate(String url) { + return bc.fireBrowserNavigationCallbacks(url); + } + } + + class AndroidBrowserComponent extends AndroidImplementation.AndroidPeer { + + private Activity act; + private WebView web; + private BrowserComponent parent; + private boolean scrollingEnabled = true; + protected AndroidBrowserComponentCallback jsCallback; + private boolean lightweightMode = false; + private ProgressDialog progressBar; + private boolean hideProgress; + private int layerType; + + + public AndroidBrowserComponent(final WebView web, Activity act, Object p) { + super(web); + if(!superPeerMode) { + doSetVisibility(false); + } + parent = (BrowserComponent) p; + this.web = web; + layerType = web.getLayerType(); + web.getSettings().setJavaScriptEnabled(true); + web.getSettings().setSupportZoom(parent.isPinchToZoomEnabled()); + this.act = act; + jsCallback = new AndroidBrowserComponentCallback(); + hideProgress = Display.getInstance().getProperty("WebLoadingHidden", "false").equals("true"); + + web.addJavascriptInterface(jsCallback, AndroidBrowserComponentCallback.JS_VAR_NAME); + web.addJavascriptInterface(new WebAppInterface(parent), "cn1application"); + if (android.os.Build.VERSION.SDK_INT >= 21) { + CookieManager.getInstance().setAcceptThirdPartyCookies(web, true); + } + + web.setWebViewClient(new WebViewClient() { + + + + public void onLoadResource(WebView view, String url) { + if (Display.getInstance().getProperty("syncNativeCookies", "false").equals("true")) { + try { + URI uri = new URI(url); + CookieManager mgr = getCookieManager(); + mgr.removeExpiredCookie(); + String domain = uri.getHost(); + removeCookiesForDomain(domain); + String cookieStr = mgr.getCookie(url); + if (cookieStr != null) { + String[] cookies = cookieStr.split(";"); + int len = cookies.length; + ArrayList out = new ArrayList(); + for (int i = 0; i < len; i++) { + Cookie c = new Cookie(); + String[] parts = cookies[i].split("="); + c.setName(parts[0].trim()); + if (parts.length > 1) { + c.setValue(parts[1].trim()); + } else { + c.setValue(""); + } + c.setDomain(domain); + out.add(c); + } + Cookie[] cookiesArr = new Cookie[out.size()]; + out.toArray(cookiesArr); + AndroidImplementation.this.addCookie(cookiesArr, false); + } + + } catch (URISyntaxException ex) { + + } + } + parent.fireWebEvent("onLoadResource", new ActionEvent(url)); + super.onLoadResource(view, url); + setShouldCalcPreferredSize(true); + } + + @Override + public void onPageStarted(WebView view, String url, Bitmap favicon) { + if (getActivity() == null) { + return; + } + + parent.fireWebEvent("onStart", new ActionEvent(url)); + super.onPageStarted(view, url, favicon); + dismissProgress(); + //show the progress only if there is no ActionBar + if(!hideProgress && !isNativeTitle()){ + progressBar = ProgressDialog.show(getActivity(), null, "Loading..."); + //if the page hasn't finished for more the 10 sec, dismiss + //the dialog + Timer t= new Timer(); + t.schedule(new TimerTask() { + @Override + public void run() { + dismissProgress(); + } + }, 10000); + } + } + + public void onPageFinished(WebView view, String url) { + parent.fireWebEvent("onLoad", new ActionEvent(url)); + super.onPageFinished(view, url); + setShouldCalcPreferredSize(true); + dismissProgress(); + } + + private void dismissProgress() { + if (progressBar != null && progressBar.isShowing()) { + progressBar.dismiss(); + Display.getInstance().callSerially(new Runnable() { + + public void run() { + setVisible(true); + repaint(); + } + }); + } + } + + public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) { + parent.fireWebEvent("onError", new ActionEvent(description, errorCode)); + super.onReceivedError(view, errorCode, description, failingUrl); + super.shouldOverrideKeyEvent(view, null); + dismissProgress(); + } + + public boolean shouldOverrideKeyEvent(WebView view, KeyEvent event) { + int keyCode = event.getKeyCode(); + if (keyCode == KeyEvent.KEYCODE_BACK || keyCode == KeyEvent.KEYCODE_MENU) { + return true; + } + + return super.shouldOverrideKeyEvent(view, event); + } + + public boolean shouldOverrideUrlLoading(WebView view, String url) { + if (url.startsWith("jar:")) { + setURL(url, null); + return true; + } + + // this will fail if dial permission isn't declared + if(url.startsWith("tel:")) { + if(parent.fireBrowserNavigationCallbacks(url)) { + try { + Intent dialer = new Intent(android.content.Intent.ACTION_DIAL, Uri.parse(url)); + getContext().startActivity(dialer); + } catch(Throwable t) {} + } + return true; + } + // this will fail if dial permission isn't declared + if(url.startsWith("mailto:")) { + if(parent.fireBrowserNavigationCallbacks(url)) { + try { + Intent emailIntent = new Intent(Intent.ACTION_SENDTO, Uri.parse(url)); + getContext().startActivity(emailIntent); + } catch(Throwable t) {} + } + return true; + } + return !parent.fireBrowserNavigationCallbacks(url); + } + + + }); + + web.setWebChromeClient(new WebChromeClient(){ + // For 3.0+ Devices (Start) + // onActivityResult attached before constructor + protected void openFileChooser(ValueCallback uploadMsg, String acceptType) + { + mUploadMessage = uploadMsg; + Intent i = new Intent(Intent.ACTION_GET_CONTENT); + i.addCategory(Intent.CATEGORY_OPENABLE); + i.setType(acceptType); + AndroidNativeUtil.getActivity().startActivityForResult(Intent.createChooser(i, "File Browser"), FILECHOOSER_RESULTCODE); + } + + + // For Lollipop 5.0+ Devices + public boolean onShowFileChooser(WebView mWebView, ValueCallback filePathCallback, WebChromeClient.FileChooserParams fileChooserParams) + { + if (uploadMessage != null) { + uploadMessage.onReceiveValue(null); + uploadMessage = null; + } + + uploadMessage = filePathCallback; + + Intent intent = fileChooserParams.createIntent(); + try + { + AndroidNativeUtil.getActivity().startActivityForResult(intent, REQUEST_SELECT_FILE); + } catch (ActivityNotFoundException e) + { + uploadMessage = null; + Toast.makeText(getActivity().getApplicationContext(), "Cannot Open File Chooser", Toast.LENGTH_LONG).show(); + return false; + } + return true; + } + + //For Android 4.1 only + protected void openFileChooser(ValueCallback uploadMsg, String acceptType, String capture) + { + mUploadMessage = uploadMsg; + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType(acceptType); + + AndroidNativeUtil.getActivity().startActivityForResult(Intent.createChooser(intent, "File Browser"), FILECHOOSER_RESULTCODE); + } + + protected void openFileChooser(ValueCallback uploadMsg) + { + mUploadMessage = uploadMsg; + Intent i = new Intent(Intent.ACTION_GET_CONTENT); + i.addCategory(Intent.CATEGORY_OPENABLE); + i.setType("image/*"); + AndroidNativeUtil.getActivity().startActivityForResult(Intent.createChooser(i, "File Chooser"), FILECHOOSER_RESULTCODE); + } + + + @Override + public boolean onConsoleMessage(ConsoleMessage consoleMessage) { + com.codename1.io.Log.p("["+consoleMessage.messageLevel()+"] "+consoleMessage.message()+" On line "+consoleMessage.lineNumber()+" of "+consoleMessage.sourceId()); + return true; + } + + @Override + public void onProgressChanged(WebView view, int newProgress) { + parent.fireWebEvent("Progress", new ActionEvent(parent, ActionEvent.Type.Progress, newProgress)); + if(!hideProgress && isNativeTitle() && getCurrentForm() != null && getCurrentForm().getTitle() != null && getCurrentForm().getTitle().length() > 0 ){ + if(getActivity() != null){ + try{ + getActivity().setProgressBarVisibility(true); + getActivity().setProgress(newProgress * 100); + if(newProgress == 100){ + getActivity().setProgressBarVisibility(false); + } + }catch(Throwable t){ + } + } + } + } + + @Override + public void onGeolocationPermissionsShowPrompt(String origin, + GeolocationPermissions.Callback callback) { + // Always grant permission since the app itself requires location + // permission and the user has therefore already granted it + callback.invoke(origin, true, false); + } + + @Override + public void onPermissionRequest(final PermissionRequest request) { + + Log.d("Codename One", "onPermissionRequest"); + getActivity().runOnUiThread(new Runnable() { + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + @Override + public void run() { + String allowedOrigins = Display.getInstance().getProperty("android.WebView.grantPermissionsFrom", null); + if (allowedOrigins != null) { + String[] origins = Util.split(allowedOrigins, " "); + boolean allowed = false; + for (String origin : origins) { + if (request.getOrigin().toString().equals(origin)) { + allowed = true; + break; + } + } + if (allowed) { + Log.d("Codename One", "Allowing permission for "+Arrays.toString(request.getResources())+" in web view for origin "+request.getOrigin()); + request.grant(request.getResources()); + } else { + Log.d("Codename One", "Denying permission for "+Arrays.toString(request.getResources())+" in web view for origin "+request.getOrigin()); + request.deny(); + } + } + + } + }); + } + }); + } + + @Override + protected void initComponent() { + if(android.os.Build.VERSION.SDK_INT == 21 && web.getLayerType() != layerType){ + act.runOnUiThread(new Runnable() { + @Override + public void run() { + web.setLayerType(layerType, null); //setting layer type to original state + } + }); + } + super.initComponent(); + blockNativeFocus(false); + setPeerImage(null); + } + + + @Override + protected Image generatePeerImage() { + try { + final Bitmap nativeBuffer = Bitmap.createBitmap( + getWidth(), getHeight(), Bitmap.Config.ARGB_8888); + Image image = new AndroidImplementation.NativeImage(nativeBuffer); + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + try { + Canvas canvas = new Canvas(nativeBuffer); + web.draw(canvas); + } catch(Throwable t) { + t.printStackTrace(); + } + } + }); + return image; + } catch(Throwable t) { + t.printStackTrace(); + return Image.createImage(5, 5); + } + } + + protected boolean shouldRenderPeerImage() { + return lightweightMode || !isInitialized(); + } + + protected void setLightweightMode(boolean l) { + doSetVisibility(!l); + if (lightweightMode == l) { + return; + } + lightweightMode = l; + } + + + + public void setScrollingEnabled(final boolean enabled){ + this.scrollingEnabled = enabled; + act.runOnUiThread(new Runnable() { + public void run() { + web.setHorizontalScrollBarEnabled(enabled); + web.setVerticalScrollBarEnabled(enabled); + if ( !enabled ){ + web.setOnTouchListener(new View.OnTouchListener(){ + + @Override + public boolean onTouch(View view, MotionEvent me) { + return (me.getAction() == MotionEvent.ACTION_MOVE); + } + + }); + } else { + web.setOnTouchListener(null); + } + } + }); + + } + + public boolean isScrollingEnabled(){ + return scrollingEnabled; + } + + public void setProperty(final String key, final Object value) { + act.runOnUiThread(new Runnable() { + public void run() { + WebSettings s = web.getSettings(); + if(key.equalsIgnoreCase("useragent")) { + s.setUserAgentString((String)value); + return; + } + try { + s.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW); + } catch(Throwable t) { + // the method isn't available in Android 4.x + } + String methodName = "set" + key; + for (Method m : s.getClass().getMethods()) { + if (m.getName().equalsIgnoreCase(methodName) && m.getParameterTypes().length == 1) { + try { + m.invoke(s, value); + } catch (Exception ex) { + ex.printStackTrace(); + } + return; + } + } + } + }); + } + + public String getTitle() { + final String[] retVal = new String[1]; + final boolean[] complete = new boolean[1]; + act.runOnUiThread(new Runnable() { + public void run() { + try { + + retVal[0] = web.getTitle(); + } finally { + complete[0] = true; + } + } + }); + while (!complete[0]) { + Display.getInstance().invokeAndBlock(new Runnable() { + @Override + public void run() { + if (!complete[0]) { + try { + Thread.sleep(20); + } catch (InterruptedException ex) { + } + } + } + }); + } + return retVal[0]; + } + + public String getURL() { + final String[] retVal = new String[1]; + final boolean[] complete = new boolean[1]; + act.runOnUiThread(new Runnable() { + public void run() { + try { + retVal[0] = web.getUrl(); + } finally { + complete[0] = true; + } + } + }); + while (!complete[0]) { + Display.getInstance().invokeAndBlock(new Runnable() { + @Override + public void run() { + if (!complete[0]) { + try { + Thread.sleep(20); + } catch (InterruptedException ex) { + } + } + } + }); + } + return retVal[0]; + } + + public void setURL(final String url, final Map headers) { + act.runOnUiThread(new Runnable() { + public void run() { + if(headers != null) { + web.loadUrl(url, headers); + } else { + web.loadUrl(url); + } + } + }); + } + + public void reload() { + act.runOnUiThread(new Runnable() { + public void run() { + web.reload(); + } + }); + } + + public boolean hasBack() { + final Boolean [] retVal = new Boolean[1]; + final boolean[] complete = new boolean[1]; + + act.runOnUiThread(new Runnable() { + public void run() { + try { + retVal[0] = web.canGoBack(); + } finally { + complete[0] = true; + } + } + }); + while (!complete[0]) { + Display.getInstance().invokeAndBlock(new Runnable() { + @Override + public void run() { + if (!complete[0]) { + try { + Thread.sleep(20); + } catch (InterruptedException ex) { + } + } + } + }); + } + return retVal[0].booleanValue(); + } + + public boolean hasForward() { + final Boolean [] retVal = new Boolean[1]; + final boolean[] complete = new boolean[1]; + + act.runOnUiThread(new Runnable() { + public void run() { + try { + retVal[0] = web.canGoForward(); + } finally { + complete[0] = true; + } + } + }); + + while (!complete[0]) { + Display.getInstance().invokeAndBlock(new Runnable() { + @Override + public void run() { + if (!complete[0]) { + try { + Thread.sleep(20); + } catch (InterruptedException ex) { + } + } + } + }); + } + return retVal[0].booleanValue(); + } + + public void back() { + act.runOnUiThread(new Runnable() { + public void run() { + web.goBack(); + } + }); + } + + public void forward() { + act.runOnUiThread(new Runnable() { + public void run() { + web.goForward(); + } + }); + } + + public void clearHistory() { + act.runOnUiThread(new Runnable() { + public void run() { + web.clearHistory(); + } + }); + } + + public void stop() { + act.runOnUiThread(new Runnable() { + public void run() { + web.stopLoading(); + } + }); + } + + public void destroy() { + act.runOnUiThread(new Runnable() { + public void run() { + web.destroy(); + } + }); + } + + public void setPage(final String html, final String baseUrl) { + act.runOnUiThread(new Runnable() { + public void run() { + web.loadDataWithBaseURL(baseUrl, html, "text/html", "UTF-8", null); + } + }); + } + + public void exposeInJavaScript(final Object o, final String name) { + act.runOnUiThread(new Runnable() { + public void run() { + web.addJavascriptInterface(o, name); + } + }); + } + + public void setPinchZoomEnabled(final boolean e) { + act.runOnUiThread(new Runnable() { + public void run() { + web.getSettings().setSupportZoom(e); + web.getSettings().setBuiltInZoomControls(e); + } + }); + } + + @Override + protected void deinitialize() { + act.runOnUiThread(new Runnable() { + @Override + public void run() { + if(android.os.Build.VERSION.SDK_INT == 21) { // bugfix for Android 5.0.x + web.setLayerType(View.LAYER_TYPE_SOFTWARE, null); //setting layer type to software to prevent the sigseg 11 crash + } + } + }); + super.deinitialize(); + } + } + + + + public Object connect(String url, boolean read, boolean write, int timeout) throws IOException { + URL u = new URL(url); + CookieHandler.setDefault(null); + URLConnection con = u.openConnection(); + if (con instanceof HttpURLConnection) { + HttpURLConnection c = (HttpURLConnection) con; + c.setUseCaches(false); + c.setDefaultUseCaches(false); + c.setInstanceFollowRedirects(false); + if(timeout > -1) { + c.setConnectTimeout(timeout); + } + + if (android.os.Build.VERSION.SDK_INT > 13) { + c.setRequestProperty("Connection", "close"); + } + } + con.setDoInput(read); + con.setDoOutput(write); + return con; + } + + @Override + public void setReadTimeout(Object connection, int readTimeout) { + if (connection instanceof URLConnection) { + ((URLConnection)connection).setReadTimeout(readTimeout); + } + } + + + + @Override + public boolean isReadTimeoutSupported() { + return true; + } + + @Override + public void setInsecure(Object connection, boolean insecure) { + if (insecure) { + if (connection instanceof HttpsURLConnection) { + HttpsURLConnection conn = (HttpsURLConnection)connection; + try { + TrustModifier.relaxHostChecking(conn); + } catch (Exception ex) { + com.codename1.io.Log.e(ex); + } + } + } + } + + + /** + * @inheritDoc + */ + public Object connect(String url, boolean read, boolean write) throws IOException { + return connect(url, read, write, timeout); + } + + + private static final char[] HEX_CHARS = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'}; + + private static String dumpHex(byte[] data) { + final int n = data.length; + final StringBuilder sb = new StringBuilder(n * 3 - 1); + for (int i = 0; i < n; i++) { + if (i > 0) { + sb.append(' '); + } + sb.append(HEX_CHARS[(data[i] >> 4) & 0x0F]); + sb.append(HEX_CHARS[data[i] & 0x0F]); + } + return sb.toString(); + } + + @Override + public String[] getSSLCertificates(Object connection, String url) throws IOException { + if (connection instanceof HttpsURLConnection) { + HttpsURLConnection conn = (HttpsURLConnection)connection; + + try { + conn.connect(); + java.security.cert.Certificate[] certs = conn.getServerCertificates(); + String[] out = new String[certs.length * 2]; + int i=0; + for (java.security.cert.Certificate cert : certs) { + { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update(cert.getEncoded()); + out[i++] = "SHA-256:" + dumpHex(md.digest()); + } + { + MessageDigest md = MessageDigest.getInstance("SHA1"); + md.update(cert.getEncoded()); + out[i++] = "SHA1:" + dumpHex(md.digest()); + } + + } + return out; + } catch (Exception ex) { + ex.printStackTrace(); + } + } + return new String[0]; + + } + + @Override + public boolean canGetSSLCertificates() { + return true; + } + + @Override + public boolean canGetPublicKeyDigests() { + return true; + } + + @Override + public String[] getSSLCertificatesEx(Object connection, String url) throws IOException { + if (connection instanceof HttpsURLConnection) { + HttpsURLConnection conn = (HttpsURLConnection) connection; + try { + conn.connect(); + java.security.cert.Certificate[] certs = conn.getServerCertificates(); + java.util.List out = new java.util.ArrayList(); + for (int i = 0; i < certs.length; i++) { + java.security.cert.Certificate cert = certs[i]; + out.add("CHAIN:" + i); + MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + sha256.update(cert.getEncoded()); + out.add("SHA-256:" + dumpHex(sha256.digest())); + MessageDigest sha1 = MessageDigest.getInstance("SHA1"); + sha1.update(cert.getEncoded()); + out.add("SHA1:" + dumpHex(sha1.digest())); + // getPublicKey().getEncoded() is already the DER SubjectPublicKeyInfo, + // which is exactly what a public-key pin is computed over. + java.security.PublicKey pk = cert.getPublicKey(); + if (pk != null && pk.getEncoded() != null) { + MessageDigest spki = MessageDigest.getInstance("SHA-256"); + spki.update(pk.getEncoded()); + out.add("SPKI-SHA-256:" + + com.codename1.util.Base64.encodeNoNewline(spki.digest())); + } + } + return out.toArray(new String[out.size()]); + } catch (Exception ex) { + ex.printStackTrace(); + } + } + return new String[0]; + } + + /** + * @inheritDoc + */ + public void setHeader(Object connection, String key, String val) { + ((URLConnection) connection).setRequestProperty(key, val); + } + + @Override + public void setChunkedStreamingMode(Object connection, int bufferLen){ + HttpURLConnection con = ((HttpURLConnection) connection); + con.setChunkedStreamingMode(bufferLen); + } + + + + /** + * @inheritDoc + */ + public OutputStream openOutputStream(Object connection) throws IOException { + if (connection instanceof String) { + String con = (String)connection; + if (con.startsWith("file://")) { + con = con.substring(7); + } + + OutputStream fc = createFileOuputStream((String) con); + BufferedOutputStream o = new BufferedOutputStream(fc, (String) con); + return o; + } + return new BufferedOutputStream(((URLConnection) connection).getOutputStream(), connection.toString()); + } + + /** + * @inheritDoc + */ + public OutputStream openOutputStream(Object connection, int offset) throws IOException { + String con = (String) connection; + con = removeFilePrefix(con); + RandomAccessFile rf = new RandomAccessFile(con, "rw"); + rf.seek(offset); + FileOutputStream fc = new FileOutputStream(rf.getFD()); + BufferedOutputStream o = new BufferedOutputStream(fc, con); + o.setConnection(rf); + return o; + } + + /** + * @inheritDoc + */ + public void cleanup(Object o) { + try { + super.cleanup(o); + if (o != null) { + if (o instanceof RandomAccessFile) { + ((RandomAccessFile) o).close(); + } + } + } catch (Throwable ex) { + ex.printStackTrace(); + } + } + + /** + * @inheritDoc + */ + public InputStream openInputStream(Object connection) throws IOException { + if (connection instanceof String) { + String con = (String) connection; + if (con.startsWith("file://")) { + con = con.substring(7); + } + InputStream fc = createFileInputStream(con); + BufferedInputStream o = new BufferedInputStream(fc, con); + return o; + } + if(connection instanceof HttpURLConnection) { + HttpURLConnection ht = (HttpURLConnection)connection; + if(ht.getResponseCode() < 400) { + return new BufferedInputStream(ht.getInputStream()); + } + return new BufferedInputStream(ht.getErrorStream()); + } else { + return new BufferedInputStream(((URLConnection) connection).getInputStream()); + } + } + + /** + * @inheritDoc + */ + public void setHttpMethod(Object connection, String method) throws IOException { + if(method.equalsIgnoreCase("patch")) { + allowPatch((HttpURLConnection) connection); + } + ((HttpURLConnection) connection).setRequestMethod(method); + } + + // the following block is based on a few suggestions in this stack overflow + // answer https://stackoverflow.com/questions/25163131/httpurlconnection-invalid-http-method-patch + private static boolean enabledPatch; + private static boolean patchFailed; + private static void allowPatch(HttpURLConnection connection) { + if(enabledPatch) { + return; + } + if(patchFailed) { + connection.setRequestProperty("X-HTTP-Method-Override", "PATCH"); + return; + } + try { + Field methodsField = HttpURLConnection.class.getDeclaredField("methods"); + + Field modifiersField = Field.class.getDeclaredField("modifiers"); + modifiersField.setAccessible(true); + modifiersField.setInt(methodsField, methodsField.getModifiers() & ~Modifier.FINAL); + + methodsField.setAccessible(true); + + String[] oldMethods = (String[]) methodsField.get(null); + Set methodsSet = new LinkedHashSet(Arrays.asList(oldMethods)); + methodsSet.addAll(Arrays.asList("PATCH")); + String[] newMethods = methodsSet.toArray(new String[0]); + + methodsField.set(null/*static field*/, newMethods); + enabledPatch = true; + } catch (NoSuchFieldException e) { + patchFailed = true; + connection.setRequestProperty("X-HTTP-Method-Override", "PATCH"); + } catch(IllegalAccessException ee) { + patchFailed = true; + connection.setRequestProperty("X-HTTP-Method-Override", "PATCH"); + } + } + + /** + * @inheritDoc + */ + public void setPostRequest(Object connection, boolean p) { + try { + if (p) { + ((HttpURLConnection) connection).setRequestMethod("POST"); + } else { + ((HttpURLConnection) connection).setRequestMethod("GET"); + } + } catch (IOException err) { + // an exception here doesn't make sense + err.printStackTrace(); + } + } + + /** + * @inheritDoc + */ + public int getResponseCode(Object connection) throws IOException { + // workaround for Android bug discussed here: http://stackoverflow.com/questions/17638398/androids-httpurlconnection-throws-eofexception-on-head-requests + HttpURLConnection con = (HttpURLConnection) connection; + if("head".equalsIgnoreCase(con.getRequestMethod())) { + con.setDoOutput(false); + con.setRequestProperty( "Accept-Encoding", "" ); + } + return ((HttpURLConnection) connection).getResponseCode(); + } + + /** + * @inheritDoc + */ + public String getResponseMessage(Object connection) throws IOException { + return ((HttpURLConnection) connection).getResponseMessage(); + } + + /** + * @inheritDoc + */ + public int getContentLength(Object connection) { + return ((HttpURLConnection) connection).getContentLength(); + } + + /** + * @inheritDoc + */ + public String getHeaderField(String name, Object connection) throws IOException { + return ((HttpURLConnection) connection).getHeaderField(name); + } + + /** + * @inheritDoc + */ + public String[] getHeaderFieldNames(Object connection) throws IOException { + Set s = ((HttpURLConnection) connection).getHeaderFields().keySet(); + String[] resp = new String[s.size()]; + s.toArray(resp); + return resp; + } + + /** + * @inheritDoc + */ + public String[] getHeaderFields(String name, Object connection) throws IOException { + HttpURLConnection c = (HttpURLConnection) connection; + List headers = new ArrayList(); + + // we need to merge headers with differing case since this should be case insensitive + for(String key : c.getHeaderFields().keySet()) { + if(key != null && key.equalsIgnoreCase(name)) { + headers.addAll(c.getHeaderFields().get(key)); + } + } + if (headers.size() > 0) { + List v = new ArrayList(); + v.addAll(headers); + Collections.reverse(v); + String[] s = new String[v.size()]; + v.toArray(s); + return s; + } + // workaround for a bug in some android devices + String f = c.getHeaderField(name); + if(f != null && f.length() > 0) { + return new String[] {f}; + } + return null; + + + + } + + /** + * Directory holding storage writes still in progress. + * + *

A sibling of the files dir rather than something inside it. Every name is a + * legal storage key, so no name reserved inside that namespace can be kept clear + * of the application: a key called after the scratch area would either be + * unstorable or, if it already existed as a file, would stop the directory being + * created and fail every write from then on. Outside the namespace there is + * nothing to collide with. It stays on the same filesystem as the entries, which + * is what lets a write be published by renaming.

+ */ + private static final String STORAGE_SCRATCH_DIR = "cn1-storage-scratch"; + + /** + * Suffix of the file each process locks for as long as it is running, so that the + * others can tell whether the writes it left behind are still being written. + * + *

This replaces judging a scratch file by its age. An application may run more + * than one process, each with its own copy of this class and so its own idea of + * what is open, and age was the only thing they all agreed on -- but + * {@code lastModified} is a wall clock reading, and a clock that jumps forward + * makes a file being written this moment look arbitrarily old. A lock says + * whether the writer is there, and the system drops it when a process ends + * however it ends, so it cannot outlive the process it stands for.

+ */ + private static final String STORAGE_LIVE_SUFFIX = ".live"; + + /** + * How long to leave between sweeps. A rate limit rather than a judgement about + * any file, measured on the monotonic clock so that setting the wall clock cannot + * disturb it. + */ + private static final long STORAGE_SWEEP_INTERVAL = 5L * 60L * 1000L; + + /** + * Distinguishes the scratch files of concurrent writes. Paired with the process + * id, since a second process counts from the beginning as well. + */ + private static final AtomicLong storageScratchCounter = new AtomicLong(); + + /** + * Guards the instant at which a write is published or abandoned, and the set of + * writes that are still open. Deleting an entry and publishing one have to take + * turns: otherwise a write that renames its scratch file just after another + * thread deleted the entry brings the deleted entry back. + */ + private static final Object storagePublishLock = new Object(); + + /** + * Name of the file whose lock serializes storage writes between processes. + */ + private static final String STORAGE_LOCK_FILE = ".lock"; + + /** + * The cross process lock, and the handle it is taken on, while this process holds + * it. Guarded by {@link #storagePublishLock}, so only one thread here ever has it. + */ + private static RandomAccessFile storageLockHandle; + private static FileLock storageLockAcrossProcesses; + + /** + * The lock this process holds for as long as it runs, saying that the scratch + * files bearing its process id are still being written. Never released: the + * system takes it back when the process ends. + */ + private static RandomAccessFile storageLiveHandle; + private static FileLock storageLiveLock; + + /** + * How many nested claims this process has on the cross process lock. A + * {@code FileLock} is held by the whole VM and cannot be taken twice, and + * clearStorage claims it and then calls deleteStorageFile for every entry. + */ + private static int storageLockDepth; + + /** + * Claims the storage for this process, so that creating a scratch file, deleting + * an entry and publishing a write cannot interleave between processes. + * + *

Unlinking a writer's scratch file is what cancels it, and that only reaches + * the writes that exist when the deletion looks. Without this a second process + * could create its scratch file just after a deletion had scanned for them, and + * publish over the entry that deletion went on to remove. A lock the filesystem + * arbitrates is the only thing both processes can see; the system drops it when a + * process ends however it ends, so it cannot be left held by a crash.

+ * + *

Best effort: if the lock cannot be taken the work still goes ahead, since a + * storage that stops writing would be worse than one exposed to a race that only + * an application with more than one process can reach at all.

+ * + *

The caller must hold {@link #storagePublishLock}.

+ */ + private static void lockStorageAcrossProcesses() { + if (storageLockDepth == 0) { + try { + File dir = storageScratchDir(); + if (dir.isDirectory() || dir.mkdirs() || dir.isDirectory()) { + // kept before the lock is attempted rather than after it succeeds, + // so that a lock which throws still leaves releaseStorageLock + // something to close. Otherwise a filesystem that refuses to lock + // leaks a descriptor on every storage operation until unrelated + // files stop opening. + storageLockHandle = + new RandomAccessFile(new File(dir, STORAGE_LOCK_FILE), "rw"); + storageLockAcrossProcesses = storageLockHandle.getChannel().lock(); + } + } catch (Throwable t) { + // android's log, not ours: the default log writer is a storage stream, + // so reporting this through it would come back through here with the + // depth still at zero and fail the same way, again and again + Log.e("CodenameOne", "Could not lock the storage", t); + releaseStorageLock(); + } + } + storageLockDepth++; + } + + /** + * Gives up this process's claim on the storage. + * + *

The caller must hold {@link #storagePublishLock}.

+ */ + private static void unlockStorageAcrossProcesses() { + storageLockDepth--; + if (storageLockDepth == 0) { + releaseStorageLock(); + } + } + + /** + * Drops the cross process lock and the handle it was taken on, whichever of them + * this process actually got. + */ + private static void releaseStorageLock() { + try { + if (storageLockAcrossProcesses != null) { + storageLockAcrossProcesses.release(); + } + } catch (Throwable t) { + Log.e("CodenameOne", "Could not release the storage lock", t); + } + storageLockAcrossProcesses = null; + try { + if (storageLockHandle != null) { + storageLockHandle.close(); + } + } catch (Throwable t) { + Log.e("CodenameOne", "Could not close the storage lock", t); + } + storageLockHandle = null; + } + + /** + * The writes that are currently open, so that deleting an entry can cancel them. + * Guarded by {@link #storagePublishLock}. + */ + private static final List openStorageWrites = + new ArrayList(); + + /** + * When the scratch area is next worth looking at, on the monotonic clock. Keeps + * the sweep from running on every write without ever being the thing that decides + * whether a file is abandoned. Guarded by {@link #storagePublishLock}. + */ + private static long nextStorageScratchSweep; + + /** + * @inheritDoc + */ + public void deleteStorageFile(String name) { + synchronized (storagePublishLock) { + lockStorageAcrossProcesses(); + try { + // cancelled before the entry goes, and under the same lock the + // publishing rename takes, so a write that is already mid close + // cannot put the entry back afterwards. + for (int iter = 0; iter < openStorageWrites.size(); iter++) { + openStorageWrites.get(iter).cancel(name); + } + // the same for writes in another process, which the monitor above + // knows nothing about. Unlinking a scratch file cancels it: the + // writer keeps a working descriptor on an inode with no name, exactly + // as it used to keep one on an entry deleted underneath it, and the + // rename that would have published it can no longer find anything to + // rename. Scratch files go first, so a publish that slips through + // between the two still leaves an entry for the delete to remove. + discardScratchFilesFor(name); + getContext().deleteFile(name); + } finally { + unlockStorageAcrossProcesses(); + } + } + } + + /** + * Unlinks every scratch file being written for the given entry, in this process + * or any other, which is what cancels those writes. + * + * @param name the storage entry + */ + private static void discardScratchFilesFor(String name) { + try { + String prefix = storageScratchPrefix(name); + File[] scratch = storageScratchDir().listFiles(); + if (scratch == null) { + return; + } + for (int iter = 0; iter < scratch.length; iter++) { + if (scratch[iter].getName().startsWith(prefix) && !scratch[iter].delete()) { + com.codename1.io.Log.p("Could not cancel the storage write " + + scratch[iter]); + } + } + } catch (IOException err) { + com.codename1.io.Log.e(err); + } + } + + /** + * @inheritDoc + */ + public void clearStorage() { + synchronized (storagePublishLock) { + // every open write, not just the ones for entries that exist. A write to + // an entry that is not there yet is absent from listStorageEntries, so the + // inherited implementation never reaches it, and it would publish a new + // entry moments after the storage was supposedly emptied. + lockStorageAcrossProcesses(); + try { + for (int iter = 0; iter < openStorageWrites.size(); iter++) { + openStorageWrites.get(iter).cancel(); + } + discardAllScratchFiles(); + super.clearStorage(); + } finally { + unlockStorageAcrossProcesses(); + } + } + } + + /** + * @inheritDoc + */ + public boolean abandonStorageWrite(String name, OutputStream writing) { + // this write and no other. Every write to the entry used to be given up + // together, so a second thread writing the same entry had its value quietly + // discarded and was told the write had succeeded. + if (writing instanceof StorageOutputStream) { + synchronized (storagePublishLock) { + ((StorageOutputStream) writing).cancel(); + } + // such a write leaves the entry untouched until it is published, so + // whatever was stored is still there + return true; + } + // a stream that never opened cannot have touched anything either. Anything + // else wrote into the entry itself and the caller has to clear up after it. + return writing == null; + } + + /** + * @inheritDoc + * + *

Writes into the entry, as it always has. A caller may hold this open and + * expect what it flushes to be readable meanwhile -- the log writer keeps one for + * the life of the application and sendLog reads the entry behind its back -- so + * an entry that appeared only on close would leave the log unreadable and lose + * everything written since the process started. What can be given here without + * changing when the entry appears is the flush that Android does not do on + * close.

+ */ + public OutputStream createStorageOutputStream(String name) throws IOException { + return new SyncingStorageOutputStream(getContext().openFileOutput(name, 0)); + } + + /** + * @inheritDoc + */ + public OutputStream createStorageOutputStream(String name, boolean replaceWhenClosed) + throws IOException { + if (!replaceWhenClosed) { + return createStorageOutputStream(name); + } + sweepStorageScratchFiles(); + return new StorageOutputStream(name); + } + + /** + * Forces a stream onto the device as it closes, which Android does not do by + * itself, without changing anything about when what is written becomes visible. + */ + private static final class SyncingStorageOutputStream extends OutputStream { + private final FileOutputStream out; + private boolean closed; + + SyncingStorageOutputStream(FileOutputStream out) { + this.out = out; + } + + @Override + public void write(int b) throws IOException { + out.write(b); + } + + @Override + public void write(byte[] b) throws IOException { + out.write(b); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + out.write(b, off, len); + } + + @Override + public void flush() throws IOException { + out.flush(); + } + + @Override + public void close() throws IOException { + if (closed) { + return; + } + closed = true; + try { + out.flush(); + out.getFD().sync(); + } finally { + out.close(); + } + } + } + + /** + * @inheritDoc + */ + public InputStream createStorageInputStream(String name) throws IOException { + return getContext().openFileInput(name); + } + + /** + * @inheritDoc + */ + public boolean storageFileExists(String name) { + String[] fileList = getContext().fileList(); + for (int iter = 0; iter < fileList.length; iter++) { + if (fileList[iter].equals(name)) { + return true; + } + } + return false; + } + + /** + * @inheritDoc + */ + public String[] listStorageEntries() { + return getContext().fileList(); + } + + /** + * @inheritDoc + */ + public int getStorageEntrySize(String name) { + return (int)new File(getContext().getFilesDir(), name).length(); + } + + /** + * Removes the scratch files left behind by a run that died mid write, once they + * are old enough that nothing can still be writing them. + */ + private void sweepStorageScratchFiles() { + synchronized (storagePublishLock) { + long now = android.os.SystemClock.elapsedRealtime(); + if (now < nextStorageScratchSweep) { + return; + } + nextStorageScratchSweep = now + STORAGE_SWEEP_INTERVAL; + // under the lock the other processes take to start a write or to say they + // are running. Finding an owner gone and then deleting its files are two + // steps, and a process id is handed out again the moment its holder is + // gone: without this a process could be given the id just examined, say so + // and start writing, and have this sweep delete the write it had only just + // begun -- or the very file it had said it was alive with, after which + // every later sweep would take it for gone. + lockStorageAcrossProcesses(); + try { + File dir = storageScratchDir(); + File[] files = dir.listFiles(); + if (files == null) { + return; + } + int mine = android.os.Process.myPid(); + for (int iter = 0; iter < files.length; iter++) { + if (isStorageLockFile(files[iter])) { + continue; + } + int owner = storageScratchOwner(files[iter].getName()); + // this process knows what it is doing without asking, and never + // tries to lock its own liveness file, which it already holds + if (owner < 0 || owner == mine || isProcessWriting(dir, owner)) { + continue; + } + if (!files[iter].delete()) { + com.codename1.io.Log.p("Could not remove the abandoned storage " + + "scratch file " + files[iter]); + } + } + } catch (Throwable t) { + // a sweep that fails costs disk space, never correctness + com.codename1.io.Log.e(t); + } finally { + unlockStorageAcrossProcesses(); + } + } + } + + /** + * The process a file in the scratch directory belongs to. + * + * @param fileName the name of the file + * @return the process id, or -1 if the name does not carry one + */ + private static int storageScratchOwner(String fileName) { + String pid; + if (fileName.endsWith(STORAGE_LIVE_SUFFIX)) { + pid = fileName.substring(0, fileName.length() - STORAGE_LIVE_SUFFIX.length()); + } else { + int digest = fileName.indexOf('-'); + int counter = digest < 0 ? -1 : fileName.indexOf('-', digest + 1); + if (counter < 0) { + return -1; + } + pid = fileName.substring(digest + 1, counter); + } + try { + return Integer.parseInt(pid); + } catch (NumberFormatException err) { + return -1; + } + } + + /** + * Whether the given process is still running, and so may still be writing the + * scratch files that carry its id. + * + *

Asked of the filesystem rather than of {@code /proc}, which since Android 9 + * shows a process only itself. A lock that can be taken is one nobody is holding. + * Anything unexpected counts as running, since deleting another process's work on + * a guess is the one outcome worth avoiding here.

+ * + * @param dir the scratch directory + * @param pid the process to ask about + * @return true if that process appears to be running + */ + private static boolean isProcessWriting(File dir, int pid) { + File live = new File(dir, pid + STORAGE_LIVE_SUFFIX); + if (!live.exists()) { + return false; + } + RandomAccessFile handle = null; + FileLock held = null; + try { + handle = new RandomAccessFile(live, "rw"); + held = handle.getChannel().tryLock(); + return held == null; + } catch (Throwable t) { + return true; + } finally { + try { + if (held != null) { + held.release(); + } + if (handle != null) { + handle.close(); + } + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + } + + /** + * Says, for as long as this process runs, that the scratch files carrying its + * process id are still being written. + * + * @param dir the scratch directory + */ + private static void claimStorageLiveness(File dir) { + synchronized (storagePublishLock) { + if (storageLiveLock != null) { + return; + } + // under the same lock the sweep takes, so that saying this process is + // running and clearing what the last holder of its id left behind cannot + // land in the middle of another process deciding that id is gone + lockStorageAcrossProcesses(); + try { + try { + storageLiveHandle = new RandomAccessFile( + new File(dir, android.os.Process.myPid() + STORAGE_LIVE_SUFFIX), "rw"); + storageLiveLock = storageLiveHandle.getChannel().lock(); + } catch (Throwable t) { + // android's log for the same reason as above + Log.e("CodenameOne", "Could not claim the storage liveness file", t); + try { + if (storageLiveHandle != null) { + storageLiveHandle.close(); + } + } catch (Throwable ignored) { + Log.e("CodenameOne", "Could not close the liveness file", ignored); + } + // the lock as well as the handle: closing the handle gives up the + // lock, and a lock this process still believed it held is one it + // would never take again, which leaves every other process reading + // it as gone and free to delete the writes it has in flight + storageLiveHandle = null; + storageLiveLock = null; + return; + } + try { + discardEarlierIncarnation(dir); + } catch (Throwable t) { + // separately, because the claim above has already succeeded and + // clearing up after whoever held this id last is not worth giving + // it up for. The leftovers keep until a later sweep. + Log.e("CodenameOne", "Could not clear the earlier incarnation", t); + } + } finally { + unlockStorageAcrossProcesses(); + } + } + } + + /** + * Unlinks every scratch file there is, cancelling every write in progress in any + * process. + */ + private static void discardAllScratchFiles() { + try { + File[] scratch = storageScratchDir().listFiles(); + if (scratch == null) { + return; + } + for (int iter = 0; iter < scratch.length; iter++) { + if (!isStorageMarkerFile(scratch[iter]) && !scratch[iter].delete()) { + com.codename1.io.Log.p("Could not cancel the storage write " + + scratch[iter]); + } + } + } catch (IOException err) { + com.codename1.io.Log.e(err); + } + } + + /** + * Whether the given file is the one whose lock serializes the processes, rather + * than a write in progress. + * + *

It has to survive both the clear and the sweep. Linux lets a locked file be + * unlinked, and the lock goes with the inode rather than the name, so a process + * that removed it while holding it would leave the next process free to create + * the name afresh and take a lock on a different inode: both would then hold + * "the" lock and neither would wait for the other. Nothing writes to it either, + * so its age says nothing about whether it is in use.

+ * + * @param file a file in the scratch directory + * @return true if the file is the lock + */ + private static boolean isStorageLockFile(File file) { + return STORAGE_LOCK_FILE.equals(file.getName()); + } + + /** + * Removes whatever a previous process left behind under this process's id. + * + *

Android hands out a process id again once the process holding it is gone, so + * after a crash or a reboot the files an earlier incarnation abandoned can be + * sitting under the id this one has just been given. The sweep passes over + * anything bearing its own id, on the grounds that a process knows its own work, + * which would leave those files where they are for good.

+ * + *

Usually this runs before the first write, when the process owns nothing and + * everything under its id must belong to the incarnation before it. That is not + * guaranteed: a claim that fails is retried by the next write, by which time this + * process may have writes of its own open. Those are known exactly and are left + * alone -- deleting one would fail a write that had already been serialized.

+ * + *

The caller must hold {@link #storagePublishLock}.

+ * + * @param dir the scratch directory + */ + private static void discardEarlierIncarnation(File dir) { + File[] files = dir.listFiles(); + if (files == null) { + return; + } + int mine = android.os.Process.myPid(); + for (int iter = 0; iter < files.length; iter++) { + if (!isStorageMarkerFile(files[iter]) + && storageScratchOwner(files[iter].getName()) == mine + && !isOpenStorageWrite(files[iter]) + && !files[iter].delete()) { + com.codename1.io.Log.p("Could not remove the abandoned storage scratch " + + "file " + files[iter]); + } + } + } + + /** + * Whether the given scratch file belongs to a write this process has open. + * + *

The caller must hold {@link #storagePublishLock}.

+ * + * @param file a file in the scratch directory + * @return true if a write in this process is using it + */ + private static boolean isOpenStorageWrite(File file) { + for (int iter = 0; iter < openStorageWrites.size(); iter++) { + if (openStorageWrites.get(iter).scratch.equals(file)) { + return true; + } + } + return false; + } + + /** + * Whether the given file is one of the markers the processes keep about + * themselves, rather than a write in progress. + * + *

Clearing the storage throws away the writes, and nothing else. A process + * whose liveness file was taken from underneath it goes on holding the lock, so + * it never notices and never makes the name again, and from then on every other + * process reads it as gone and feels free to delete the writes it has in flight. + * The sweep is the one place a liveness file is removed, and only once its owner + * is known to be gone.

+ * + * @param file a file in the scratch directory + * @return true if the file is a marker rather than a pending write + */ + private static boolean isStorageMarkerFile(File file) { + return isStorageLockFile(file) || file.getName().endsWith(STORAGE_LIVE_SUFFIX); + } + + /** + * The start of the name of every scratch file for the given entry. + * + *

A digest rather than the entry itself: an entry name may be as long as the + * filesystem allows on its own, so anything built by appending to one would be + * refused. Fixed width, and specific enough that one entry's deletion does not + * cancel another's write.

+ * + * @param name the storage entry + * @return the prefix shared by that entry's scratch files + * @throws IOException if the digest is unavailable + */ + private static String storageScratchPrefix(String name) throws IOException { + try { + byte[] digest = java.security.MessageDigest.getInstance("SHA-256") + .digest(name.getBytes("UTF-8")); + StringBuilder b = new StringBuilder(digest.length * 2); + for (int iter = 0; iter < digest.length; iter++) { + b.append(Character.forDigit((digest[iter] >> 4) & 0xf, 16)); + b.append(Character.forDigit(digest[iter] & 0xf, 16)); + } + return b.append('-').toString(); + } catch (java.security.NoSuchAlgorithmException err) { + throw new IOException("No SHA-256 to name storage scratch files with", err); + } + } + + /** + * Resolves a storage entry to its file, refusing anything that would land outside + * the storage directory. + * + *

{@code openFileOutput} used to make this check on our behalf and reject any + * name holding a path separator. Publishing by rename does not: with name + * normalization turned off a key like {@code ../shared_prefs/settings.xml} + * reaches here as it was written, and {@code File} resolves it, which would put + * the rename anywhere in the application's private data and leave behind an entry + * that Storage itself could no longer read or delete.

+ * + * @param name the storage entry + * @return the file the entry is stored in + * @throws IOException if the name does not name an entry in the storage directory + */ + private static File storageEntryFile(String name) throws IOException { + File dir = getContext().getFilesDir(); + if (name.indexOf('/') >= 0 || name.indexOf(File.separatorChar) >= 0) { + throw new IOException("Storage entry " + name + " contains a path separator"); + } + File entry = new File(dir, name); + if (!dir.equals(entry.getParentFile())) { + throw new IOException("Storage entry " + name + " resolves outside " + dir); + } + return entry; + } + + /** + * The directory holding the writes that are in progress. + * + * @return the scratch directory, which is not guaranteed to exist yet + * @throws IOException if the application has no data directory to put it in + */ + private static File storageScratchDir() throws IOException { + File files = getContext().getFilesDir(); + File data = files.getParentFile(); + if (data == null) { + throw new IOException("No application data directory above " + files); + } + return new File(data, STORAGE_SCRATCH_DIR); + } + + /** + * Writes a storage entry to a scratch file, forces the bytes onto the device and + * only then renames that file over the entry. + * + *

{@code openFileOutput} truncates the entry as it opens it, and Android does + * not flush a file on close. Writing the entry in place therefore left a window + * on every single write in which the entry was empty or half written on disk, and + * left the bytes of a completed write sitting in the page cache for as long as + * the kernel felt like holding them. An abrupt end to the process or to the + * device inside either window -- a low memory kill, a force stop, a battery pull, + * a panic -- lost the entry, and on a filesystem that journals the truncation + * ahead of the data it came back as a zero length file. How wide those windows + * are is a property of the filesystem and of how eagerly the vendor kills + * background processes, which is why this only ever showed up on some devices.

+ * + *

The entry now changes in a single rename, which the filesystem cannot show + * half done, and the bytes reach the device before that rename is made.

+ */ + private static final class StorageOutputStream extends OutputStream { + private final String name; + private final File target; + private final File scratch; + private final FileOutputStream out; + private boolean closed; + private boolean cancelled; + + StorageOutputStream(String name) throws IOException { + this.name = name; + this.target = storageEntryFile(name); + File dir = storageScratchDir(); + if (!dir.isDirectory() && !dir.mkdirs() && !dir.isDirectory()) { + throw new IOException("Could not create the storage scratch directory " + + dir); + } + // the write goes ahead whether or not that succeeded. A claim can only + // fail where the filesystem will not lock, and refusing to write would + // turn that into an application that cannot store anything -- far worse + // than what it costs, which is that another process sweeping at that + // moment may take this write for abandoned and unlink it. That fails the + // write, honestly, and leaves what was already stored where it is; the + // next write claims again. Same trade the cross process lock makes. + claimStorageLiveness(dir); + // the digest of the entry lets another process find and cancel this write. + // The process id separates concurrent processes, whose counters both start + // from the beginning, and the counter separates writes within one. + this.scratch = new File(dir, storageScratchPrefix(name) + + android.os.Process.myPid() + "-" + + storageScratchCounter.incrementAndGet()); + // created and registered as one step under the lock a deletion takes. + // Registering afterwards would leave a write whose scratch file already + // exists but which a concurrent deleteStorageFile cannot see to cancel, + // and that write would rename itself over the entry that was deleted. + synchronized (storagePublishLock) { + lockStorageAcrossProcesses(); + try { + this.out = new FileOutputStream(scratch); + openStorageWrites.add(this); + } finally { + unlockStorageAcrossProcesses(); + } + } + } + + /** + * Marks this write as one that must not be published, whatever entry it is + * for. Called holding {@link #storagePublishLock}. + */ + void cancel() { + cancelled = true; + } + + /** + * Marks this write as one that must not be published, because the entry it + * would publish over has been deleted since it opened. Called holding + * {@link #storagePublishLock}. + * + * @param entry the entry being deleted + */ + void cancel(String entry) { + if (name.equals(entry)) { + cancelled = true; + } + } + + @Override + public void write(int b) throws IOException { + out.write(b); + } + + @Override + public void write(byte[] b) throws IOException { + out.write(b); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + out.write(b, off, len); + } + + @Override + public void flush() throws IOException { + out.flush(); + } + + @Override + public void close() throws IOException { + if (closed) { + return; + } + closed = true; + try { + try { + out.flush(); + out.getFD().sync(); + } finally { + out.close(); + } + publish(); + } finally { + synchronized (storagePublishLock) { + openStorageWrites.remove(this); + } + if (scratch.exists() && !scratch.delete()) { + com.codename1.io.Log.p("Could not remove the storage scratch file " + + scratch); + } + } + } + + /** + * Renames the scratch file over the entry, which is the point at which the + * write becomes visible. + * + * @throws IOException if the entry could not be replaced, so that the caller + * that wrote it hears about it rather than being told the write succeeded + */ + private void publish() throws IOException { + synchronized (storagePublishLock) { + lockStorageAcrossProcesses(); + try { + // the one case where not publishing is not a failure: this + // process cancelled the write itself, so the caller either asked + // for the entry to go or is already abandoning the write. Failing + // here would only log noise over an outcome that is already known. + if (cancelled) { + return; + } + if (scratch.renameTo(target)) { + syncStorageDirectory(target.getParentFile()); + return; + } + // A missing scratch file is not reported as a success. Another + // process unlinking it does mean this entry was deleted, and + // failing here reaches the same place -- writeObject deletes the + // entry on a failed write -- while still telling the caller that + // what it wrote did not land. Anything else that removed the file + // gets the same honest answer, where calling it a success would + // leave the caller believing in a value the storage never took. + throw new IOException("Could not store " + name); + } finally { + unlockStorageAcrossProcesses(); + } + } + } + } + + /** + * Forces a rename in the given directory onto the device, so that a completed + * write does not fall back to its previous contents after an abrupt shutdown. + * Best effort: without it a crash can still only cost the newest write, never the + * integrity of an entry. + * + * @param dir the directory holding the storage entries + */ + private static void syncStorageDirectory(File dir) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + return; + } + try { + DirectorySync.sync(dir); + } catch (Throwable t) { + // some filesystems refuse to sync a directory handle + } + } + + /** + * Isolates the API 21 syscalls, so that verifying {@code AndroidImplementation} + * on an older device never has to resolve them. + */ + private static final class DirectorySync { + private DirectorySync() { + } + + static void sync(File dir) throws android.system.ErrnoException { + java.io.FileDescriptor fd = android.system.Os.open(dir.getPath(), + android.system.OsConstants.O_RDONLY, 0); + try { + android.system.Os.fsync(fd); + } finally { + android.system.Os.close(fd); + } + } + } + + private String addFile(String s) { + // I explicitly don't create a "proper URL" since code might rely on the fact that the file isn't encoded + if(s != null && s.startsWith("/")) { + return "file://" + s; + } + return s; + } + + /** + * @inheritDoc + */ + public String[] listFilesystemRoots() { + + if(!checkForPermission(Manifest.permission.READ_EXTERNAL_STORAGE, "This is required to browse the file system")){ + return new String[]{}; + } + + String [] storageDirs = getStorageDirectories(); + if(storageDirs != null){ + String [] roots = new String[storageDirs.length + 1]; + System.arraycopy(storageDirs, 0, roots, 0, storageDirs.length); + roots[roots.length - 1] = addFile(Environment.getRootDirectory().getAbsolutePath()); + return roots; + } + return new String[]{addFile(Environment.getRootDirectory().getAbsolutePath())}; + } + + @Override + public boolean hasCachesDir() { + return true; + } + + @Override + public String getCachesDir() { + return getContext().getCacheDir().getAbsolutePath(); + } + + + + private String[] getStorageDirectories() { + String [] storageDirs = null; + + String storageDev = Environment.getExternalStorageDirectory().getPath(); + String storageRoot = storageDev.substring(0, storageDev.length() - 1); + BufferedReader bufReader = null; + + try { + bufReader = new BufferedReader(new InputStreamReader(new FileInputStream("/proc/mounts"), StandardCharsets.UTF_8)); + ArrayList list = new ArrayList(); + String line; + + while ((line = bufReader.readLine()) != null) { + if (line.contains("vfat") || line.contains("/mnt") || line.contains("/storage")) { + StringTokenizer tokens = new StringTokenizer(line, " "); + String s = tokens.nextToken(); + s = tokens.nextToken(); // Take the second token, i.e. mount point + + if (s.indexOf("secure") != -1) { + continue; + } + + if (s.startsWith(storageRoot) == true) { + list.add(s); + continue; + } + + if (line.contains("vfat") && line.contains("/mnt")) { + list.add(s); + continue; + } + } + } + + int count = list.size(); + + if (count < 2) { + storageDirs = new String[] { + storageDev + }; + } + else { + storageDirs = new String[count]; + + for (int i = 0; i < count; i++) { + storageDirs[i] = (String) list.get(i); + } + } + } + catch (FileNotFoundException e) {} + catch (IOException e) {} + finally { + if (bufReader != null) { + try { + bufReader.close(); + } + catch (IOException e) {} + } + + return storageDirs; + } + } + + /** + * @inheritDoc + */ + public String getAppHomePath() { + return addFile(getContext().getFilesDir().getAbsolutePath() + "/"); + } + + @Override + public String toNativePath(String path) { + return removeFilePrefix(path); + } + + + + /** + * @inheritDoc + */ + public String[] listFiles(String directory) throws IOException { + directory = removeFilePrefix(directory); + return new File(directory).list(); + } + + /** + * @inheritDoc + */ + public long getRootSizeBytes(String root) { + return -1; + } + + /** + * @inheritDoc + */ + public long getRootAvailableSpace(String root) { + return -1; + } + + /** + * @inheritDoc + */ + public void mkdir(String directory) { + directory = removeFilePrefix(directory); + new File(directory).mkdir(); + } + + /** + * @inheritDoc + */ + public void deleteFile(String file) { + file = removeFilePrefix(file); + File f = new File(file); + f.delete(); + } + + /** + * @inheritDoc + */ + public boolean isHidden(String file) { + file = removeFilePrefix(file); + return new File(file).isHidden(); + } + + /** + * @inheritDoc + */ + public void setHidden(String file, boolean h) { + } + + /** + * @inheritDoc + */ + public long getFileLength(String file) { + file = removeFilePrefix(file); + return new File(file).length(); + } + + /** + * @inheritDoc + */ + public long getFileLastModified(String file) { + file = removeFilePrefix(file); + return new File(file).lastModified(); + } + + /** + * @inheritDoc + */ + public boolean isDirectory(String file) { + file = removeFilePrefix(file); + return new File(file).isDirectory(); + } + + /** + * @inheritDoc + */ + public char getFileSystemSeparator() { + return File.separatorChar; + } + + /** + * @inheritDoc + */ + public OutputStream openFileOutputStream(String file) throws IOException { + file = removeFilePrefix(file); + OutputStream os = null; + try{ + os = createFileOuputStream(file); + }catch(FileNotFoundException fne){ + //It is impossible to know if a path is considered an external + //storage on the various android's versions. + //So we try to open the path and if failed due to permission we will + //ask for the permission from the user + if(fne.getMessage().contains("Permission denied")){ + + if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to access the file")){ + //The user refused to give access. + return null; + }else{ + //The user gave permission try again to access the path + return createFileOuputStream(file); + } + + }else{ + throw fne; + } + } + + return os; + } + + static String removeFilePrefix(String file) { + if (file.startsWith("file://")) { + return file.substring(7); + } + if (file.startsWith("file:/")) { + return file.substring(5); + } + return file; + } + + /** + * @inheritDoc + */ + public InputStream openFileInputStream(String file) throws IOException { + file = removeFilePrefix(file); + InputStream is = null; + try{ + is = createFileInputStream(file); + }catch(FileNotFoundException fne){ + //It is impossible to know if a path is considered an external + //storage on the various android's versions. + //So we try to open the path and if failed due to permission we will + //ask for the permission from the user + if(fne.getMessage().contains("Permission denied")){ + + if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to access the file")){ + //The user refused to give access. + return null; + }else{ + //The user gave permission try again to access the path + return openFileInputStream(file); + } + + }else{ + throw fne; + } + } + + return is; + } + + @Override + public boolean isMultiTouch() { + return true; + } + + /** + * @inheritDoc + */ + public boolean exists(String file) { + file = removeFilePrefix(file); + return new File(file).exists(); + } + + /** + * @inheritDoc + */ + public void rename(String file, String newName) { + file = removeFilePrefix(file); + new File(file).renameTo(new File(new File(file).getParentFile(), newName)); + } + + protected File createFileObject(String fileName) { + return new File(fileName); + } + + protected InputStream createFileInputStream(String fileName) throws FileNotFoundException { + return new FileInputStream(removeFilePrefix(fileName)); + } + + protected InputStream createFileInputStream(File f) throws FileNotFoundException { + return new FileInputStream(f); + } + + protected OutputStream createFileOuputStream(String fileName) throws FileNotFoundException { + return new FileOutputStream(removeFilePrefix(fileName)); + } + + protected OutputStream createFileOuputStream(java.io.File f) throws FileNotFoundException { + return new FileOutputStream(f); + } + + /** + * @inheritDoc + */ + public boolean shouldWriteUTFAsGetBytes() { + return true; + } + + + /** + * @inheritDoc + */ + public void closingOutput(OutputStream s) { + // For some reasons the Android guys chose not doing this by default: + // http://android-developers.blogspot.com/2010/12/saving-data-safely.html + // this seems to be a mistake of sacrificing stability for minor performance + // gains which will only be noticeable on a server. + if (s != null) { + if (s instanceof FileOutputStream) { + try { + FileDescriptor fd = ((FileOutputStream) s).getFD(); + if (fd != null) { + fd.sync(); + } + } catch (IOException ex) { + // this exception doesn't help us + ex.printStackTrace(); + } + } + } + } + + /** + * @inheritDoc + */ + public void printStackTraceToStream(Throwable t, Writer o) { + PrintWriter p = new PrintWriter(o); + t.printStackTrace(p); + } + + private AndroidBiometrics biometrics; + private AndroidSecureStorage secureStorage; + private AndroidNfc nfc; + private AndroidBluetooth bluetooth; + + @Override + public com.codename1.security.Biometrics getBiometrics() { + if (biometrics == null) { + biometrics = new AndroidBiometrics(); + } + return biometrics; + } + + @Override + public com.codename1.security.SecureStorage getSecureStorage() { + if (secureStorage == null) { + secureStorage = new AndroidSecureStorage(); + } + return secureStorage; + } + + @Override + public com.codename1.nfc.Nfc getNfc() { + if (nfc == null) { + nfc = new AndroidNfc(this); + } + return nfc; + } + + @Override + public com.codename1.bluetooth.Bluetooth getBluetooth() { + if (bluetooth == null) { + bluetooth = new AndroidBluetooth(); + } + return bluetooth; + } + + private com.codename1.health.Health health; + + /// Returns the Health Connect-backed health entry point. The store + /// degrades to reporting itself unsupported when no bridge has been + /// injected, which is the case for apps that never reference + /// com.codename1.health. + @Override + public com.codename1.health.Health getHealth() { + // Guarded because everything the store serializes is per-instance: + // the authorization queue, the subscription registry, drain + // coalescing and the persisted-cursor lock. Two threads racing this + // getter each got their own store, and two stores coordinate on + // nothing -- they would launch overlapping permission flows despite + // the queue inside each one being correct. + synchronized (AndroidImplementation.class) { + if (health == null) { + health = new AndroidHealth(); + } + return health; + } + } + + /** + * This method returns the platform Location Control + * + * @return LocationControl Object + */ + public LocationManager getLocationManager() { + String permissionMessage = "This is required to get the location"; + if ( + !checkForPermission( Manifest.permission.ACCESS_FINE_LOCATION, permissionMessage) + ) { + return null; + } + if ( + Build.VERSION.SDK_INT >= 29 + && "true".equals(Display.getInstance().getProperty("android.requiresBackgroundLocationPermissionForAPI29", "false")) + ) { + if ( + !checkForPermission( + "android.permission.ACCESS_BACKGROUND_LOCATION", + permissionMessage + ) + ) { + com.codename1.io.Log.e(new RuntimeException("Background location permission denied")); + } + } + + boolean includesPlayServices = Display.getInstance().getProperty("IncludeGPlayServices", "false").equals("true"); + if (includesPlayServices && hasAndroidMarket()) { + try { + Class clazz = Class.forName("com.codename1.location.AndroidLocationPlayServiceManager"); + return (com.codename1.location.LocationManager)clazz.getMethod("getInstance").invoke(null); + } catch (Exception e) { + return AndroidLocationManager.getInstance(getContext()); + } + } else { + return AndroidLocationManager.getInstance(getContext()); + } + } + + private AndroidMotionSensorManager motionSensorManager; + + @Override + public com.codename1.sensors.MotionSensorManager getMotionSensorManager() { + if (motionSensorManager == null) { + Context ctx = getContext(); + if (ctx == null) { + return null; + } + motionSensorManager = new AndroidMotionSensorManager(ctx); + } + return motionSensorManager; + } + + private String fixAttachmentPath(String attachment) { + com.codename1.io.File cn1File = new com.codename1.io.File(attachment); + File mediaStorageDir = new File(new File(getContext().getCacheDir(), "intent_files"), "Attachment"); + + // Create the storage directory if it does not exist + if (!mediaStorageDir.exists()) { + if (!mediaStorageDir.mkdirs()) { + Log.d(Display.getInstance().getProperty("AppName", "CodenameOne"), "failed to create directory"); + return null; + } + } + + File newFile = new File(mediaStorageDir.getPath() + File.separator + + cn1File.getName()); + if (newFile.exists()) { + if (Display.getInstance().getProperty("DeleteCachedFileAfterShare", "false").equals("true")) { + newFile.delete(); + } else { + // Create a media file name + String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date()); + newFile = new File(mediaStorageDir.getPath() + File.separator + + "IMG_" + timeStamp + "_" + cn1File.getName()); + } + } + + + //Uri fileUri = Uri.fromFile(newFile); + newFile.getParentFile().mkdirs(); + //Uri imageUri = Uri.fromFile(newFile); + Uri fileUri = FileProvider.getUriForFile(getContext(), getContext().getPackageName()+".provider", newFile); + + try { + InputStream is = FileSystemStorage.getInstance().openInputStream(attachment); + OutputStream os = new FileOutputStream(newFile); + byte [] buf = new byte[1024]; + int len; + while((len = is.read(buf)) > -1){ + os.write(buf, 0, len); + } + is.close(); + os.close(); + } catch (IOException ex) { + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + } + + return fileUri.toString(); + } + + /** + * @inheritDoc + */ + public void sendMessage(String[] recipients, String subject, Message msg) { + if(editInProgress()) { + stopEditing(true); + } + Intent emailIntent; + String attachment = msg.getAttachment(); + boolean hasAttachment = (attachment != null && attachment.length() > 0) || msg.getAttachments().size() > 0; + + if(msg.getMimeType().equals(Message.MIME_TEXT) && !hasAttachment){ + StringBuilder to = new StringBuilder(); + for (int i = 0; i < recipients.length; i++) { + to.append(recipients[i]); + to.append(";"); + } + emailIntent = new Intent(Intent.ACTION_SENDTO, + Uri.parse( + "mailto:" + to.toString() + + "?subject=" + Uri.encode(subject) + + "&body=" + Uri.encode(msg.getContent()))); + }else{ + if (hasAttachment) { + if(msg.getAttachments().size() > 1) { + emailIntent = new Intent(android.content.Intent.ACTION_SEND_MULTIPLE); + emailIntent.putExtra(android.content.Intent.EXTRA_EMAIL, recipients); + emailIntent.putExtra(android.content.Intent.EXTRA_SUBJECT, subject); + emailIntent.setType(msg.getMimeType()); + ArrayList uris = new ArrayList(); + + for(String path : msg.getAttachments().keySet()) { + uris.add(Uri.parse(fixAttachmentPath(path))); + } + + emailIntent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris); + } else { + emailIntent = new Intent(android.content.Intent.ACTION_SEND); + emailIntent.putExtra(android.content.Intent.EXTRA_EMAIL, recipients); + emailIntent.putExtra(android.content.Intent.EXTRA_SUBJECT, subject); + emailIntent.setType(msg.getMimeType()); + emailIntent.setType(msg.getAttachmentMimeType()); + //if the attachment is in the uder home dir we need to copy it + //to an accessible dir + attachment = fixAttachmentPath(attachment); + emailIntent.putExtra(Intent.EXTRA_STREAM, Uri.parse(attachment)); + } + } else { + emailIntent = new Intent(android.content.Intent.ACTION_SEND); + emailIntent.putExtra(android.content.Intent.EXTRA_EMAIL, recipients); + emailIntent.putExtra(android.content.Intent.EXTRA_SUBJECT, subject); + emailIntent.setType(msg.getMimeType()); + } + if (msg.getMimeType().equals(Message.MIME_HTML)) { + emailIntent.putExtra(android.content.Intent.EXTRA_TEXT, Html.fromHtml(msg.getContent())); + emailIntent.putExtra("android.intent.extra.HTML_TEXT", msg.getContent()); + }else{ + /* + // Attempted this workaround to fix the ClassCastException that occurs on android when + // there are multiple attachments. Unfortunately, this fixes the stack trace, but + // has the unwanted side-effect of producing a blank message body. + // Same workaround for HTML mimetype also fails the same way. + // Conclusion, Just live with the stack trace. It doesn't seem to affect the + // execution of the program... treat it as a warning. + // See https://github.com/codenameone/CodenameOne/issues/1782 + if (msg.getAttachments().size() > 1) { + ArrayList contentArr = new ArrayList(); + contentArr.add(msg.getContent()); + emailIntent.putStringArrayListExtra(android.content.Intent.EXTRA_TEXT, contentArr); + } else { + emailIntent.putExtra(android.content.Intent.EXTRA_TEXT, msg.getContent()); + + }*/ + emailIntent.putExtra(android.content.Intent.EXTRA_TEXT, msg.getContent()); + } + + } + final String attach = attachment; + AndroidNativeUtil.startActivityForResult(Intent.createChooser(emailIntent, "Send mail..."), new IntentResultListener() { + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + if(attach != null && attach.length() > 0 && attach.contains("tmp")){ + FileSystemStorage.getInstance().delete(attach); + } + } + }); + } + + /** + * @inheritDoc + */ + public void dial(String phoneNumber) { + Intent dialer = new Intent(android.content.Intent.ACTION_DIAL, Uri.parse("tel:" + phoneNumber)); + getContext().startActivity(dialer); + } + + @Override + public int getSMSSupport() { + if(canDial()) { + return Display.SMS_INTERACTIVE; + } + return Display.SMS_NOT_SUPPORTED; + } + + /** + * @inheritDoc + */ + public void sendSMS(final String phoneNumber, final String message, boolean i) throws IOException { + /*if(!checkForPermission(Manifest.permission.SEND_SMS, "This is required to send a SMS")){ + return; + }*/ + if(!checkForPermission(Manifest.permission.READ_PHONE_STATE, "This is required to send a SMS")){ + return; + } + if(i) { + Intent smsIntent = null; + if(android.os.Build.VERSION.SDK_INT < 19){ + smsIntent = new Intent(Intent.ACTION_VIEW); + smsIntent.setType("vnd.android-dir/mms-sms"); + smsIntent.putExtra("address", phoneNumber); + smsIntent.putExtra("sms_body",message); + }else{ + smsIntent = new Intent(Intent.ACTION_SENDTO); + smsIntent.setData(Uri.parse("smsto:" + Uri.encode(phoneNumber))); + smsIntent.putExtra("sms_body", message); + } + getContext().startActivity(smsIntent); + + } /*else { + SmsManager sms = SmsManager.getDefault(); + ArrayList parts = sms.divideMessage(message); + sms.sendMultipartTextMessage(phoneNumber, null, parts, null, null); + }*/ + } + + @Override + public void dismissNotification(Object o) { + NotificationManager notificationManager = (NotificationManager) getContext().getSystemService(Activity.NOTIFICATION_SERVICE); + if(o != null){ + Integer n = (Integer)o; + notificationManager.cancel("CN1", n.intValue()); + }else{ + notificationManager.cancelAll(); + } + } + + @Override + public boolean isNotificationSupported() { + return true; + } + + /** + * Keys of display properties that need to be made available to Services + * i.e. must be accessible even if CN1 is not initialized. + * + * This is accomplished by setting them inside init(). Then they + * are written to file so that they can be accessed inside a service + * like push notification service. + */ + private static final String[] servicePropertyKeys = new String[]{ + "android.NotificationChannel.id", + "android.NotificationChannel.name", + "android.NotificationChannel.description", + "android.NotificationChannel.importance", + "android.NotificationChannel.enableLights", + "android.NotificationChannel.lightColor", + "android.NotificationChannel.enableVibration", + "android.NotificationChannel.vibrationPattern", + "android.NotoficationChannel.soundUri" + }; + + /** + * Flag to indicate if any of the service properties have been changed. + */ + private static boolean servicePropertiesDirty() { + for (String key : servicePropertyKeys) { + if (Display.getInstance().getProperty(key, null) != null) { + return true; + } + } + return false; + } + + /** + * Stores properties that need to be accessible to services. + * i.e. must be accessible even if CN1 is not initialized. + * + * This is accomplished by setting them inside init(). Then they + * are written to file so that they can be accessed inside a service + * like push notification service. + */ + private static Map serviceProperties; + + /** + * Gets the service properties. Will read properties from file so that + * they are available even if CN1 is not initialized. + * @param a + * @return + */ + public static Map getServiceProperties(Context a) { + if (serviceProperties == null) { + InputStream i = null; + try { + serviceProperties = new HashMap(); + try { + i = a.openFileInput("CN1$AndroidServiceProperties"); + if(i == null) { + return serviceProperties; + } + } catch (FileNotFoundException notFoundEx){ + return serviceProperties; + } + DataInputStream is = new DataInputStream(i); + int count = is.readInt(); + for (int idx=0; idx out = getServiceProperties(a); + + + for (String key : servicePropertyKeys) { + + String val = Display.getInstance().getProperty(key, null); + if (val != null) { + out.put(key, val); + } + if ("true".equals(Display.getInstance().getProperty(key+"#delete", null))) { + out.remove(key); + + } + } + + OutputStream os = null; + try { + os = a.openFileOutput("CN1$AndroidServiceProperties", 0); + if (os == null) { + System.out.println("Failed to save service properties null output stream"); + return; + } + DataOutputStream dos = new DataOutputStream(os); + dos.writeInt(out.size()); + for (String key : out.keySet()) { + dos.writeUTF(key); + dos.writeUTF((String)out.get(key)); + } + serviceProperties = null; + } catch (FileNotFoundException ex) { + System.out.println("Service properties file not found. This is normal for the first run. On subsequent runs, the file should exist."); + } catch (IOException ex) { + + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + } finally { + try { + if (os != null) os.close(); + } catch (Throwable ex) { + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + } + } + } + } + + /** + * Gets a "service" display property. This is a property that is available + * even if CN1 is not initialized. They are written to file after init() so that + * they are available thereafter to services like push notification services. + * @param key THe key + * @param defaultValue The default value + * @param context Context + * @return The value. + */ + public static String getServiceProperty(String key, String defaultValue, Context context) { + if (Display.isInitialized()) { + return Display.getInstance().getProperty(key, defaultValue); + } + String val = getServiceProperties(context).get(key); + return val == null ? defaultValue : val; + } + + /** + * Sets the notification channel on a notification builder. Uses service properties to + * set properties of channel. + * @param nm The notification manager. + * @param mNotifyBuilder The notify builder + * @param context The context + * @since 7.0 + */ + public static void setNotificationChannel(NotificationManager nm, NotificationCompat.Builder mNotifyBuilder, Context context) { + setNotificationChannel(nm, mNotifyBuilder, context, (String)null); + + } + + /** + * Sets the notification channel on a notification builder. Uses service properties to + * set properties of channel. + * @param nm The notification manager. + * @param mNotifyBuilder The notify builder + * @param context The context + * @param soundName The name of the sound to use for notifications on this channel. E.g. mysound.mp3. This feature is not yet implemented, but + * parameter is added now to scaffold compatibility with build daemon until implementation is complete. + * @since 7.0 + */ + public static void setNotificationChannel(NotificationManager nm, NotificationCompat.Builder mNotifyBuilder, Context context, String soundName) { + if (android.os.Build.VERSION.SDK_INT >= 26) { + try { + NotificationManager mNotificationManager = nm; + + String id = getServiceProperty("android.NotificationChannel.id", "cn1-channel", context); + + CharSequence name = getServiceProperty("android.NotificationChannel.name", "Notifications", context); + + String description = getServiceProperty("android.NotificationChannel.description", "Remote notifications", context); + + // NotificationManager.IMPORTANCE_LOW = 2 + // NotificationManager.IMPORTANCE_HIGH = 4 // <-- Minimum level to produce sound. + int importance = Integer.parseInt(getServiceProperty("android.NotificationChannel.importance", "4", context)); + // Note: Currently we use a single notification channel for the app, but if the app uses different kinds of + // push notifications, then this may not be sufficient. E.g. The app may send both silent push notifications + // and regular notifications - but their settings (e.g. sound) are all managed through one channel with + // same settings. + // TODO Add support for multiple channels. + // See https://github.com/codenameone/CodenameOne/issues/2583 + + Class clsNotificationChannel = Class.forName("android.app.NotificationChannel"); + //android.app.NotificationChannel mChannel = new android.app.NotificationChannel(id, name, importance); + Constructor constructor = clsNotificationChannel.getConstructor(java.lang.String.class, java.lang.CharSequence.class, int.class); + Object mChannel = constructor.newInstance(new Object[]{id, name, importance}); + + Method method = clsNotificationChannel.getMethod("setDescription", java.lang.String.class); + method.invoke(mChannel, new Object[]{description}); + //mChannel.setDescription(description); + + method = clsNotificationChannel.getMethod("enableLights", boolean.class); + method.invoke(mChannel, new Object[]{Boolean.parseBoolean(getServiceProperty("android.NotificationChannel.enableLights", "true", context))}); + //mChannel.enableLights(Boolean.parseBoolean(getServiceProperty("android.NotificationChannel.enableLights", "true", context))); + + method = clsNotificationChannel.getMethod("setLightColor", int.class); + method.invoke(mChannel, new Object[]{Integer.parseInt(getServiceProperty("android.NotificationChannel.lightColor", "" + android.graphics.Color.RED, context))}); + //mChannel.setLightColor(Integer.parseInt(getServiceProperty("android.NotificationChannel.lightColor", "" + android.graphics.Color.RED, context))); + + method = clsNotificationChannel.getMethod("enableVibration", boolean.class); + method.invoke(mChannel, new Object[]{Boolean.parseBoolean(getServiceProperty("android.NotificationChannel.enableVibration", "false", context))}); + //mChannel.enableVibration(Boolean.parseBoolean(getServiceProperty("android.NotificationChannel.enableVibration", "false", context))); + String vibrationPatternStr = getServiceProperty("android.NotificationChannel.vibrationPattern", null, context); + if (vibrationPatternStr != null) { + String[] parts = vibrationPatternStr.split(","); + int len = parts.length; + long[] pattern = new long[len]; + for (int i = 0; i < len; i++) { + pattern[i] = Long.parseLong(parts[i].trim()); + } + method = clsNotificationChannel.getMethod("setVibrationPattern", long[].class); + method.invoke(mChannel, new Object[]{pattern}); + //mChannel.setVibrationPattern(pattern); + } + + String soundUri = getServiceProperty("android.NotificationChannel.soundUri", null, context); + if (soundUri != null) { + Uri uri= android.net.Uri.parse(soundUri); + + android.media.AudioAttributes audioAttributes = new android.media.AudioAttributes.Builder() + .setContentType(android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(android.media.AudioAttributes.USAGE_NOTIFICATION) + .build(); + method = clsNotificationChannel.getMethod("setSound", android.net.Uri.class, android.media.AudioAttributes.class); + method.invoke(mChannel, new Object[]{uri, audioAttributes}); + } + + method = NotificationManager.class.getMethod("createNotificationChannel", clsNotificationChannel); + method.invoke(mNotificationManager, new Object[]{mChannel}); + //mNotificationManager.createNotificationChannel(mChannel); + try { + // For some reason I can't find the app-support-v4.jar for + // API 26 that includes this method so that I can compile in netbeans. + // So we use reflection... If someone coming after can find a newer version + // that has setChannelId(), please rip out this ugly reflection hack and + // replace it with a proper call to mNotifyBuilder.setChannelId(id) + mNotifyBuilder.getClass().getMethod("setChannelId", new Class[]{String.class}).invoke(mNotifyBuilder, new Object[]{id}); + } catch (Exception ex) { + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + } + //mNotifyBuilder.setChannelId(id); + } catch (ClassNotFoundException ex) { + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + } catch (NoSuchMethodException ex) { + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + } catch (SecurityException ex) { + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + } catch (IllegalAccessException ex) { + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + } catch (IllegalArgumentException ex) { + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + } catch (InvocationTargetException ex) { + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + } catch (InstantiationException ex) { + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + } + //mNotifyBuilder.setChannelId(id); + } + + } + + public Object notifyStatusBar(String tickerText, String contentTitle, + String contentBody, boolean vibrate, boolean flashLights, Hashtable args) { + int id = getContext().getResources().getIdentifier("icon", "drawable", getContext().getApplicationInfo().packageName); + + NotificationManager notificationManager = (NotificationManager) getContext().getSystemService(Activity.NOTIFICATION_SERVICE); + + Intent notificationIntent = new Intent(); + notificationIntent.setComponent(activityComponentName); + PendingIntent contentIntent = createPendingIntent(getContext(), 0, notificationIntent); + + + NotificationCompat.Builder builder = new NotificationCompat.Builder(getContext()) + .setContentIntent(contentIntent) + .setSmallIcon(id) + .setContentTitle(contentTitle) + .setTicker(tickerText); + if(flashLights){ + builder.setLights(0, 1000, 1000); + } + if(vibrate){ + builder.setVibrate(new long[]{0, 100, 1000}); + } + if(args != null) { + Boolean b = (Boolean)args.get("persist"); + if(b != null && b.booleanValue()) { + builder.setAutoCancel(false); + builder.setOngoing(true); + } else { + builder.setAutoCancel(false); + } + } else { + builder.setAutoCancel(true); + } + Notification notification = builder.build(); + int notifyId = 10001; + notificationManager.notify("CN1", notifyId, notification); + return new Integer(notifyId); + } + + public boolean isContactsPermissionGranted() { + if (android.os.Build.VERSION.SDK_INT < 23) { + return true; + } + + if (android.support.v4.content.ContextCompat.checkSelfPermission(getContext(), + Manifest.permission.READ_CONTACTS) + != PackageManager.PERMISSION_GRANTED) { + return false; + } + return true; + } + + + @Override + public String[] getAllContacts(boolean withNumbers) { + if(!checkForPermission(Manifest.permission.READ_CONTACTS, "This is required to get the contacts")){ + return new String[]{}; + } + return AndroidContactsManager.getInstance().getContacts(getContext(), withNumbers); + } + + @Override + public com.codename1.calendar.LocalCalendarSource getLocalCalendarSource() { + if (calendarSource == null) { + calendarSource = new AndroidCalendarSource(getContext()); + } + return calendarSource; + } + + @Override + public Contact getContactById(String id) { + if(!checkForPermission(Manifest.permission.READ_CONTACTS, "This is required to get the contacts")){ + return null; + } + return AndroidContactsManager.getInstance().getContact(getContext(), id); + } + + @Override + public Contact getContactById(String id, boolean includesFullName, boolean includesPicture, + boolean includesNumbers, boolean includesEmail, boolean includeAddress){ + if(!checkForPermission(Manifest.permission.READ_CONTACTS, "This is required to get the contacts")){ + return null; + } + return AndroidContactsManager.getInstance().getContact(getContext(), id, includesFullName, includesPicture, + includesNumbers, includesEmail, includeAddress); + } + + @Override + public Contact[] getAllContacts(boolean withNumbers, boolean includesFullName, boolean includesPicture, boolean includesNumbers, boolean includesEmail, boolean includeAddress) { + if(!checkForPermission(Manifest.permission.READ_CONTACTS, "This is required to get the contacts")){ + return new Contact[]{}; + } + return AndroidContactsManager.getInstance().getAllContacts(getContext(), withNumbers, includesFullName, includesPicture, includesNumbers, includesEmail, includeAddress); + } + + @Override + public boolean isGetAllContactsFast() { + return true; + } + + @Override + public boolean isContactPickerSupported() { + // Both paths behind AndroidContactPicker exist on every version this + // port runs on: the system picker from Android 17, ACTION_PICK + // against the contacts provider before that. A device with no + // contacts app answers with ActivityNotFoundException, which the + // picker reports as an empty selection -- the same thing a cancelled + // pick reports, so callers need no separate case for it. + // + // Deliberately NOT PackageManager.resolveActivity. Review asked for + // it, to catch the kiosk device that has no contacts app at all, and + // it would answer the wrong question on every ordinary one: from + // Android 11 a resolve query is filtered by package visibility, so an + // app without a matching entry is told nothing handles the + // intent even where the picker works perfectly. LAUNCHING an implicit + // intent is not filtered, which is why the picker itself needs no + // and works regardless. Trading a false yes on a stripped + // device -- whose cost is a pick that reports empty, exactly as a + // cancelled one does -- for a false no on every modern device, whose + // cost is a working feature hidden with no way to find out why, is a + // bad trade. + return getActivity() != null; + } + + @Override + public void pickContacts(int requestedFields, boolean multiSelect, + int selectionLimit, boolean requireAllRequestedFields, + ActionListener response) { + if (getActivity() == null) { + fireContactPickerResult(response, new Contact[0]); + return; + } + if (editInProgress()) { + stopEditing(true); + } + // Deliberately no checkForPermission call. The whole point of the + // picker is that neither path needs READ_CONTACTS, and asking for it + // here would put the permission back into the manifest and in front + // of the user for a flow that does not need it. + AndroidContactPicker.pick(getContext(), requestedFields, multiSelect, + selectionLimit, requireAllRequestedFields, + new ContactPickerResult(response)); + } + + /** + * Hands a picker selection back to the listener that asked for it. + */ + private final class ContactPickerResult implements AndroidContactPicker.Result { + private final ActionListener response; + + ContactPickerResult(ActionListener response) { + this.response = response; + } + + @Override + public void picked(Contact[] picked) { + fireContactPickerResult(response, picked); + } + } + + public String createContact(String firstName, String surname, String officePhone, String homePhone, String cellPhone, String email) { + if(!checkForPermission(Manifest.permission.WRITE_CONTACTS, "This is required to create a contact")){ + return null; + } + return AndroidContactsManager.getInstance().createContact(getContext(), firstName, surname, officePhone, homePhone, cellPhone, email); + } + + public boolean deleteContact(String id) { + if(!checkForPermission(Manifest.permission.WRITE_CONTACTS, "This is required to delete a contact")){ + return false; + } + return AndroidContactsManager.getInstance().deleteContact(getContext(), id); + } + + @Override + public boolean isNativeShareSupported() { + return true; + } + + @Override + public boolean isNativeInAppReviewSupported() { + // True only when the Play In-App Review library was bundled, which the + // AndroidGradleBuilder does when the app references the app-review API. + return getActivity() != null && AppReviewSupport.isSupported(); + } + + @Override + public void requestNativeInAppReview(final SuccessCallback done) { + final CodenameOneActivity activity = getActivity(); + if (activity == null || !AppReviewSupport.isSupported()) { + if (done != null) { + done.onSucess(Boolean.FALSE); + } + return; + } + activity.runOnUiThread(new Runnable() { + public void run() { + AppReviewSupport.requestReview(activity, done); + } + }); + } + + @Override + public void share(String text, String image, String mimeType, Rectangle sourceRect){ + share(text, image, mimeType, sourceRect, null); + } + + @Override + public void share(String text, String image, String mimeType, Rectangle sourceRect, final com.codename1.share.ShareResultListener listener) { + /*if(!checkForPermission(Manifest.permission.READ_PHONE_STATE, "This is required to perform share")){ + return; + }*/ + Intent shareIntent = new Intent(android.content.Intent.ACTION_SEND); + if(image == null){ + if (text.startsWith("file:") && mimeType != null && new com.codename1.io.File(text).exists()) { + shareIntent.setType(mimeType); + shareIntent.putExtra(Intent.EXTRA_STREAM, Uri.parse(fixAttachmentPath(text))); + } else { + shareIntent.setType("text/plain"); + shareIntent.putExtra(android.content.Intent.EXTRA_TEXT, text); + } + }else{ + shareIntent.setType(mimeType); + shareIntent.putExtra(Intent.EXTRA_STREAM, Uri.parse(fixAttachmentPath(image))); + shareIntent.putExtra(Intent.EXTRA_TEXT, text); + } + + Intent chooser; + try { + if (listener != null && android.os.Build.VERSION.SDK_INT >= 22) { + chooser = buildShareChooserWithCallback(shareIntent, listener); + } else { + chooser = Intent.createChooser(shareIntent, "Share with..."); + } + } catch (Throwable t) { + // Fall back to the plain chooser, then synthesize a listener + // result so the app doesn't hang on an unfulfilled callback. + chooser = Intent.createChooser(shareIntent, "Share with..."); + if (listener != null) { + listener.onResult(com.codename1.share.ShareResult.sharedTo(null)); + } + } + getContext().startActivity(chooser); + } + + // ONE receiver for the process, and one listener held at a time. + // + // A receiver per share leaked every cancelled one. It is unregistered from + // inside onReceive, and Android sends nothing when the chooser is + // dismissed -- there is no public dismissal signal -- so a cancelled share + // left its receiver registered on the application context, holding the + // listener and, through it, the button and the form it is on. Each cancel + // added another, for the life of the process, and a share button is + // exactly the kind of control a user opens and backs out of repeatedly. + // + // Reusing one receiver bounds that at a single retained listener: the next + // share replaces the one a dismissal left behind. It cannot be driven to + // zero from here, because knowing the chooser was dismissed is the thing + // Android does not tell us. + // + // Instance fields, not static: there is one implementation per process, + // the receiver belongs to it, and a lazily initialised static is a + // different claim -- one SpotBugs reads as a threading bug, correctly, + // because nothing here would make it safe if it were true. + // + // pendingShareListener is written from the Codename One EDT and read on + // the Android main thread, which is why it is volatile. That is a native + // boundary crossing, not core framework code. + private BroadcastReceiver shareChooserReceiver; + + private String shareChooserAction; + + private volatile com.codename1.share.ShareResultListener pendingShareListener; + + @TargetApi(22) + private Intent buildShareChooserWithCallback(Intent shareIntent, final com.codename1.share.ShareResultListener listener) { + final Context appCtx = getContext().getApplicationContext(); + // The listener this chooser is for. Set before the receiver can + // possibly fire, and replacing whatever a dismissed chooser left. + pendingShareListener = listener; + if (shareChooserReceiver != null) { + // Already registered and listening on the same action, so there is + // nothing to build but the PendingIntent below. + return chooserFor(appCtx, shareIntent, shareChooserAction); + } + final String action = appCtx.getPackageName() + ".CN1_SHARE_CHOSEN"; + shareChooserAction = action; + // The receiver fires once when the user picks a target. Android + // does not expose a dismissal signal for the chooser, so the + // listener simply does not fire on user-cancel (see comment + // further down). + BroadcastReceiver receiver = new BroadcastReceiver() { + @Override + public void onReceive(Context ctx, Intent intent) { + // Taken, so a repeat broadcast cannot deliver twice. The + // receiver stays registered for the next share. + com.codename1.share.ShareResultListener target = pendingShareListener; + pendingShareListener = null; + if (target == null) { + return; + } + String pkg = null; + try { + android.content.ComponentName cn = intent.getParcelableExtra(Intent.EXTRA_CHOSEN_COMPONENT); + if (cn != null) pkg = cn.getPackageName(); + } catch (Throwable ignore) {} + target.onResult(com.codename1.share.ShareResult.sharedTo(pkg)); + } + }; + IntentFilter filter = new IntentFilter(action); + boolean registered = false; + if (android.os.Build.VERSION.SDK_INT >= 33) { + // RECEIVER_EXPORTED = 0x2 -- constant exists at runtime on + // API 33+ but is not present in older android.jar build deps, + // so call the 3-arg overload via reflection to stay source- + // compatible. + try { + java.lang.reflect.Method m = Context.class.getMethod( + "registerReceiver", BroadcastReceiver.class, IntentFilter.class, int.class); + m.invoke(appCtx, receiver, filter, Integer.valueOf(0x2)); + registered = true; + } catch (Throwable ignore) {} + } + if (!registered) { + appCtx.registerReceiver(receiver, filter); + } + // Recorded only once it is really listening, so a registration that + // threw is retried by the next share rather than skipped for ever. + shareChooserReceiver = receiver; + // Android's chooser IntentSender callback never fires on + // dismissal: there is no public API to observe a user-cancel. + // Apps that need a dismissal signal must use Activity-resume. + + return chooserFor(appCtx, shareIntent, action); + } + + /// The chooser Intent itself, wrapping a broadcast PendingIntent on this + /// action. + /// + /// Split out because it is built on every share while the receiver behind + /// it is built once. FLAG_UPDATE_CURRENT is what makes the fixed action + /// safe to reuse: the same PendingIntent is handed back with this + /// chooser's extras, and only one chooser is ever up at a time. + @TargetApi(22) + private Intent chooserFor(Context appCtx, Intent shareIntent, String action) { + Intent pi = new Intent(action).setPackage(appCtx.getPackageName()); + int piFlags = PendingIntent.FLAG_UPDATE_CURRENT; + if (android.os.Build.VERSION.SDK_INT >= 31) { + // FLAG_MUTABLE was introduced in API 31; its numeric value + // (0x02000000) is referenced here directly so the source + // still compiles against pre-31 android.jar build deps. + piFlags |= 0x02000000; + } + PendingIntent pendingIntent = PendingIntent.getBroadcast(appCtx, 0, pi, piFlags); + return Intent.createChooser(shareIntent, "Share with...", pendingIntent.getIntentSender()); + } + + /// Printing uses the Android print framework which requires API 19 + /// and a foreground activity to host the print dialog. + @Override + public boolean isPrintingSupported() { + return android.os.Build.VERSION.SDK_INT >= 19 && getActivity() != null; + } + + /// Print through the Android print framework. PDF files are streamed + /// verbatim into a `android.print.PrintDocumentAdapter`; images go + /// through the support library `PrintHelper` which scales them to the + /// page. + /// + /// Outcome reporting is best effort: the PDF path polls the returned + /// `android.print.PrintJob` and treats a queued/started job as + /// completed since Android offers no callback for the terminal job + /// state once it was handed to the print service. The image path + /// reports completed when `PrintHelper` finishes because it can't + /// distinguish a dismissed dialog from a printed page. + @Override + public void print(final String filePath, final String mimeType, final com.codename1.printing.PrintResultListener listener) { + final PrintResultDispatcher dispatcher = new PrintResultDispatcher(listener); + if (!isPrintingSupported()) { + dispatcher.fire(com.codename1.printing.PrintResult.failed( + "Printing requires Android 4.4 or newer and a foreground activity")); + return; + } + if (filePath == null) { + dispatcher.fire(com.codename1.printing.PrintResult.failed("No file to print")); + return; + } + final File file = new File(removeFilePrefix(filePath)); + if (!file.exists()) { + dispatcher.fire(com.codename1.printing.PrintResult.failed("File not found: " + filePath)); + return; + } + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + try { + // PrintSupport touches android.print which only exists + // on API 19+; the isPrintingSupported() gate above keeps + // the class from loading on older devices. + PrintSupport.startPrint(getActivity(), file, mimeType, dispatcher); + } catch (Throwable t) { + dispatcher.fire(com.codename1.printing.PrintResult.failed( + "Failed to start print job: " + t)); + } + } + }); + } + + /// Delivers a [com.codename1.printing.PrintResult] to the listener at + /// most once. The listener may be null and results may arrive from any + /// thread; `Display` moves the callback onto the EDT. + private static final class PrintResultDispatcher { + private final com.codename1.printing.PrintResultListener listener; + private boolean fired; + + PrintResultDispatcher(com.codename1.printing.PrintResultListener listener) { + this.listener = listener; + } + + void fire(com.codename1.printing.PrintResult result) { + synchronized (this) { + if (fired) { + return; + } + fired = true; + } + if (listener != null) { + listener.onResult(result); + } + } + } + + /// All android.print framework access lives in this class so the + /// classes it references are only loaded behind the API 19 check in + /// [#print]. + @TargetApi(19) + private static final class PrintSupport { + + private static final int JOB_PENDING = 0; + private static final int JOB_COMPLETED = 1; + private static final int JOB_CANCELLED = 2; + private static final int JOB_FAILED = 3; + + /// How long the poller waits for the print dialog/job to reach a + /// terminal state before giving up. + private static final long POLL_TIMEOUT = 15 * 60 * 1000L; + private static final long POLL_INTERVAL = 500; + + /// Must run on the UI thread: `PrintManager.print` and + /// `PrintHelper.printBitmap` both require it. + static void startPrint(Activity activity, File file, String mimeType, PrintResultDispatcher dispatcher) { + String jobName = file.getName(); + if ("application/pdf".equalsIgnoreCase(mimeType)) { + android.print.PrintManager printManager = + (android.print.PrintManager) activity.getSystemService(Context.PRINT_SERVICE); + if (printManager == null) { + dispatcher.fire(com.codename1.printing.PrintResult.failed("Print service unavailable")); + return; + } + android.print.PrintJob job = printManager.print(jobName, + new PdfFilePrintAdapter(jobName, file), null); + pollPrintJob(activity, job, dispatcher); + } else if (mimeType != null && mimeType.startsWith("image/")) { + printImage(activity, file, jobName, dispatcher); + } else { + dispatcher.fire(com.codename1.printing.PrintResult.failed( + "Unsupported print document type: " + mimeType)); + } + } + + private static void printImage(Activity activity, File file, String jobName, + final PrintResultDispatcher dispatcher) { + Bitmap bitmap = BitmapFactory.decodeFile(file.getAbsolutePath()); + if (bitmap == null) { + dispatcher.fire(com.codename1.printing.PrintResult.failed( + "Unable to decode image for printing")); + return; + } + android.support.v4.print.PrintHelper helper = new android.support.v4.print.PrintHelper(activity); + helper.setScaleMode(android.support.v4.print.PrintHelper.SCALE_MODE_FIT); + helper.printBitmap(jobName, bitmap, new android.support.v4.print.PrintHelper.OnPrintFinishCallback() { + @Override + public void onFinish() { + // PrintHelper fires onFinish when the print flow ends + // without exposing whether the user printed or + // dismissed the dialog; report completed best effort. + dispatcher.fire(com.codename1.printing.PrintResult.completed()); + } + }); + } + + /// Watches the print job from a background thread and reports the + /// first terminal state. The job object must only be queried on + /// the UI thread, so every tick bounces through `runOnUiThread`. + private static void pollPrintJob(final Activity activity, final android.print.PrintJob job, + final PrintResultDispatcher dispatcher) { + Thread poller = new Thread(new Runnable() { + @Override + public void run() { + long deadline = System.currentTimeMillis() + POLL_TIMEOUT; + while (System.currentTimeMillis() < deadline) { + try { + Thread.sleep(POLL_INTERVAL); + } catch (InterruptedException ignore) { + } + final int[] state = new int[]{JOB_PENDING}; + final boolean[] done = new boolean[1]; + final Object lock = new Object(); + activity.runOnUiThread(new Runnable() { + @Override + public void run() { + int s = JOB_PENDING; + try { + if (job.isCancelled()) { + s = JOB_CANCELLED; + } else if (job.isFailed()) { + s = JOB_FAILED; + } else if (job.isCompleted()) { + s = JOB_COMPLETED; + } else if (job.isQueued() || job.isStarted() || job.isBlocked()) { + // The dialog phase is over and the + // job belongs to the print service; + // that is as "completed" as Android + // lets us observe reliably. + s = JOB_COMPLETED; + } + } catch (Throwable t) { + s = JOB_FAILED; + } + synchronized (lock) { + state[0] = s; + done[0] = true; + lock.notifyAll(); + } + } + }); + synchronized (lock) { + long waitUntil = System.currentTimeMillis() + 5000; + while (!done[0] && System.currentTimeMillis() < waitUntil) { + try { + lock.wait(POLL_INTERVAL); + } catch (InterruptedException ignore) { + } + } + if (!done[0]) { + // UI thread didn't get to us; try again on + // the next tick until the deadline passes. + continue; + } + } + switch (state[0]) { + case JOB_COMPLETED: + dispatcher.fire(com.codename1.printing.PrintResult.completed()); + return; + case JOB_CANCELLED: + dispatcher.fire(com.codename1.printing.PrintResult.cancelled()); + return; + case JOB_FAILED: + dispatcher.fire(com.codename1.printing.PrintResult.failed("Print job failed")); + return; + default: + // still in the dialog phase, keep polling + } + } + dispatcher.fire(com.codename1.printing.PrintResult.failed( + "Timed out waiting for the print job status")); + } + }, "CN1PrintJobPoller"); + poller.setDaemon(true); + poller.start(); + } + + /// Streams an existing PDF file into the print system unchanged. + /// Layout/write failures are routed through the framework + /// callbacks which fail the print job; the poller in + /// [#pollPrintJob] then reports the failure to the listener, so + /// the dispatcher still fires exactly once. + private static final class PdfFilePrintAdapter extends android.print.PrintDocumentAdapter { + private final String jobName; + private final File file; + + PdfFilePrintAdapter(String jobName, File file) { + this.jobName = jobName; + this.file = file; + } + + @Override + public void onLayout(android.print.PrintAttributes oldAttributes, + android.print.PrintAttributes newAttributes, + android.os.CancellationSignal cancellationSignal, + LayoutResultCallback callback, Bundle extras) { + if (cancellationSignal != null && cancellationSignal.isCanceled()) { + callback.onLayoutCancelled(); + return; + } + try { + android.print.PrintDocumentInfo info = new android.print.PrintDocumentInfo.Builder(jobName) + .setContentType(android.print.PrintDocumentInfo.CONTENT_TYPE_DOCUMENT) + .setPageCount(android.print.PrintDocumentInfo.PAGE_COUNT_UNKNOWN) + .build(); + callback.onLayoutFinished(info, !newAttributes.equals(oldAttributes)); + } catch (Throwable t) { + callback.onLayoutFailed(t.toString()); + } + } + + @Override + public void onWrite(android.print.PageRange[] pages, + android.os.ParcelFileDescriptor destination, + android.os.CancellationSignal cancellationSignal, + WriteResultCallback callback) { + FileInputStream in = null; + FileOutputStream out = null; + try { + in = new FileInputStream(file); + out = new FileOutputStream(destination.getFileDescriptor()); + byte[] buffer = new byte[8192]; + int count; + while ((count = in.read(buffer)) > -1) { + if (cancellationSignal != null && cancellationSignal.isCanceled()) { + callback.onWriteCancelled(); + return; + } + out.write(buffer, 0, count); + } + callback.onWriteFinished(new android.print.PageRange[]{android.print.PageRange.ALL_PAGES}); + } catch (Throwable t) { + callback.onWriteFailed(t.toString()); + } finally { + if (in != null) { + try { + in.close(); + } catch (Throwable ignore) { + } + } + if (out != null) { + try { + out.close(); + } catch (Throwable ignore) { + } + } + } + } + } + } + + /** + * @inheritDoc + */ + public String getPlatformName() { + return "and"; + } + + /** + * Snapshot of the recent process logcat for crash protection. Since + * Android 4.1 (API 16) apps can only read their own process log + * without the READ_LOGS permission, which is exactly what we want. + * Returns the last ~200 lines (capped at 32 KB). + */ + @Override + public String getNativeLogSnapshot() { + java.io.BufferedReader reader = null; + Process proc = null; + try { + proc = Runtime.getRuntime().exec(new String[]{ + "logcat", "-d", "-t", "200", "-v", "threadtime"}); + reader = new java.io.BufferedReader( + new java.io.InputStreamReader(proc.getInputStream(), "UTF-8")); + StringBuilder sb = new StringBuilder(8192); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append('\n'); + if (sb.length() > 32 * 1024) { + break; + } + } + return sb.length() == 0 ? null : sb.toString(); + } catch (Throwable ignored) { + // logcat unavailable (very old Android, locked-down ROM, + // etc.) -- crash protection still works, just without the + // device log context. + return null; + } finally { + if (reader != null) { + try { reader.close(); } catch (java.io.IOException ignored) { } + } + if (proc != null) { + try { proc.destroy(); } catch (Throwable ignored) { } + } + } + } + + /** + * @inheritDoc + */ + public String[] getPlatformOverrides() { + if (isWatch()) { + return new String[]{"watch", "android", "android-watch"}; + } + if (isTV()) { + return new String[]{"tv", "android", "android-tv"}; + } + if (isTablet()) { + return new String[]{"tablet", "android", "android-tab"}; + } else { + return new String[]{"phone", "android", "android-phone"}; + } + } + + /** + * @inheritDoc + */ + public void copyToClipboard(final Object obj) { + super.copyToClipboard(obj); + if (getActivity() == null) { + return; + } + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + int sdk = android.os.Build.VERSION.SDK_INT; + if (sdk < 11) { + android.text.ClipboardManager clipboard = (android.text.ClipboardManager) getActivity().getSystemService(Context.CLIPBOARD_SERVICE); + clipboard.setText(obj.toString()); + // Afterwards, as in the branch below: a clip that was never published has + // not replaced the one the system is still holding, and unpinning that one + // first left its files reclaimable while it was still there to be pasted. + clipboardHolds(0); + } else { + android.content.ClipboardManager clipboard = (android.content.ClipboardManager) getActivity().getSystemService(Context.CLIPBOARD_SERVICE); + android.content.ClipData clip; + long staged = 0; + boolean assembled = false; + if (obj instanceof ClipboardContent) { + AssembledClip built = clipDataFor((ClipboardContent) obj); + clip = built == null ? null : built.getData(); + staged = built == null ? 0 : built.getClip(); + assembled = true; + if (clip == null) { + // A copy of nothing is an empty clipboard, which is a thing the user + // asked for and can paste. A *drag* of nothing is not: there the null + // refuses to start, because a drag that carries nothing still lands + // somewhere and tells that receiver it succeeded. + clip = ClipData.newPlainText("Codename One", ""); + } + } else { + // Nothing of ours is staged for a plain text clip. + clip = ClipData.newPlainText("Codename One", obj.toString()); + } + watchPrimaryClip(clipboard); + // Pinned for the length of the call, held only if it returns. setPrimaryClip + // can throw -- a payload past the Binder transaction limit is the usual way + // -- and switching the hold beforehand handed the *old* clip's files to + // reclamation while the system was still holding that clip, pinned the ones + // that never reached the clipboard in their place, and left a callback + // counted that would never arrive. The pin in between is what keeps the new + // clip's own files from being reclaimed in the window this opens. + clipboardPublishing(staged); + boolean published = false; + try { + clipboard.setPrimaryClip(clip); + published = true; + } finally { + clipboardPublished(staged, published); + if (assembled) { + // Taken over by the clipboard, or given up on. Either way this + // assembly is no longer one nothing has claimed. + endStagingClip(staged); + } + } + } + } + }); + } + + /// Builds the Android clip that publishes a `ClipboardContent`, for a clipboard copy and + /// for a native drag alike -- both hand another application the same thing, so both go + /// through the same conversion, including the file provider URIs that let the receiving + /// application read generated image bytes. + /// + /// #### Parameters + /// + /// - `content`: the representations to publish + /// + /// #### Returns + /// + /// the clip, or null when the content produced no representation at all + AssembledClip clipDataFor(ClipboardContent content) { + // Held here and handed down, never read back off the field. A clipboard copy runs + // on the Android UI thread and a drag on the Codename One event dispatch thread, so + // two assemblies can overlap -- and one reading the field mid-way filed its + // remaining files under the other's id, which split one clip across two and left + // the half nobody pinned free to be deleted while the clip still referenced it. + final long clip = beginStagingClip(); + // Every read this assembly makes goes through here; see Assembly for why it is not the + // content's own memory of what its providers produced. + Assembly assembly = new Assembly(content); + int sdk = android.os.Build.VERSION.SDK_INT; + List mimeTypes = new ArrayList(); + List items = new ArrayList(); + String plain = assembly.text(ClipboardContent.MIME_TEXT); + String html = assembly.text(ClipboardContent.MIME_HTML); + // A clip carries one text payload. Where the content has no text/plain but does have + // some other text representation -- markdown, AsciiDoc, a URI list -- that one is the + // payload, since publishing an empty clip instead would lose it outright. + String primaryTextMime = plain != null ? ClipboardContent.MIME_TEXT : null; + // Not when there is HTML: that is already the payload, and the plain text beside it is + // derived from the markup below rather than searched for among the other + // representations, which would put an unrelated one under the HTML. + if (plain == null && html == null) { + String[] advertised = content.getMimeTypes(); + for (int iter = 0; iter < advertised.length && plain == null; iter++) { + if (!advertised[iter].startsWith("text/")) { + // Text types only, however the value happens to be carried. A String under + // application/json -- or under an application's own type -- is that type's + // encoding and not a reading the source offered as text, and publishing it + // as the clip's text let a text-only application paste a representation + // nobody advertised to it. Nothing is lost by refusing: a String under a + // type that is not text travels as a typed content URI like any other + // representation, under its own name. The file list is covered by the same + // test, since that is not a text type either. + // + // The types getMimeTypes answers with are normalized to lower case, so this + // is an ASCII comparison against an ASCII constant and no locale enters it. + continue; + } + String value = assembly.text(advertised[iter]); + if (value != null) { + plain = value; + primaryTextMime = advertised[iter]; + } + } + } + // The types are recorded here, but the text does not become an item of its own yet. A + // clip item is a dragged *object*, so a text item beside a file item is two things + // being dragged at once, and a receiver that imports everything takes the document + // *and* a stray piece of text instead of choosing the best form of one thing. Where + // the clip carries a URI, the text rides on it -- see attachCarriedText below. + boolean carriesHtml = sdk >= 16 && html != null; + if (carriesHtml && plain == null) { + // Android *requires* it: ClipData.Item refuses HTML with no plain text beside it, + // and threw IllegalArgumentException out of the thread that was building the clip + // -- so content offering nothing but MIME_HTML crashed a copy and silently failed + // a drag. Rendered from the markup rather than being the markup, which would show + // every receiver the tags. + plain = htmlToPlainText(html); + } + if (carriesHtml) { + mimeTypes.add(ClipboardContent.MIME_TEXT); + mimeTypes.add(ClipboardContent.MIME_HTML); + } else if (plain != null) { + mimeTypes.add(ClipboardContent.MIME_TEXT); + if (primaryTextMime != null && !mimeTypes.contains(primaryTextMime)) { + mimeTypes.add(primaryTextMime); + } + } + // One pass at a time. Together under a single catch, a failure in the first abandoned + // the two after it as well, so a clip whose image could not be written went out + // without the document and the typed representations it also had. + try { + addBinaryContent(assembly, mimeTypes, items, clip); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + try { + addPublishedUris(assembly, mimeTypes, items, clip); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + try { + addRemainingRepresentations(assembly, plain, mimeTypes, items, clip); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + if (carriesHtml || plain != null) { + attachCarriedText(items, plain, carriesHtml ? html : null); + } + if (items.isEmpty()) { + // Nothing was produced. Every representation this content offered is a provider that + // answered null or threw, which ClipboardDataProvider explicitly permits -- so there + // is no clip, and the callers decide what that means. Answering with empty text + // instead replaced the payload with a different one: a drag offering only + // application/pdf reported success and let another application accept blank text. + return new AssembledClip(null, clip); + } + // Built from the union of the types, not by appending to a text clip. ClipData.addItem + // does not add the item's type to the description, so a clip assembled that way + // describes itself as text only -- and both a Codename One drop target filtering on + // MIME_FILE and an external receiver choosing a representation read the description. + ClipData data = new ClipData("Codename One", + mimeTypes.toArray(new String[mimeTypes.size()]), items.get(0)); + for (int iter = 1; iter < items.size(); iter++) { + data.addItem(items.get(iter)); + } + return new AssembledClip(data, clip); + } + + /// A clip and the assembly that built it. + /// + /// The id travels with the clip because that is the only way its caller can say which + /// assembly the clipboard or the drag now holds: a field read afterwards answers about + /// whichever assembly began most recently, and two of them can be in flight at once. + static final class AssembledClip { + /// The clip, or null when the content produced nothing that could be published. + private final ClipData data; + private final long clip; + + AssembledClip(ClipData data, long clip) { + this.data = data; + this.clip = clip; + } + + ClipData getData() { + return data; + } + + long getClip() { + return clip; + } + } + + // ------------------------------------------------------------------------------------ + // Native drag and drop. See AndroidNativeDragAndDrop; the payload is the same ClipData a + // copy publishes, which is why a drag out of the application lands in another application + // exactly as a paste would. + // ------------------------------------------------------------------------------------ + + @Override + public boolean isNativeDragAndDropSupported() { + return AndroidNativeDragAndDrop.isSupported(); + } + + @Override + public boolean isNativeDragOutsideApplicationSupported() { + return AndroidNativeDragAndDrop.isOutsideApplicationSupported(); + } + + @Override + public boolean startNativeDrag(com.codename1.ui.NativeDragOperation op) { + return AndroidNativeDragAndDrop.startDrag(this, op); + } + + @Override + public void cancelNativeDrag() { + AndroidNativeDragAndDrop.cancelDrag(); + } + + /** + * Collects the image bytes and file references carried by the ClipboardContent as items and + * MIME types, exposing binary content as FileProvider content:// URIs. The caller assembles + * the ClipData from the union of everything collected here and the text types, because + * ClipData.addItem cannot widen a description that already exists. + */ + private void addBinaryContent(Assembly assembly, List mimeTypes, + List items, long clip) throws IOException { + String authority = getContext().getPackageName() + ".provider"; + + // The files first, then the byte-backed representations. Android's ClipData.Item holds + // exactly one Uri, so two representations that are both bytes cannot be one item -- the + // platform has no way to say "another reading of the same object" for them, only for + // the text and markup that attachCarriedText rides on the item below. Publishing them + // is still right: they are what the description advertises, and dropping them would + // refuse the very target that accepted the hover on one. What order fixes is which + // object a receiver reading only the first item takes -- the document, not its + // thumbnail. + // + // It is also what puts the carried text on the document rather than on the thumbnail. + + // File references: MIME_FILE may be a single String or a String[] + Object fileData = assembly.value(ClipboardContent.MIME_FILE); + if (fileData != null) { + String[] paths; + if (fileData instanceof String[]) { + paths = (String[]) fileData; + } else { + paths = new String[]{ fileData.toString() }; + } + for (int i = 0; i < paths.length; i++) { + String pathOrUri = paths[i]; + if (pathOrUri == null || pathOrUri.length() == 0) { + continue; + } + // Each file on its own. A path outside the roots the file provider was + // configured with throws, and one throwing on the second of three used to + // abandon the third as well *and* skip every representation after the file + // loop -- so the clip went out holding one file, silently, and the drag + // reported success. + try { + Uri u; + if (hasScheme(pathOrUri, "content:")) { + u = Uri.parse(pathOrUri); + } else { + File file = hasScheme(pathOrUri, "file:") + ? new File(Uri.parse(pathOrUri).getPath()) + : new File(pathOrUri); + u = shareableUriFor(file, authority, clip); + } + if (!mimeTypes.contains("text/uri-list")) { + mimeTypes.add("text/uri-list"); + } + // And whatever the document actually is. A receiver in another application + // reads the description and nothing else while the drag hovers, so a PDF + // dragged out of here described only as a URI list was refused by every + // target that filters on application/pdf -- the type was there for the + // asking on the URI, and only this side can ask it in time. The alias the + // hover adds locally cannot help them; it never leaves this process. + // + // Only a type the resolver actually knows. octet-stream is what a provider + // answers when it has nothing to say, and advertising that would tell a + // receiver the clip holds a type it cannot use. + String resolved = bareMimeType( + getContext().getContentResolver().getType(u)); + if (resolved != null && resolved.length() > 0 + && !"application/octet-stream".equals(resolved) + && !mimeTypes.contains(resolved)) { + mimeTypes.add(resolved); + } + items.add(new ClipData.Item(u)); + } catch (Throwable t) { + // Absent rather than advertised: nothing named it a type of its own, so + // no receiver is told the clip holds a file it does not. + com.codename1.io.Log.e(t); + } + } + } + + // Image bytes: prefer PNG, then JPEG, then GIF + String imageMime = null; + byte[] imageBytes = null; + String imageExt = null; + imageBytes = assembly.bytes(ClipboardContent.MIME_PNG); + if (imageBytes != null) { + imageMime = ClipboardContent.MIME_PNG; + imageExt = "png"; + } else { + imageBytes = assembly.bytes(ClipboardContent.MIME_JPEG); + if (imageBytes != null) { + imageMime = ClipboardContent.MIME_JPEG; + imageExt = "jpg"; + } else { + imageBytes = assembly.bytes(ClipboardContent.MIME_GIF); + if (imageBytes != null) { + imageMime = ClipboardContent.MIME_GIF; + imageExt = "gif"; + } + } + } + if (imageBytes != null) { + try { + Uri imageUri = writeAsProviderUri(imageBytes, imageExt, imageMime, clip); + if (imageUri != null) { + if (!mimeTypes.contains(imageMime)) { + mimeTypes.add(imageMime); + } + items.add(new ClipData.Item(imageUri)); + } + } catch (Throwable t) { + // On its own, so a picture that cannot be written does not take the files + // and the other representations with it. + com.codename1.io.Log.e(t); + } + } + } + + /// The text of an HTML fragment, for the plain text Android requires beside it. + /// + /// Empty rather than null when the markup renders to nothing: an item may carry empty text + /// with its HTML, and may not carry none. + private static String htmlToPlainText(String html) { + try { + CharSequence text = android.os.Build.VERSION.SDK_INT >= 24 + ? android.text.Html.fromHtml(html, android.text.Html.FROM_HTML_MODE_LEGACY) + : android.text.Html.fromHtml(html); + return text == null ? "" : text.toString(); + } catch (Throwable t) { + // Markup this platform will not parse still has to travel; the HTML is the payload + // and the text beside it is what Android asks for, not what the clip is for. + com.codename1.io.Log.e(t); + return ""; + } + } + + /// Puts the URIs a text/uri-list names on the clip as URIs. + /// + /// A URI is what an Android receiver reads off `ClipData.Item#getUri()`, and a link has + /// nothing else to be read off. Left to the passes around this one a uri-list became + /// carried text, or -- where the clip had text already -- a content URI holding the list + /// as a document; either way a receiver that took the clip because it advertised + /// text/uri-list found no URI on it at all. + /// + /// One item per URI, because an item is a dragged object and a list of three links is + /// three of them. The clip's text still rides on the first, as it does on a file. + private void addPublishedUris(Assembly assembly, List mimeTypes, + List items, long clip) { + String list = assembly.text(ClipboardContent.MIME_URI_LIST); + if (list == null) { + return; + } + // The files the source published, which the clip is already carrying: each went onto + // it as a content URI this application minted, so the list's own spelling of the same + // document -- a path, or a file: URI of it -- would drag that document a second time. + // + // Compared against those paths rather than against the minted URIs, which are not + // equal to anything the source wrote. Entry by entry, too: returning on the first file + // threw away every *other* line, so a document published beside its own web address + // advertised text/uri-list and delivered the document alone. + List alreadyCarried = new ArrayList(); + Object files = assembly.value(ClipboardContent.MIME_FILE); + if (files instanceof String[]) { + String[] paths = (String[]) files; + for (int iter = 0; iter < paths.length; iter++) { + if (paths[iter] != null) { + alreadyCarried.add(publishedUriKey(paths[iter])); + } + } + } else if (files instanceof String) { + alreadyCarried.add(publishedUriKey((String) files)); + } + boolean carriesPublishedFile = false; + for (int iter = 0; iter < items.size(); iter++) { + Uri carried = items.get(iter).getUri(); + // A *generated* URI is not one of the source's. It carries a representation's + // bytes -- an image, a document this application encoded -- and a reader filters + // it out precisely because the source never published it as a URI. + if (carried != null && !isGeneratedClipFile(carried)) { + carriesPublishedFile = true; + break; + } + } + boolean any = false; + String[] lines = list.split("\n"); + for (int iter = 0; iter < lines.length; iter++) { + String line = lines[iter].trim(); + // RFC 2483: a line opening with a hash is a comment, not a URI. + if (line.length() == 0 || line.charAt(0) == '#') { + continue; + } + if (alreadyCarried.contains(publishedUriKey(line))) { + continue; + } + Uri published = publishableUri(line, clip); + if (published == null) { + continue; + } + items.add(new ClipData.Item(published)); + any = true; + } + // Declared when the clip can produce one: the entries just added, the published files + // a reader builds the list back out of, or both. + if (any || carriesPublishedFile) { + declareUriList(mimeTypes); + } + } + + /// One entry of a URI list, in a form the clip may leave this process with, or null when + /// it cannot be published at all. + /// + /// A file: URI is the case that needs the work. Android refuses to let a clip carrying one + /// cross the application boundary -- prepareToLeaveProcess throws FileUriExposedException + /// from API 24 -- so a copy of a list naming a local document threw out of the UI thread it + /// was made on, and a global drag of one never started. It goes through the file provider + /// exactly as the file representation does, which is also what makes it *readable* by the + /// receiver rather than merely legal. + /// + /// Anything else -- an http address, a mailto:, another application's content URI -- is + /// already publishable and travels as it was written. + private Uri publishableUri(String line, long clip) { + if (!hasScheme(line, "file:")) { + return Uri.parse(line); + } + String path = Uri.parse(line).getPath(); + if (path == null || path.length() == 0) { + return null; + } + try { + return shareableUriFor(new File(path), + getContext().getPackageName() + ".provider", clip); + } catch (Throwable t) { + // Absent rather than advertised, as the file representation does it: a document + // outside the roots the provider was configured with cannot be handed over, and + // naming it anyway tells the receiver the clip holds something it will not get. + com.codename1.io.Log.e(t); + return null; + } + } + + /// What two spellings of one file have in common. + /// + /// ClipboardContent's file representation permits a raw path, and a URI list beside it + /// commonly names the same document as a file: URI -- percent encoded, as a URI is. They + /// are one document, and putting both on the clip drags it twice. + private static String publishedUriKey(String value) { + if (hasScheme(value, "file:")) { + String path = Uri.parse(value).getPath(); + return path == null ? value : path; + } + return value; + } + + private static void declareUriList(List mimeTypes) { + if (!mimeTypes.contains(ClipboardContent.MIME_URI_LIST)) { + mimeTypes.add(ClipboardContent.MIME_URI_LIST); + } + } + + /// Puts the clip's text on the first item that carries a URI, or makes an item of it when + /// there is none. + /// + /// Android has no notion of "an alternative reading of this object": every item is another + /// thing being dragged. A file and its text fallback therefore have to be one item, or a + /// receiver importing the clip gets two objects where the source published one. The same + /// mistake on the iOS side made a receiver import a document and a stray piece of text. + private static void attachCarriedText(List items, String plain, String html) { + for (int iter = 0; iter < items.size(); iter++) { + Uri uri = items.get(iter).getUri(); + if (uri != null) { + items.set(iter, html != null + ? new ClipData.Item(plain, html, null, uri) + : new ClipData.Item(plain, null, uri)); + return; + } + } + // Nothing to ride on, so the text is the object. First, as it was before there was + // anything else in the clip at all. + items.add(0, html != null ? new ClipData.Item(plain, html) : new ClipData.Item(plain)); + } + + /// Adds the representations neither the text nor the binary pass above has taken. + /// + /// Byte-backed types -- a PDF, an archive, an application's own format -- become typed + /// content URIs, which is the only labelled way an Android clip carries bytes. Text types + /// are advertised only when their value *is* the text the clip already carries: a clip has + /// one text payload, so advertising a second, different reading of it would tell a receiver + /// the clip holds something it cannot then produce, and a Codename One target would accept + /// the hover and be refused at the drop. + private void addRemainingRepresentations(Assembly assembly, String carriedText, + List mimeTypes, List items, long clip) throws IOException { + String[] advertised = assembly.content().getMimeTypes(); + for (int iter = 0; iter < advertised.length; iter++) { + String mime = advertised[iter]; + if (mimeTypes.contains(mime) || ClipboardContent.MIME_FILE.equals(mime)) { + continue; + } + // Each representation on its own: a provider that throws is one type absent, not + // every type after it. ClipboardDataProvider permits it to fail. + Object value = assembly.value(mime); + byte[] bytes = null; + if (value instanceof String) { + if (carriedText != null && carriedText.equals(value)) { + // The same text the clip already carries, so naming the type is enough. + mimeTypes.add(mime); + continue; + } + // A *different* reading -- Markdown source beside its plain rendering, say. + // A clip carries one text payload, so this one travels as a typed content URI + // the way binary does. Dropping it instead, which is what this did, lost a + // representation the application deliberately published. + bytes = ((String) value).getBytes("UTF-8"); + } else if (value instanceof byte[]) { + bytes = (byte[]) value; + } + if (bytes != null) { + try { + Uri uri = writeAsProviderUri(bytes, extensionForMime(mime), mime, clip); + if (uri != null) { + mimeTypes.add(mime); + items.add(new ClipData.Item(uri)); + } + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + } + } + + /// A content URI another application can read for this file. + /// + /// The file provider is configured with a fixed set of roots -- the application's files + /// directory and cache/intent_files -- and getUriForFile throws for anything outside them. + /// Plenty of perfectly good paths are outside them: FileSystemStorage lists external + /// storage roots, and a file there used to throw, be logged, and be left out of the clip + /// entirely -- taking the whole drag with it when it was the only thing being dragged. + /// + /// So it is copied where the provider can reach, under its own name, which is what a + /// receiver sees. Not through writeAsProviderUri: that names and records what it mints as + /// transport for a representation's bytes, and this is a file the source published. + private static final long MAX_STAGED_SHARE_BYTES = 8L * 1024 * 1024; + private static final String SHARED_COPY_PREFIX = "cn1-shared-"; + + private Uri shareableUriFor(File file, String authority, long clip) throws IOException { + try { + Uri direct = FileProvider.getUriForFile(getContext(), authority, file); + getContext().grantUriPermission("android", direct, + Intent.FLAG_GRANT_READ_URI_PERMISSION); + return direct; + } catch (Throwable outsideTheRoots) { + com.codename1.io.Log.e(outsideTheRoots); + } + // The copy runs on the thread that started the drag, which is the event dispatch + // thread, and a drag has to begin while the finger is still down -- so this cannot be + // moved off it and cannot be allowed to take long. Android stops waiting for input after + // five seconds; a few megabytes is far below that on any storage, and a file bigger than + // this has no business being copied at all. It belongs under a provider root, which is + // where the roots above now put the external storage such files actually live on. + if (file.length() > MAX_STAGED_SHARE_BYTES) { + throw new IOException("refusing to copy " + file.length() + " bytes on the event " + + "dispatch thread to share " + file); + } + File dir = new File(getContext().getCacheDir(), "intent_files"); + dir.mkdirs(); + // Its own directory, so the copy keeps the original name without colliding with + // another file of the same name in the same drag. + File holder = File.createTempFile(SHARED_COPY_PREFIX, "", dir); + if (!holder.delete() || !holder.mkdirs()) { + throw new IOException("could not stage " + file + " for sharing"); + } + File copy = new File(holder, file.getName()); + boolean registered = false; + try { + InputStream in = new FileInputStream(file); + try { + OutputStream os = new FileOutputStream(copy); + try { + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) > 0) { + os.write(buffer, 0, read); + } + } finally { + os.close(); + } + } finally { + in.close(); + } + Uri shared = FileProvider.getUriForFile(getContext(), authority, copy); + getContext().grantUriPermission("android", shared, + Intent.FLAG_GRANT_READ_URI_PERMISSION); + // Remembered so it is cleaned up, but not as transport: this is a file the source + // published, and it has to read back as one. + rememberStagedClipFile(shared, copy, false, clip); + registered = true; + return shared; + } finally { + if (!registered) { + // A source that vanished, a read that failed, a disk that filled: the holder + // and whatever was written into it exist by now, and nothing has registered + // them for reclamation -- so every failed export left its partial copy in the + // cache for good. + // + // Registration, not the copy, is what ends the window. Naming the file to the + // provider can fail on its own -- a path the manifest's roots do not cover is + // refused there and nowhere else -- and with the flag set at the end of the + // copy, that failure leaked exactly what this was written to prevent. + copy.delete(); + holder.delete(); + } + } + } + + /// One clip assembly's reading of a content, kept to itself. + /// + /// A representation registered as a provider is resolved once per transfer, and the memory + /// of that lives on the ClipboardContent -- which is fine for a transfer that owns it and + /// wrong for two that overlap. A copy assembles on Android's UI thread and a drag on the + /// event dispatch thread, so one could reset the shared memo halfway through the other and + /// hand it a value produced for a different transfer: a clip built from two generations of + /// a payload that changes. + /// + /// So an assembly reads through this instead. The provider is asked at most once per type + /// *per assembly*, which is what the promise actually is, and neither assembly can disturb + /// the other because neither touches the content's own memory. + private static final class Assembly { + private final ClipboardContent content; + private final Map produced = new HashMap(); + + Assembly(ClipboardContent content) { + this.content = content; + } + + ClipboardContent content() { + return content; + } + + Object value(String mimeType) { + if (content == null || mimeType == null) { + return null; + } + if (produced.containsKey(mimeType)) { + return produced.get(mimeType); + } + Object value = null; + try { + value = com.codename1.ui.NativeDragAndDrop.produceTransferValue(content, mimeType); + } catch (Throwable err) { + // A provider that fails is one type absent, not a clip abandoned -- and the + // failure is remembered like any other answer, so a second read of the same + // type does not run it again. Same rule as clipboardValue. + com.codename1.io.Log.e(err); + } + produced.put(mimeType, value); + return value; + } + + String text(String mimeType) { + Object value = value(mimeType); + return value instanceof String ? (String) value : null; + } + + byte[] bytes(String mimeType) { + Object value = value(mimeType); + return value instanceof byte[] ? (byte[]) value : null; + } + } + + /// Writes bytes somewhere the application's file provider can serve them from and returns + /// the content URI, which is how an Android clip carries anything that is not text. + /// + /// AndroidGradleBuilder exposes cache/intent_files through the app's FileProvider, so + /// generated payloads stay inside that root and FileProvider can safely name them. + /// + /// The name carries `mime` so the read back is an answer rather than a guess -- see + /// `#decodeMimeFromFileName(java.lang.String)`. + private Uri writeAsProviderUri(byte[] bytes, String extension, String mime, long clip) + throws IOException { + if (bytes == null) { + return null; + } + // A zero length payload is still a payload: refusing it would leave the clip without a + // type it had advertised, and a target filtering on that type would accept the hover + // and be refused the drop. + File dir = new File(getContext().getCacheDir(), "intent_files"); + dir.mkdirs(); + // A name built from the clock and the payload's length collided: two representations of + // one payload that share an extension and a byte length are written within the same + // millisecond, and the second overwrote the first -- leaving both clip items pointing at + // the second one's bytes. createTempFile is the guarantee rather than a longer guess. + String encoded = encodeMimeForFileName(mime); + File file = File.createTempFile( + encoded == null ? CLIP_FILE_PREFIX : CLIP_FILE_PREFIX + encoded + "-", + "." + extension, dir); + boolean registered = false; + try { + OutputStream os = new FileOutputStream(file); + try { + os.write(bytes); + } finally { + os.close(); + } + Uri uri = FileProvider.getUriForFile(getContext(), + getContext().getPackageName() + ".provider", file); + // Grant broadly so any paste or drop target can read the content:// URI + getContext().grantUriPermission("android", uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); + rememberStagedClipFile(uri, file, true, clip); + registered = true; + return uri; + } finally { + if (!registered) { + // The file exists from createTempFile onwards, and reclamation only ever sees + // what was registered -- so a cache that fills mid-write, or a provider that + // refuses to name the file, left a partial cn1-clip- file behind that nothing + // would ever collect. The same window the published-file copy above closes. + file.delete(); + } + } + } + + /// The name every generated clip file starts with, and the alphabet + /// `#encodeMimeForFileName(java.lang.String)` writes the type in. + private static final String CLIP_FILE_PREFIX = "cn1-clip-"; + private static final String CLIP_MIME_HEX = "0123456789abcdef"; + + /// Writes a MIME type into something that is legal in a file name and reads back as itself. + /// + /// The extension cannot do this job. It is derived from the type and the derivation is + /// lossy -- `application/x-foo` and `application/x-foo+json` both reduce to `xfoo` -- so two + /// representations of one payload can produce URIs no reader can tell apart, and both are + /// then dropped rather than mispaired. Hex is unlovely for a file name nobody reads, and it + /// is exact: every byte of the type survives, and no character it produces means anything to + /// a file system, a URI or `#decodeMimeFromFileName(java.lang.String)`. + /// + /// Answers null for a type this cannot carry, and the file is then named without one. + private static String encodeMimeForFileName(String mime) { + if (mime == null || mime.length() == 0 || mime.length() > 60) { + return null; + } + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter < mime.length(); iter++) { + int c = mime.charAt(iter); + if (c > 0xff) { + return null; + } + out.append(CLIP_MIME_HEX.charAt((c >> 4) & 0xf)).append(CLIP_MIME_HEX.charAt(c & 0xf)); + } + return out.toString(); + } + + /// The MIME type `#encodeMimeForFileName(java.lang.String)` wrote into this name, or null + /// when the name did not come from there -- a clip another application published, or one + /// whose type was too long to carry. + private static String decodeMimeFromFileName(String name) { + if (name == null || !name.startsWith(CLIP_FILE_PREFIX)) { + return null; + } + int end = name.indexOf('-', CLIP_FILE_PREFIX.length()); + if (end < 0) { + return null; + } + String hex = name.substring(CLIP_FILE_PREFIX.length(), end); + if (hex.length() == 0 || (hex.length() & 1) != 0) { + return null; + } + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter < hex.length(); iter += 2) { + int hi = Character.digit(hex.charAt(iter), 16); + int lo = Character.digit(hex.charAt(iter + 1), 16); + if (hi < 0 || lo < 0) { + return null; + } + out.append((char) ((hi << 4) | lo)); + } + return asciiLower(out.toString()); + } + + /// A file extension for a MIME type, used to name the temporary file a content URI is + /// served from. + /// + /// Android's own table first, because a FileProvider derives the URI's type from the + /// extension: a synthesized one it does not recognize makes ContentResolver.getType answer + /// application/octet-stream, and the type the clip advertised is then unrecoverable when + /// the clip is read back. + private static String extensionForMime(String mime) { + try { + String known = android.webkit.MimeTypeMap.getSingleton().getExtensionFromMimeType(mime); + if (known != null && known.length() > 0) { + return known; + } + } catch (Throwable t) { + // Fall through to the synthesized extension below. + } + int slash = mime.indexOf('/'); + String sub = slash < 0 ? mime : mime.substring(slash + 1); + int plus = sub.indexOf('+'); + if (plus > 0) { + sub = sub.substring(0, plus); + } + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter < sub.length(); iter++) { + char c = sub.charAt(iter); + if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) { + out.append(c); + } + } + return out.length() == 0 ? "bin" : out.toString(); + } + + /// The MIME type to file an incoming image's bytes under: the framework's constant for the + /// three formats it names, and the type the content resolver reported for anything else. + /// + /// `#mimeForImageType(java.lang.String)` answers PNG for everything it does not recognize, + /// which for a WebP meant filing WebP bytes as a PNG -- undecodable by anything that + /// believed the label, and invisible to a target filtering on the type the drag advertised, + /// so the hover was accepted and the drop refused. + private static String imageMimeFor(String type) { + String lower = asciiLower(type); + if (lower.startsWith(ClipboardContent.MIME_PNG) + || lower.startsWith(ClipboardContent.MIME_JPEG) + || lower.startsWith(ClipboardContent.MIME_GIF)) { + return mimeForImageType(lower); + } + return lower; + } + + /** + * Maps a content resolver image MIME type to the corresponding ClipboardContent MIME constant, + * defaulting to PNG for unrecognized image types. + */ + private static String mimeForImageType(String type) { + if (type == null) { + return ClipboardContent.MIME_PNG; + } + if (type.startsWith(ClipboardContent.MIME_JPEG)) { + return ClipboardContent.MIME_JPEG; + } + if (type.startsWith(ClipboardContent.MIME_GIF)) { + return ClipboardContent.MIME_GIF; + } + return ClipboardContent.MIME_PNG; + } + + /** + * @inheritDoc + */ + public Object getPasteDataFromClipboard() { + if (getContext() == null) { + return null; + } + final Object[] response = new Object[1]; + runOnUiThreadAndBlock(new Runnable() { + @Override + public void run() { + int sdk = android.os.Build.VERSION.SDK_INT; + if (sdk < 11) { + android.text.ClipboardManager clipboard = (android.text.ClipboardManager) getActivity().getSystemService(Context.CLIPBOARD_SERVICE); + response[0] = clipboard.getText().toString(); + } else { + android.content.ClipboardManager clipboard = (android.content.ClipboardManager) getActivity().getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clip = clipboard.getPrimaryClip(); + if (clip == null || clip.getItemCount() == 0) { + return; + } + // With the description, exactly as a drop is read. Without it the only + // types a paste could report were the ones an item produced by itself, + // so another application's text published under a type of its own -- + // text/markdown, an application's own format -- arrived as nothing but + // text/plain and the type it was published under was gone. + ClipboardContent content = contentFromClip(clip, clip.getDescription()); + String plain = content.getText(ClipboardContent.MIME_TEXT); + // What the clip actually holds, not how many types it happens to name. + // Counting worked only because every clip used to acquire a text/plain of + // its own, empty or not: with that padding gone an image-only clip counted + // as one type, fell through to the plain-text answer, and a paste that had + // a perfectly good PNG in it returned null. + String[] types = content.getMimeTypes(); + boolean textOnly = types.length == 0 + || (types.length == 1 && ClipboardContent.MIME_TEXT.equals(types[0])); + if (!textOnly) { + response[0] = content; + } else { + response[0] = plain != null && plain.length() > 0 ? plain : null; + } + } + } + }); + return response[0]; + } + + /// Reads an Android `android.content.ClipData` into the framework's `ClipboardContent`. + /// + /// Shared by paste and by a native drop, because Android describes both the same way: a + /// list of items that are each text, HTML or a URI, and a URI is either an image to be read + /// or a file reference to be passed along. The plain text representation is always present, + /// even when empty, so a caller can tell "nothing but text" from "something richer" by the + /// number of MIME types. + /// + /// #### Parameters + /// + /// - `clip`: the clip data, which may be null + /// + /// #### Returns + /// + /// the content, never null + ClipboardContent contentFromClip(ClipData clip) { + return contentFromClip(clip, clip == null ? null : clip.getDescription()); + } + + /// Reads a clip, and where a description is given also honours the MIME types it + /// advertises. + /// + /// A drag is filtered twice: once against the description while it hovers, and again + /// against the materialized content when it is dropped. If the second view is narrower than + /// the first, a target accepts the hover and is then refused the drop -- which is what + /// happened to a component filtering on `ClipboardContent#MIME_URI_LIST`, because a URI + /// item materializes as `MIME_FILE` alone. Nothing is invented here: an advertised type is + /// only filled from a value the clip actually produced. + /// + /// A paste is read the same way, from the primary clip's own description. It used to pass + /// none, on the reasoning that a paste should report only what the clip produced -- but + /// the description *is* what the clip says it holds, and without it a type another + /// application published its text under was simply lost. What is filled from it is still + /// only ever a value the clip produced. + /// + /// #### Parameters + /// + /// - `clip`: the clip data, which may be null + /// + /// - `description`: what the source advertised, or null to report only what was read -- + /// which no caller does any more, though a port that has no description to offer + /// still may + /// + /// #### Returns + /// + /// the content, never null + ClipboardContent contentFromClip(ClipData clip, ClipDescription description) { + ClipboardContent content = new ClipboardContent(); + if (clip == null) { + content.setData(ClipboardContent.MIME_TEXT, ""); + return content; + } + int sdk = android.os.Build.VERSION.SDK_INT; + String plain = null; + String html = null; + List fileUris = new ArrayList(); + // Every URI the clip carried that the source published, files or not. A link dragged out + // of a browser belongs here and not in fileUris: it is a URI, and it is not a document on + // disk. The two lists differ only by that, and by the transport URIs this exporter mints, + // which are in neither because the source never published them as URIs at all. + List publishedUris = new ArrayList(); + // URIs the content resolver could not name. An application defined type has no entry in + // Android's table, so a FileProvider serving it reports octet-stream or nothing at all. + List unnamedUris = new ArrayList(); + for (int i = 0; i < clip.getItemCount(); i++) { + ClipData.Item item = clip.getItemAt(i); + try { + Uri uri = item.getUri(); + if (uri != null) { + // Without the parameters, because a bare MIME type is what everything here + // compares against: a provider answering "text/plain; charset=utf-8" would + // file the document under a type no target asks for, and would slip past + // the MIME_TEXT check below that stops the synthesized empty text from + // overwriting it. + String type = bareMimeType(getContext().getContentResolver().getType(uri)); + if (type != null && type.startsWith("image/")) { + // Promised, not read. Reading it here opened the URI and pulled the + // whole image across on Android's own UI thread, before the drop was + // even queued -- so a photo dropped on a target that wanted nothing + // but getFiles() stalled the application, or ran it out of memory, + // for bytes nobody asked for. The same promise the typed branch below + // makes, and safe for the same reason: the grant this drop was given + // lasts as long as the activity, so a read a moment later on the + // event dispatch thread still succeeds. See uriBytesProvider. + String imageMime = imageMimeFor(type); + if (!content.hasMimeType(imageMime)) { + content.setDataProvider(imageMime, uriBytesProvider(uri)); + } + } else if (type != null && type.length() > 0 + && !"application/octet-stream".equals(type)) { + // A typed URI is a file reference *and* that type. Reducing it to a file + // alone let a target filtering on, say, application/pdf accept the hover + // -- the description advertised the type -- and then be refused the + // drop, because the content it is filtered against a second time no + // longer had it. The bytes are promised rather than read: a target that + // only wants the path should not pay for a document it never opens. + if (!content.hasMimeType(type)) { + content.setDataProvider(type, uriBytesProvider(uri)); + } + } else { + unnamedUris.add(uri); + } + // A URI item is a file reference as well as whatever its type made of it -- + // unless it is one this exporter minted to carry bytes. The image branch + // used to return before reaching this at all, so dragging a PNG *file* + // produced image bytes and no file, and a target filtering on MIME_FILE + // accepted the hover -- the description still advertised text/uri-list -- + // and was refused the drop. Adding every URI unconditionally is the other + // error: a payload of nothing but application/pdf bytes travels as a + // content URI without text/uri-list ever being advertised, and calling that + // a file both invents a representation the source never published and lets + // a nested file-only target take a drop the PDF-capable one was chosen for + // while it hovered. + // + // The two are told apart by the exporter's own record of what it minted, + // not by anything about the URI or its name -- an application may publish a + // file called anything at all. + if (!isGeneratedClipFile(uri) && mayCarryAcrossApplications(uri)) { + publishedUris.add(uri.toString()); + if (namesALocalFile(uri)) { + fileUris.add(uri.toString()); + } + } + // No continue: an item carrying a URI carries the clip's text too, because + // that is where this exporter puts it -- a text item of its own would be a + // second object being dragged. Returning here dropped the fallback the + // source published on its own round trip. + } + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + if (html == null && sdk >= 16) { + // Empty markup is a value, not an absence: getHtmlText answers null when the + // item carries no HTML at all, so anything else is what the source published. + // Discarding it left fillAdvertisedTypes to rebuild the advertised text/html + // from the plain text, handing the target something the source never wrote -- + // and this exporter publishes exactly that item for content whose HTML is empty. + html = item.getHtmlText(); + } + if (plain == null) { + // What the item literally carries first, and empty counts: getText answers + // null when the item holds no text at all, so anything else is what the + // source published -- the same reading getHtmlText gets above. Discarding an + // empty one left an advertised text/markdown with nothing to restore it + // from, and a target that took the hover on that type was refused the drop. + CharSequence literal = item.getText(); + if (literal != null) { + plain = literal.toString(); + } else if (item.getUri() == null) { + // Nothing literal, so it is derived -- and only for an item with no URI. + // coerceToText on one of those goes and reads the document behind it, + // which is a different value altogether and none of this branch's + // business. An empty derivation means the item had nothing to give + // rather than that the source published nothing, so it does not stop + // the search. + CharSequence derived = item.coerceToText(getContext()); + if (derived != null && derived.length() > 0) { + plain = derived.toString(); + } + } + } + } + if (html != null) { + // A value the clip's own item published, so it wins over a URI the resolver happened + // to type text/html -- an .html file being dragged. Same rule as the text below, + // and the reason that one needs a guard and this one does not: there is no + // synthesized empty HTML to write over a representation that already answered. + content.setData(ClipboardContent.MIME_HTML, html); + } + if (!fileUris.isEmpty()) { + content.setFiles(fileUris.toArray(new String[fileUris.size()])); + } + // Not when the clip named exactly one type and it is not text/plain. That type is what + // the text *is*: another application publishing a direct item of its own format -- + // application/json, say -- carries the value as the item's text, because an Android + // item has nowhere else to put a string. Calling it text/plain lost the name the clip + // gave it, and a target filtered to that name accepted the hover and was refused the + // drop; fillAdvertisedTypes below hands the value to the type instead. + if (plain != null && soleAdvertisedType(description) == null) { + content.setData(ClipboardContent.MIME_TEXT, plain); + } else if (plain == null && !content.hasMimeType(ClipboardContent.MIME_TEXT) + && description != null && description.hasMimeType(ClipboardContent.MIME_TEXT)) { + // The clip promised text and no item produced it, so the empty string keeps that + // promise: a target that accepted the hover on text/plain would otherwise be + // refused the drop it was told it could have. Only then, though -- a clip that + // never mentioned text does not acquire it here. findTarget runs again against the + // materialized content, so inventing text/plain let a nested text-only component + // take a drop the type-capable ancestor had been chosen for while it hovered, and + // that component never saw an enter event at all. + // + // Nor over a representation that answered: a URI the resolver typed text/plain, + // which is what a dragged .txt is, has already registered the document's own + // contents, and writing over that handed the target an empty document. + content.setData(ClipboardContent.MIME_TEXT, ""); + } + if (description != null) { + fillAdvertisedTypes(content, description, plain, publishedUris, unnamedUris); + } else if (!publishedUris.isEmpty() && !content.hasMimeType(ClipboardContent.MIME_URI_LIST)) { + // A paste is told nothing about what the clip advertises, so what it reports can + // only come from what the clip carried -- and what this one carried is URIs. + // Another application copying a link publishes exactly that, one item with a URI + // and no text at all: nothing above it produces a representation, so without this + // the read answered with an empty content and the paste with null. + // + // Nothing is invented by it either. These are the URIs the clip itself carried, + // minus the ones this exporter minted as transport, which is what a URI list is. + content.setData(ClipboardContent.MIME_URI_LIST, uriListOf(publishedUris)); + } + return content; + } + + /// The content URIs this exporter minted to carry bytes, oldest first. + /// + /// Remembered, not recognized. The file name cannot answer the question: an application may + /// publish a file of its own by any name it likes, and one called cn1-clip-roundtrip.txt is + /// exactly what the clipboard round trip publishes -- which a prefix test then threw away + /// as one of ours, losing the file reference it had just copied. The type cannot answer it + /// either, since a PDF published as bytes and a PDF published as a file both arrive as + /// application/pdf. Only the exporter knows, so the exporter records it. + /// + /// Bounded: a clip that has been replaced on the clipboard can no longer be pasted, so the + /// oldest entries are of no further use. A clip that outlives the process falls back to + /// being read as a file, which is what it was read as before any of this existed. + /// It also names the file, because every one of these is a file this application wrote + /// into its own cache and nothing else will ever come back for it. A clip that has been + /// replaced cannot be pasted, so when one falls off the end its file goes with it -- + /// otherwise copying documents or images repeatedly leaves every one of them on disk for + /// the life of the installation. + /// + /// Kept by the clip rather than one file at a time. A single payload can stage more files + /// than any per-file bound, and counting them individually deleted the earliest ones while + /// clipDataFor was still building the very clip that referenced them -- so the clip went + /// out pointing at files that were already gone. Whole clips are what is forgotten, never + /// the one being assembled. + /// + /// Bounded by bytes rather than by a count of clips. A receiver may hold a content URI + /// this application handed it and read it much later -- a queued upload does exactly that, + /// and the grant stays valid -- so counting clips deleted a file somebody was still + /// entitled to as soon as eight more copies had been made, however small. What can + /// actually fill a device is bytes: a hundred staged text fragments cost nothing and all + /// survive, while a few videos are reclaimed as soon as they add up. + /// + /// There is no signal that says a receiver is finished with one, and inventing one would + /// be a new public API every application had to adopt to keep behaving as it does today. + /// The same reasoning, and the same budget, as the dropped copies on iOS. + private static final long GENERATED_CLIP_BUDGET = 64L * 1024 * 1024; + private static final java.util.LinkedHashMap STAGED_CLIP_FILES = + new java.util.LinkedHashMap(); + + /// One file staged for a clip: where it is, and whether it carries a representation's + /// bytes rather than being a file the source published. + private static final class StagedClipFile { + private final String path; + private final boolean transport; + private final long clip; + /// What it occupies, for the budget above. Taken when it is staged, because by the + /// time it is reclaimed the file may be gone and a size of zero would make a large + /// clip look free. + private final long bytes; + + StagedClipFile(String path, boolean transport, long clip, long bytes) { + this.path = path; + this.transport = transport; + this.clip = clip; + this.bytes = bytes; + } + } + + /// The clip being assembled. Incremented as each one starts, so everything staged for it + /// is recognisable as belonging together. + private static long stagingClip; + + /// The clip the system clipboard is holding, and the clip a running drag is carrying. + /// + /// Neither is superseded by anything newer, which is what a window of recent clips would + /// otherwise assume. A clipboard holds its clip until something replaces it, and every + /// drag in between advances the count -- so nine drags after a copy deleted the files the + /// clipboard was still pointing at, and the paste the user eventually made produced a + /// content URI nothing could read. + private static long clipboardClip; + private static long draggingClip; + + /// The assembly a publication in progress is about to put on the clipboard, exempt from + /// reclamation until the attempt is over. Nothing holds it yet -- the clipboard has not + /// taken it -- and without this the window between assembling a clip and the system + /// accepting it was one in which its own files could be deleted. + private static long publishingClip; + + /// Changes to the primary clip this application is about to make itself, which the watcher + /// below hears about like any other and must not read as somebody else's copy. + /// + /// A count rather than a flag: a copy can be made while an earlier one's callback is still + /// queued, and a flag cleared by the first would have made the second look foreign. + private static int expectedClipChanges; + + /// True once the primary clip watcher is installed, which happens the first time this + /// application puts anything on the clipboard. + private static boolean clipboardWatched; + + /// The assemblies that have begun and whose caller has not yet taken them over. + /// + /// An assembly is exempt from reclamation while it is being built -- its files are being + /// referenced by a clip that does not exist yet -- and stays exempt until whoever asked for + /// it has put it on the clipboard or handed it to a drag. Exempting only the clip currently + /// growing was not enough: a copy assembles on Android's UI thread while a drag assembles + /// on the event dispatch thread, so one could finish and be waiting for its caller to claim + /// it while the other's staging triggered a reclamation that deleted its files. The caller + /// then published, or dragged, a clip of dead URIs. + private static final java.util.Set ASSEMBLING_CLIPS = new java.util.HashSet(); + + private static long beginStagingClip() { + synchronized (STAGED_CLIP_FILES) { + long clip = ++stagingClip; + ASSEMBLING_CLIPS.add(Long.valueOf(clip)); + return clip; + } + } + + /// Ends an assembly's exemption, because its caller has taken it over -- or has given up on + /// it, which is the same thing as far as its files are concerned. + /// + /// #### Parameters + /// + /// - `clip`: the assembly, or zero when there was none + static void endStagingClip(long clip) { + if (clip == 0) { + return; + } + synchronized (STAGED_CLIP_FILES) { + ASSEMBLING_CLIPS.remove(Long.valueOf(clip)); + reclaimStagedClipFiles(); + } + } + + /// Starts listening for the primary clip being replaced, once. + /// + /// A clip this application published is exempt from reclamation for as long as the + /// clipboard holds it, and nothing but another copy of our own used to end that -- so a + /// copy made in *another* application left ours pinned for good, and an oversized one then + /// sat in the cache above the budget with nothing able to reclaim it. + /// + /// Called on the Android UI thread, from the copy that is about to pin something. + /// + /// Android only delivers these callbacks to an application that has focus, so a copy made + /// elsewhere while this one is in the background is still missed. That leaves the hold in + /// place until the next copy either application makes, which is the behaviour this + /// replaces rather than a new failure -- and the files are in the cache directory, which + /// the system reclaims under pressure whatever this bookkeeping believes. + private static void watchPrimaryClip(android.content.ClipboardManager clipboard) { + synchronized (STAGED_CLIP_FILES) { + if (clipboardWatched) { + return; + } + clipboardWatched = true; + } + try { + clipboard.addPrimaryClipChangedListener( + new android.content.ClipboardManager.OnPrimaryClipChangedListener() { + @Override + public void onPrimaryClipChanged() { + synchronized (STAGED_CLIP_FILES) { + if (expectedClipChanges > 0) { + // Our own copy, which has already said what it holds. + expectedClipChanges--; + return; + } + } + // A clip somebody else published replaced ours, so what ours was carrying + // is nobody's to paste any more. + clipboardHolds(0); + } + }); + } catch (Throwable t) { + // A device that will not register the listener keeps the old behaviour, which is + // a hold that outlives the clip rather than a crash on copy. + com.codename1.io.Log.e(t); + synchronized (STAGED_CLIP_FILES) { + clipboardWatched = false; + // Nothing will consume what was counted for the copy this call belongs to. + expectedClipChanges = 0; + } + } + } + + /// Records that this application is about to replace the primary clip, so the watcher does + /// not mistake its own callback for another application's copy, and pins what the clip is + /// about to carry for the length of the attempt. + /// + /// #### Parameters + /// + /// - `clip`: the assembly being published, or zero for a clip with nothing staged + private static void clipboardPublishing(long clip) { + synchronized (STAGED_CLIP_FILES) { + if (clipboardWatched) { + expectedClipChanges++; + } + // Only while something is listening. Counting a copy no callback will ever arrive + // for -- a device that refused the listener -- left the count standing, and if a + // later copy did install the watcher, that phantom swallowed the first genuinely + // foreign clipboard change: the clip stayed pinned and its files stayed out of + // reach of the budget. + publishingClip = clip; + } + } + + /// Ends a publication, either committing it or putting back what it had provisionally + /// taken. + /// + /// #### Parameters + /// + /// - `clip`: the assembly that was being published + /// + /// - `published`: true when setPrimaryClip returned + private static void clipboardPublished(long clip, boolean published) { + synchronized (STAGED_CLIP_FILES) { + publishingClip = 0; + if (!published && expectedClipChanges > 0) { + // No callback is coming for a clip that never reached the clipboard. + expectedClipChanges--; + } + } + if (published) { + // Now, and only now, is the clip the clipboard's -- which is also what stops the + // one it replaced from being pinned. + clipboardHolds(clip); + } + } + + /// Records which clip the system clipboard now holds, or zero for a clip with nothing + /// staged for it. + /// + /// Called for every clip put on the clipboard, plain text included: what matters as much + /// is that the clip it held *before* is not the clipboard's any more, so its files may go + /// when they age out. + static void clipboardHolds(long clip) { + synchronized (STAGED_CLIP_FILES) { + clipboardClip = clip; + // Letting go is as good a moment to reconsider as staging is: a clip that was + // over the budget on its own could not be reclaimed while it was held, and + // nothing else would have looked at it again until some later transfer staged + // a file -- which for an application that drags one large payload and then + // stops is never. + reclaimStagedClipFiles(); + } + } + + /// The clip a drag is carrying right now, so a release queued for one drag can tell + /// whether it is still the drag whose hold it is about to end. + static long draggingClip() { + synchronized (STAGED_CLIP_FILES) { + return draggingClip; + } + } + + /// Ends the hold on one drag's clip, and only that one. + /// + /// A drop's release is queued onto the event dispatch thread, and a callback that enters a + /// nested event loop can let another drag start before it runs. Clearing the shared slot + /// unconditionally then let go of the *new* drag's clip, whose files a cache over budget + /// could delete while the receiving application was still to read them. + /// + /// #### Parameters + /// + /// - `clip`: the clip whose drag has finished, or zero to release whatever is held + static void releaseDragHold(long clip) { + synchronized (STAGED_CLIP_FILES) { + if (clip != 0 && draggingClip != clip) { + return; + } + // Compared and cleared without letting go of the lock in between. A completion + // listener on the event dispatch thread can start the next drag at any moment, and + // it claims this slot: reading it, releasing the lock and then clearing it let go + // of a drag that had begun after the comparison said it was safe. The body is + // dragHolds(0) written out for that reason and nothing else. + draggingClip = 0; + reclaimStagedClipFiles(); + } + } + + /// Records the clip a drag is carrying, or zero once it has ended. + static void dragHolds(long clip) { + synchronized (STAGED_CLIP_FILES) { + draggingClip = clip; + reclaimStagedClipFiles(); + } + } + + private static void rememberStagedClipFile(Uri uri, File file, boolean transport, + long clip) { + synchronized (STAGED_CLIP_FILES) { + STAGED_CLIP_FILES.remove(uri.toString()); + STAGED_CLIP_FILES.put(uri.toString(), + new StagedClipFile(file.getAbsolutePath(), transport, clip, file.length())); + reclaimStagedClipFiles(); + } + } + + /// Reclaims staged files, oldest first, until what is left fits the budget. + /// + /// Never an assembly whose caller has yet to take it over -- it is still growing, or + /// waiting to be handed to a clipboard or a drag -- and never the one the clipboard, a + /// running drag or a publication in progress is carrying, none of which are superseded by + /// anything however old they are. Called when a file is staged and again when any of those + /// is released, because a clip too large for the budget on its own can only be reclaimed + /// once nothing holds it any more. + private static void reclaimStagedClipFiles() { + synchronized (STAGED_CLIP_FILES) { + long held = 0; + for (StagedClipFile staged : STAGED_CLIP_FILES.values()) { + held += staged.bytes; + } + java.util.Iterator> entries = + STAGED_CLIP_FILES.entrySet().iterator(); + while (held > GENERATED_CLIP_BUDGET && entries.hasNext()) { + StagedClipFile staged = entries.next().getValue(); + if (ASSEMBLING_CLIPS.contains(Long.valueOf(staged.clip)) + || staged.clip == clipboardClip || staged.clip == draggingClip + || staged.clip == publishingClip) { + continue; + } + held -= staged.bytes; + entries.remove(); + deleteStagedClipFile(staged); + } + } + } + + /// Removes a staged file, and the directory it was given to itself when it had one. + /// + /// Best effort by design: a file that will not delete is one the cache directory will + /// eventually reclaim, which is what a cache directory is for -- and is also what bounds + /// the files left behind by a process that ended before it could let go of them. + private static void deleteStagedClipFile(StagedClipFile staged) { + try { + File file = new File(staged.path); + File holder = file.getParentFile(); + if (file.delete() && holder != null + && holder.getName().startsWith(SHARED_COPY_PREFIX)) { + holder.delete(); + } + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + /// True when this content URI is one `#writeAsProviderUri(byte[], java.lang.String, + /// java.lang.String)` minted to carry a representation's bytes, rather than a file the + /// source published. + private static boolean isGeneratedClipFile(Uri uri) { + synchronized (STAGED_CLIP_FILES) { + StagedClipFile staged = STAGED_CLIP_FILES.get(uri.toString()); + return staged != null && staged.transport; + } + } + + /// True when a URI another application put on a clip is one this application may carry. + /// + /// A file: URI, or a bare path, is not. Android has refused to let a clip carrying one + /// cross an application boundary since API 24 -- prepareToLeaveProcess throws for exactly + /// that -- so one arriving here was never published by a well behaved application, and it + /// comes with no grant that would make it readable in the first place. Taking it at its + /// word is worse than useless: the path is read with *this* application's permissions, and + /// republishing it -- a copy, a drag onward -- would hand somebody else a file the sender + /// could not open, named by the sender. A content: URI carries a grant and is the only + /// spelling a clip is entitled to use for a document; everything remote is carried as a + /// URI and never opened as a path. + /// + /// This is about what *arrives*. What the application itself publishes through + /// `ClipboardContent#setFiles(java.lang.String...)` is its own file and is unaffected. + private static boolean mayCarryAcrossApplications(Uri uri) { + String scheme = uri.getScheme(); + if (scheme == null) { + return false; + } + return !"file".equalsIgnoreCase(scheme); + } + + /// True when this URI names something on this device rather than somewhere on the web. + /// + /// A link dragged out of a browser arrives as a text/uri-list item whose URI is https, + /// and calling that a file handed a file-only target a URL through getFiles() as though + /// it were a document on disk. It is still carried, under MIME_URI_LIST, which is what + /// it actually is. + private static boolean namesALocalFile(Uri uri) { + String scheme = uri.getScheme(); + if (scheme == null) { + // A bare path, which is a local file by construction. + return true; + } + // equalsIgnoreCase rather than a fold: it compares character by character and is + // locale independent, which String.toLowerCase() is not. + return "content".equalsIgnoreCase(scheme) || "file".equalsIgnoreCase(scheme); + } + + /// Lowercases ASCII letters only, so the result never depends on the device locale. + /// + /// String.toLowerCase() is locale sensitive, and a Turkish or Azerbaijani default turns + /// I into a dotless i: IMAGE/PNG normalized under one of those locales stopped being + /// equal to image/png, so every check against the framework's own constants failed and + /// a port no longer recognized the representation at all. MIME types, schemes and file + /// extensions are ASCII by definition, which is what makes folding only ASCII correct + /// rather than merely safe. Codename One has no java.util.Locale to ask for the root + /// locale instead. + /// True when this value opens with that scheme, whatever case it was written in. + /// + /// A URI scheme is case insensitive by specification, and a case-sensitive prefix test + /// read FILE:///sdcard/report.pdf as a literal path -- a file that does not exist, so + /// the only representation a file-only clip had was quietly dropped. + /// + /// #### Parameters + /// + /// - `value`: the path or URI + /// + /// - `scheme`: the scheme to test for, colon included, in lower case + private static boolean hasScheme(String value, String scheme) { + return value.length() >= scheme.length() + && value.regionMatches(true, 0, scheme, 0, scheme.length()); + } + + static String asciiLower(String s) { + StringBuilder out = new StringBuilder(s.length()); + for (int iter = 0; iter < s.length(); iter++) { + char c = s.charAt(iter); + out.append(c >= 'A' && c <= 'Z' ? (char) (c + 32) : c); + } + return out.toString(); + } + + /// A MIME type without its parameters, lower case, or null when there is none. + private static String bareMimeType(String type) { + if (type == null) { + return null; + } + int semicolon = type.indexOf(';'); + String bare = asciiLower((semicolon < 0 ? type : type.substring(0, semicolon)).trim()); + return bare.length() == 0 ? null : bare; + } + + /// Reads a content URI's bytes when something actually asks for them. + /// + /// The drag-and-drop permission this drop was granted lasts for the life of the activity -- + /// nothing calls release() on it -- so a read that happens a moment later on the event + /// dispatch thread still succeeds. Once read the value is kept, so a target that reads + /// during the drop may hold the result for as long as it likes. + /// + /// What it does not survive is the activity: a representation *first* asked for after the + /// activity that received the drop has been destroyed reads through a grant that no + /// longer exists, and answers null. Copying every representation into this application's + /// own storage at drop time is the only way round that, and it is the wrong trade -- it + /// is the eager read that stalls the platform's thread with a document nobody asked for, + /// which is why this is a promise in the first place. Component.nativeDrop says so where + /// an application will read it. + private ClipboardDataProvider uriBytesProvider(final Uri uri) { + return new ClipboardDataProvider() { + @Override + public Object getClipboardData(String mimeType) { + try { + InputStream in = getContext().getContentResolver().openInputStream(uri); + if (in == null) { + return null; + } + byte[] bytes; + try { + bytes = Util.readInputStream(in); + } finally { + in.close(); + } + // A text type reads back as text: the framework's getText() answers null + // for a byte array, so a Markdown representation that went out as a typed + // URI would come back unreadable to the very API that asked for it. + if (bytes != null && mimeType != null && mimeType.startsWith("text/")) { + return new String(bytes, "UTF-8"); + } + return bytes; + } catch (Throwable t) { + com.codename1.io.Log.e(t); + return null; + } + } + }; + } + + /// The `text/uri-list` spelling of the URIs a clip carried: one per line, CRLF separated + /// as RFC 2483 has it. + private static String uriListOf(List uris) { + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter < uris.size(); iter++) { + if (iter > 0) { + out.append("\r\n"); + } + out.append(uris.get(iter)); + } + return out.toString(); + } + + /// Fills the MIME types the drag advertised but the read did not produce, from what it did. + /// + /// An Android clip carries a single text payload and the description says what that text + /// is, so a type the description names and the clip did not otherwise yield is that text -- + /// `text/uri-list` excepted, which is the list of URIs the clip carried. A type with no + /// value to give it is left absent rather than advertised empty. + private void fillAdvertisedTypes(ClipboardContent content, ClipDescription description, + String plain, List publishedUris, List unnamedUris) { + List unsatisfiedBinary = new ArrayList(); + List unsatisfiedText = new ArrayList(); + for (int iter = 0; iter < description.getMimeTypeCount(); iter++) { + String mime = description.getMimeType(iter); + if (mime == null) { + continue; + } + mime = asciiLower(mime); + if (content.hasMimeType(mime)) { + continue; + } + if ("text/uri-list".equals(mime)) { + // Every URI, not only the ones that name files: a URI list is a URI list, and a + // link the source published belongs in it even though it is not a document. + if (!publishedUris.isEmpty()) { + content.setData(ClipboardContent.MIME_URI_LIST, uriListOf(publishedUris)); + } + continue; + } + // A text type is *not* assumed to be the carried text here. The exporter writes a + // text representation whose value differs from that text into a content URI exactly + // as it writes binary, so assuming made a target asking for an application's own + // text format receive the plain fallback instead of the value it published. + if (mime.startsWith("text/")) { + unsatisfiedText.add(mime); + } else { + unsatisfiedBinary.add(mime); + } + } + List unclaimed = new ArrayList(unnamedUris); + for (int iter = unclaimed.size() - 1; iter >= 0; iter--) { + Uri uri = unclaimed.get(iter); + String named = mimeForUnnamedUri(uri, unsatisfiedBinary, unsatisfiedText); + if (named != null) { + content.setDataProvider(named, uriBytesProvider(uri)); + unsatisfiedBinary.remove(named); + unsatisfiedText.remove(named); + unclaimed.remove(iter); + } + } + if (unclaimed.size() == 1) { + // One representation the clip promised and could not produce, and one URI whose + // type Android could not name: the pairing cannot be anything else. A byte backed + // type is taken first because bytes can only have come from a URI, where a text one + // may also be another reading of the text the clip carries. With more of either it + // could be, and inventing an association would tell a target it has something it + // may not -- which is the failure this whole path exists to avoid -- so those are + // left absent and the target correctly refuses. + String only = null; + if (unsatisfiedBinary.size() == 1) { + only = unsatisfiedBinary.remove(0); + } else if (unsatisfiedBinary.isEmpty() && unsatisfiedText.size() == 1) { + only = unsatisfiedText.remove(0); + } + if (only != null) { + content.setDataProvider(only, uriBytesProvider(unclaimed.get(0))); + } + } + if (plain != null) { + for (int iter = 0; iter < unsatisfiedText.size(); iter++) { + // What is left: an Android clip carries a single text payload, and a text type + // no URI accounted for is another name for that payload -- which is exactly how + // the exporter advertises a reading whose value *is* the carried text. + content.setData(unsatisfiedText.get(iter), plain); + } + if (unsatisfiedText.isEmpty() && unsatisfiedBinary.size() == 1 && unclaimed.isEmpty() + && !content.hasMimeType(ClipboardContent.MIME_TEXT)) { + // And a type that is not text, when it is the only thing left unaccounted for + // and the carried text was not published as text either -- which is the clip + // that named one format of its own and put the value in the item, and only + // that clip. The pairing cannot be anything else, the same reasoning the one + // unclaimed URI above is matched by. + content.setData(unsatisfiedBinary.get(0), plain); + } + } + } + + /// The one type a clip advertises when that is all it advertises and it is not plain + /// text, or null. + /// + /// A clip that names a single format of its own is the case where the item's text is that + /// format rather than a plain reading of it; anything advertising text/plain, or more than + /// one type, is read the way it always was. + private static String soleAdvertisedType(ClipDescription description) { + if (description == null || description.getMimeTypeCount() != 1) { + return null; + } + String mime = description.getMimeType(0); + if (mime == null) { + return null; + } + mime = asciiLower(mime); + return ClipboardContent.MIME_TEXT.equals(mime) ? null : mime; + } + + /// The type an untyped content URI was published as, recovered from the name of the file it + /// serves. + /// + /// ContentResolver could not name it -- MimeTypeMap has no entry for an application defined + /// type, so the FileProvider serving it reports octet-stream. What this application wrote + /// still says so in its own name, exactly, which is the answer; a clip from elsewhere gets + /// the extension read as a type, which is a good guess and is treated as one -- an extension + /// two advertised types share answers nothing. + private String mimeForUnnamedUri(Uri uri, List binary, List text) { + String name = displayNameFor(uri); + if (name == null) { + return null; + } + String declared = decodeMimeFromFileName(name); + if (declared != null) { + // Written by this application, which named the type outright. It answers even when + // it names a type that is not among the candidates -- that means the type is already + // satisfied, or was never advertised, and either way this URI is not the missing + // one. Guessing past an exact answer would be strictly worse. + return binary.contains(declared) || text.contains(declared) ? declared : null; + } + int dot = name.lastIndexOf('.'); + if (dot < 0 || dot == name.length() - 1) { + return null; + } + String extension = asciiLower(name.substring(dot + 1)); + String match = null; + for (int pass = 0; pass < 2; pass++) { + List candidates = pass == 0 ? binary : text; + for (int iter = 0; iter < candidates.size(); iter++) { + String candidate = candidates.get(iter); + if (extension.equals(extensionForMime(candidate))) { + if (match != null) { + return null; + } + match = candidate; + } + } + } + return match; + } + + /// The file name behind a content URI, which is where the extension an exporter chose + /// survives. A provider that will not answer OpenableColumns still has the name in its path. + private String displayNameFor(Uri uri) { + Cursor cursor = null; + try { + cursor = getContext().getContentResolver().query(uri, + new String[]{android.provider.OpenableColumns.DISPLAY_NAME}, + null, null, null); + if (cursor != null && cursor.moveToFirst()) { + int column = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME); + if (column >= 0) { + String name = cursor.getString(column); + if (name != null && name.length() > 0) { + return name; + } + } + } + } catch (Throwable t) { + // Fall through to the path below. + } finally { + if (cursor != null) { + cursor.close(); + } + } + return uri.getLastPathSegment(); + } + + public static MediaException createMediaException(int extra) { + MediaErrorType type; + String message; + switch (extra) { + + case MediaPlayer.MEDIA_ERROR_IO: + type = MediaErrorType.Network; + message = "IO error"; + break; + case MediaPlayer.MEDIA_ERROR_MALFORMED: + type = MediaErrorType.Decode; + message = "Media was malformed"; + break; + case MediaPlayer.MEDIA_ERROR_NOT_VALID_FOR_PROGRESSIVE_PLAYBACK: + type = MediaErrorType.SrcNotSupported; + message = "Not valie for progressive playback"; + break; + case MediaPlayer.MEDIA_ERROR_SERVER_DIED: + type = MediaErrorType.Network; + message = "Server died"; + break; + case MediaPlayer.MEDIA_ERROR_TIMED_OUT: + type = MediaErrorType.Network; + message = "Timed out"; + break; + + case MediaPlayer.MEDIA_ERROR_UNKNOWN: + type = MediaErrorType.Network; + message = "Unknown error"; + break; + case MediaPlayer.MEDIA_ERROR_UNSUPPORTED: + type = MediaErrorType.SrcNotSupported; + message = "Unsupported media"; + break; + default: + type = MediaErrorType.Network; + message = "Unknown error"; + } + return new MediaException(type, message); + } + + + public class Video extends AndroidImplementation.AndroidPeer implements AsyncMedia { + + private VideoView nativeVideo; + private Activity activity; + private boolean fullScreen = false; + private Rectangle bounds; + private boolean nativeController = true; + private boolean nativePlayer; + private Form curentForm; + private List completionHandlers; + private final EventDispatcher errorListeners = new EventDispatcher(); + + private final EventDispatcher stateChangeListeners = new EventDispatcher(); + private PlayRequest pendingPlayRequest; + private PauseRequest pendingPauseRequest; + private boolean androidSeekPreviewWorkaroundEnabled; + + @Override + public State getState() { + if (isPlaying()) { + return State.Playing; + } else { + return State.Paused; + } + } + + protected void fireMediaStateChange(State newState) { + if (stateChangeListeners.hasListeners() && newState != getState()) { + stateChangeListeners.fireActionEvent(new MediaStateChangeEvent(this, getState(), newState)); + } + } + + @Override + public void addMediaStateChangeListener(ActionListener l) { + + stateChangeListeners.addListener(l); + } + + @Override + public void removeMediaStateChangeListener(ActionListener l) { + + stateChangeListeners.removeListener(l); + } + + @Override + public void addMediaErrorListener(ActionListener l) { + errorListeners.addListener(l); + } + + @Override + public void removeMediaErrorListener(ActionListener l) { + errorListeners.removeListener(l); + } + + @Override + public PlayRequest playAsync() { + final PlayRequest out = new PlayRequest(); + out.ready(new SuccessCallback() { + @Override + public void onSucess(AsyncMedia value) { + if (out == pendingPlayRequest) { + pendingPlayRequest = null; + } + } + }).except(new SuccessCallback() { + @Override + public void onSucess(Throwable value) { + if (out == pendingPlayRequest) { + pendingPlayRequest = null; + } + } + }); + ; + if (pendingPlayRequest != null) { + pendingPlayRequest.ready(new SuccessCallback() { + @Override + public void onSucess(AsyncMedia value) { + if (!out.isDone()) { + out.complete(value); + } + } + }).except(new SuccessCallback() { + @Override + public void onSucess(Throwable value) { + if (!out.isDone()) { + out.error(value); + } + } + }); + return out; + } else { + pendingPlayRequest = out; + } + + ActionListener onStateChange = new ActionListener() { + @Override + public void actionPerformed(MediaStateChangeEvent evt) { + stateChangeListeners.removeListener(this); + if (!out.isDone()) { + if (evt.getNewState() == State.Playing) { + out.complete(Video.this); + } + } + + } + + }; + + stateChangeListeners.addListener(onStateChange); + play(); + + return out; + + } + + @Override + public PauseRequest pauseAsync() { + final PauseRequest out = new PauseRequest(); + out.ready(new SuccessCallback() { + @Override + public void onSucess(AsyncMedia value) { + if (out == pendingPauseRequest) { + pendingPauseRequest = null; + } + } + }).except(new SuccessCallback() { + @Override + public void onSucess(Throwable value) { + if (out == pendingPauseRequest) { + pendingPauseRequest = null; + } + } + }); + ; + if (pendingPauseRequest != null) { + pendingPauseRequest.ready(new SuccessCallback() { + @Override + public void onSucess(AsyncMedia value) { + if (!out.isDone()) { + out.complete(value); + } + } + }).except(new SuccessCallback() { + @Override + public void onSucess(Throwable value) { + if (!out.isDone()) { + out.error(value); + } + } + }); + return out; + } else { + pendingPauseRequest = out; + } + + ActionListener onStateChange = new ActionListener() { + @Override + public void actionPerformed(MediaStateChangeEvent evt) { + stateChangeListeners.removeListener(this); + if (!out.isDone()) { + if (evt.getNewState() == State.Paused) { + out.complete(Video.this); + } + } + + } + + }; + + stateChangeListeners.addListener(onStateChange); + play(); + + return out; + } + + + public Video(final VideoView nativeVideo, final Activity activity, final Runnable onCompletion) { + super(new RelativeLayout(activity)); + this.nativeVideo = nativeVideo; + RelativeLayout rl = (RelativeLayout)getNativePeer(); + + rl.addView(nativeVideo); + RelativeLayout.LayoutParams layout = new RelativeLayout.LayoutParams(getWidth(), getHeight()); + layout.addRule(RelativeLayout.CENTER_HORIZONTAL); + layout.addRule(RelativeLayout.CENTER_VERTICAL); + rl.setLayoutParams(layout); + rl.requestLayout(); + + this.activity = activity; + if (nativeController) { + MediaController mc = new AndroidImplementation.CN1MediaController(); + nativeVideo.setMediaController(mc); + } + + nativeVideo.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { + @Override + public void onCompletion(MediaPlayer arg0) { + fireMediaStateChange(State.Paused); + + fireCompletionHandlers(); + } + }); + if (onCompletion != null) { + addCompletionHandler(onCompletion); + } + + nativeVideo.setOnErrorListener(new MediaPlayer.OnErrorListener() { + @Override + public boolean onError(MediaPlayer mp, int what, int extra) { + com.codename1.io.Log.p("Media player error: " + mp + " what: " + what + " extra: " + extra); + errorListeners.fireActionEvent(new MediaErrorEvent(Video.this, createMediaException(extra))); + fireMediaStateChange(State.Paused); + fireCompletionHandlers(); + return true; + } + }); + + } + + + + private void fireCompletionHandlers() { + if (completionHandlers != null && !completionHandlers.isEmpty()) { + Display.getInstance().callSerially(new Runnable() { + public void run() { + if (completionHandlers != null && !completionHandlers.isEmpty()) { + ArrayList toRun; + synchronized(Video.this) { + toRun = new ArrayList(completionHandlers); + } + for (Runnable r : toRun) { + r.run(); + } + } + } + }); + } + } + private void setNativeController(final boolean nativeController) { + if (nativeController != this.nativeController) { + this.nativeController = nativeController; + if (nativeVideo != null) { + Activity activity = getActivity(); + if (activity != null) { + activity.runOnUiThread(new Runnable() { + + @Override + public void run() { + if (nativeVideo != null) { + MediaController mc = new AndroidImplementation.CN1MediaController(); + nativeVideo.setMediaController(mc); + if (!nativeController) mc.setVisibility(View.GONE); + else mc.setVisibility(View.VISIBLE); + + } + } + + }); + } + + } + } + } + + @Override + public void init() { + super.init(); + setVisible(true); + } + + public void prepare() { + } + + @Override + public void play() { + Component cmp = getVideoComponent(); + if (cmp.getParent() == null && nativePlayer && curentForm == null) { + curentForm = Display.getInstance().getCurrent(); + Form f = new Form(); + f.setBackCommand(new Command("") { + @Override + public void actionPerformed(ActionEvent evt) { + Component cmp = getVideoComponent(); + if(cmp != null) { + cmp.remove(); + pause(); + } + curentForm.showBack(); + curentForm = null; + } + }); + f.setLayout(new BorderLayout()); + + if(cmp.getParent() != null) { + cmp.getParent().removeComponent(cmp); + } + f.addComponent(BorderLayout.CENTER, cmp); + f.show(); + } + nativeVideo.start(); + fireMediaStateChange(State.Playing); + } + + @Override + public void pause() { + if(nativeVideo != null && nativeVideo.canPause()){ + nativeVideo.pause(); + fireMediaStateChange(State.Paused); + } + } + + @Override + public void cleanup() { + if(nativeVideo != null) { + nativeVideo.stopPlayback(); + fireMediaStateChange(State.Paused); + } + nativeVideo = null; + if (nativePlayer && curentForm != null) { + curentForm.showBack(); + curentForm = null; + } + } + + @Override + public int getTime() { + if(nativeVideo != null){ + return nativeVideo.getCurrentPosition(); + } + return -1; + } + + @Override + public void setTime(int time) { + if(nativeVideo != null){ + final int seekTime = time; + activity.runOnUiThread(new Runnable() { + @Override + public void run() { + if (nativeVideo == null) { + return; + } + nativeVideo.seekTo(seekTime); + if (androidSeekPreviewWorkaroundEnabled && !nativeVideo.isPlaying()) { + final int refreshSeekTime = Math.max(0, seekTime - 1); + nativeVideo.postDelayed(new Runnable() { + @Override + public void run() { + if (nativeVideo != null && !nativeVideo.isPlaying()) { + nativeVideo.seekTo(refreshSeekTime); + nativeVideo.seekTo(seekTime); + nativeVideo.invalidate(); + } + } + }, 60); + } + } + }); + } + } + + @Override + public int getDuration() { + if(nativeVideo != null){ + return nativeVideo.getDuration(); + } + return -1; + } + + @Override + public void setVolume(int vol) { + // float v = ((float) vol) / 100.0F; + AudioManager am = (AudioManager) activity.getSystemService(Context.AUDIO_SERVICE); + int max = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC); + am.setStreamVolume(AudioManager.STREAM_MUSIC, vol, 0); + } + + @Override + public int getVolume() { + AudioManager am = (AudioManager) activity.getSystemService(Context.AUDIO_SERVICE); + return am.getStreamVolume(AudioManager.STREAM_MUSIC); + } + + @Override + public boolean isVideo() { + return true; + } + + @Override + public boolean isFullScreen() { + return fullScreen || nativePlayer; + } + + @Override + public void setFullScreen(boolean fullScreen) { + this.fullScreen = fullScreen; + if (fullScreen) { + bounds = new Rectangle(getBounds()); + setX(0); + setY(0); + setWidth(Display.getInstance().getDisplayWidth()); + setHeight(Display.getInstance().getDisplayHeight()); + } else { + if (bounds != null) { + setX(bounds.getX()); + setY(bounds.getY()); + setWidth(bounds.getSize().getWidth()); + setHeight(bounds.getSize().getHeight()); + } + } + repaint(); + } + + @Override + public Component getVideoComponent() { + return this; + } + + @Override + protected Dimension calcPreferredSize() { + if(nativeVideo != null){ + return new Dimension(nativeVideo.getWidth(), nativeVideo.getHeight()); + } + return new Dimension(); + } + + @Override + public void setWidth(final int width) { + super.setWidth(width); + final int currH = getHeight(); + if(nativeVideo != null){ + activity.runOnUiThread(new Runnable() { + + public void run() { + float nh = nativeVideo.getHeight(); + float nw = nativeVideo.getWidth(); + float w = width; + float h = currH; + if (nh != 0 && nw != 0) { + h = width * nh / nw; + if (h > getHeight()) { + h = getHeight(); + w = h * nw / nh; + } + if (w > getWidth()) { + w = getWidth(); + h = w * nh / nw; + } + } + RelativeLayout.LayoutParams layout = new RelativeLayout.LayoutParams((int)w, (int)h); + layout.addRule(RelativeLayout.CENTER_HORIZONTAL); + layout.addRule(RelativeLayout.CENTER_VERTICAL); + nativeVideo.setLayoutParams(layout); + nativeVideo.requestLayout(); + nativeVideo.getHolder().setSizeFromLayout(); + } + }); + } + } + + @Override + public void setHeight(final int height) { + super.setHeight(height); + final int currW = getWidth(); + if(nativeVideo != null){ + activity.runOnUiThread(new Runnable() { + + public void run() { + float nh = nativeVideo.getHeight(); + float nw = nativeVideo.getWidth(); + float h = height; + float w = currW; + if (nh != 0 && nw != 0) { + w = h * nw / nh; + if (h > getHeight()) { + h = getHeight(); + w = h * nw / nh; + } + if (w > getWidth()) { + w = getWidth(); + h = w * nh / nw; + } + } + RelativeLayout.LayoutParams layout = new RelativeLayout.LayoutParams((int)w, (int)h); + layout.addRule(RelativeLayout.CENTER_HORIZONTAL); + layout.addRule(RelativeLayout.CENTER_VERTICAL); + nativeVideo.setLayoutParams(layout); + nativeVideo.requestLayout(); + nativeVideo.getHolder().setSizeFromLayout(); + } + }); + } + } + + @Override + public void setNativePlayerMode(boolean nativePlayer) { + this.nativePlayer = nativePlayer; + } + + @Override + public boolean isNativePlayerMode() { + return nativePlayer; + } + + @Override + public boolean isPlaying() { + if(nativeVideo != null){ + return nativeVideo.isPlaying(); + } + return false; + } + + public void setVariable(String key, Object value) { + if (nativeVideo != null && Media.VARIABLE_NATIVE_CONTRLOLS_EMBEDDED.equals(key) && value instanceof Boolean) { + setNativeController((Boolean)value); + return; + } + if (Media.VARIABLE_ANDROID_SEEK_PREVIEW_WORKAROUND.equals(key) && value instanceof Boolean) { + androidSeekPreviewWorkaroundEnabled = ((Boolean)value).booleanValue(); + } + } + + public Object getVariable(String key) { + return null; + } + + @Override + public void addMediaCompletionHandler(Runnable onComplete) { + addCompletionHandler(onComplete); + } + + + + private void addCompletionHandler(Runnable onCompletion) { + synchronized(this) { + if (completionHandlers == null) { + completionHandlers = new ArrayList(); + } + completionHandlers.add(onCompletion); + } + } + + private void removeCompletionHandler(Runnable onCompletion) { + synchronized(this) { + if (completionHandlers != null) { + completionHandlers.remove(onCompletion); + } + } + } + + + } + + + private String getImageFilePath(Uri uri) { + String scheme = uri.getScheme(); + String[] filePathColumn = {MediaStore.Images.Media.DATA}; + Cursor cursor = getContext().getContentResolver().query( + android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + new String[]{ MediaStore.Images.Media.DATA}, + null, + null, + null + ); + // Some gallery providers may return an empty cursor on modern Android builds. + String filePath = null; + if (cursor != null) { + try { + int columnIndex = cursor.getColumnIndex(filePathColumn[0]); + if (columnIndex >= 0 && cursor.moveToFirst()) { + filePath = cursor.getString(columnIndex); + } + } finally { + cursor.close(); + } + } + + if (filePath == null || "content".equals(scheme)) { + //if the file is not on the filesystem download it and save it + //locally + InputStream inputStream = null; + OutputStream tmp = null; + try { + inputStream = getContext().getContentResolver().openInputStream(uri); + if (inputStream != null) { + String name = new File(uri.toString()).getName();//getContentName(getContext().getContentResolver(), uri); + if (name != null) { + String homePath = getAppHomePath(); + if (homePath.endsWith("/")) { + homePath = homePath.substring(0, homePath.length()-1); + } + filePath = homePath + + getFileSystemSeparator() + name; + File f = new File(removeFilePrefix(filePath)); + tmp = createFileOuputStream(f); + Util.copy(inputStream, tmp); + } + } + } catch (Exception e) { + com.codename1.io.Log.e(e); + } finally { + Util.cleanup(tmp); + Util.cleanup(inputStream); + } + } + return filePath; + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent intent) { + + if (requestCode == ZOOZ_PAYMENT) { + ((IntentResultListener) pur).onActivityResult(requestCode, resultCode, intent); + return; + } + + takePersistablePermissionsFromIntent(intent); + + if (requestCode == REQUEST_SELECT_FILE || requestCode == FILECHOOSER_RESULTCODE) { + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + if (requestCode == REQUEST_SELECT_FILE) { + if (uploadMessage == null) return; + Uri[] results = null; + + // Check that the response is a good one + if (resultCode == Activity.RESULT_OK) { + if (intent != null) { + // If there is not data, then we may have taken a photo + String dataString = intent.getDataString(); + ClipData clipData = intent.getClipData(); + + if (clipData != null) { + results = new Uri[clipData.getItemCount()]; + for (int i = 0; i < clipData.getItemCount(); i++) { + ClipData.Item item = clipData.getItemAt(i); + results[i] = item.getUri(); + } + } else if (dataString != null) { + results = new Uri[]{Uri.parse(dataString)}; + } + } + } + + uploadMessage.onReceiveValue(results); + uploadMessage = null; + } + } + else if (requestCode == FILECHOOSER_RESULTCODE) { + if (null == mUploadMessage) { + return; + } + // Use MainActivity.RESULT_OK if you're implementing WebView inside Fragment + // Use RESULT_OK only if you're implementing WebView inside an Activity + Uri result = intent == null || resultCode != Activity.RESULT_OK ? null : intent.getData(); + mUploadMessage.onReceiveValue(result); + mUploadMessage = null; + } + else { + + Toast.makeText(getActivity().getApplicationContext(), "Failed to Upload File", Toast.LENGTH_LONG).show(); + } + return; + } + + + if (resultCode == Activity.RESULT_OK) { + if (requestCode == CAPTURE_IMAGE) { + try { + String imageUri = (String) Storage.getInstance().readObject("imageUri"); + Vector pathandId = StringUtil.tokenizeString(imageUri, ";"); + String path = (String)pathandId.get(0); + String lastId = (String)pathandId.get(1); + Storage.getInstance().deleteStorageFile("imageUri"); + clearMediaDB(lastId, path); + callback.fireActionEvent(new ActionEvent(addFile(path))); + return; + } catch (Exception e) { + e.printStackTrace(); + } + } else if (requestCode == CAPTURE_VIDEO) { + String path = (String) Storage.getInstance().readObject("videoUri"); + Storage.getInstance().deleteStorageFile("videoUri"); + callback.fireActionEvent(new ActionEvent(addFile(path))); + return; + } else if (requestCode == CAPTURE_AUDIO) { + Uri data = intent.getData(); + String path = convertImageUriToFilePath(data, getContext()); + callback.fireActionEvent(new ActionEvent(addFile(path))); + return; + + } else if (requestCode == OPEN_GALLERY_MULTI) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + if(intent.getClipData() != null){ + // If it was a multi-request + ArrayList selectedPaths = new ArrayList(); + int count = intent.getClipData().getItemCount(); + for (int i=0; i= 0 && cursor.moveToFirst()) { + filePath = cursor.getString(columnIndex); + } + } finally { + cursor.close(); + } + } + boolean fileExists = false; + if (filePath != null) { + File file = new File(filePath); + fileExists = file.exists() && file.canRead(); + } + + if (!fileExists && "content".equals(scheme)) { + //if the file is not on the filesystem download it and save it + //locally + try { + InputStream inputStream = getContext().getContentResolver().openInputStream(selectedImage); + if (inputStream != null) { + String name = getContentName(getContext().getContentResolver(), selectedImage); + if (name != null) { + filePath = getAppHomePath() + + getFileSystemSeparator() + name; + File f = new File(removeFilePrefix(filePath)); + OutputStream tmp = createFileOuputStream(f); + byte[] buffer = new byte[1024]; + int read = -1; + while ((read = inputStream.read(buffer)) > -1) { + tmp.write(buffer, 0, read); + } + tmp.close(); + inputStream.close(); + } + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + if (filePath == null) { + callback.fireActionEvent(null); + return; + } + + callback.fireActionEvent(new ActionEvent(new String[]{filePath})); + return; + } else if (requestCode == OPEN_GALLERY) { + + Uri selectedImage = intent.getData(); + String scheme = intent.getScheme(); + + String[] filePathColumn = {MediaStore.Images.Media.DATA}; + Cursor cursor = getContext().getContentResolver().query(selectedImage, filePathColumn, null, null, null); + + // Some gallery providers may return an empty cursor on modern Android builds. + String filePath = null; + if (cursor != null) { + try { + int columnIndex = cursor.getColumnIndex(filePathColumn[0]); + if (columnIndex >= 0 && cursor.moveToFirst()) { + filePath = cursor.getString(columnIndex); + } + } finally { + cursor.close(); + } + } + boolean fileExists = false; + if (filePath != null) { + File file = new File(filePath); + fileExists = file.exists() && file.canRead(); + } + + if (!fileExists && "content".equals(scheme)) { + //if the file is not on the filesystem download it and save it + //locally + try { + InputStream inputStream = getContext().getContentResolver().openInputStream(selectedImage); + if (inputStream != null) { + String name = getContentName(getContext().getContentResolver(), selectedImage); + if (name != null) { + filePath = getAppHomePath() + + getFileSystemSeparator() + name; + File f = new File(removeFilePrefix(filePath)); + OutputStream tmp = createFileOuputStream(f); + byte[] buffer = new byte[1024]; + int read = -1; + while ((read = inputStream.read(buffer)) > -1) { + tmp.write(buffer, 0, read); + } + tmp.close(); + inputStream.close(); + } + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + if (filePath == null) { + callback.fireActionEvent(null); + return; + } + + callback.fireActionEvent(new ActionEvent(filePath)); + return; + } else { + if(callback != null) { + callback.fireActionEvent(new ActionEvent("ok")); + } + return; + } + } + //clean imageUri + String imageUri = (String) Storage.getInstance().readObject("imageUri"); + if(imageUri != null){ + Storage.getInstance().deleteStorageFile("imageUri"); + } + + if(callback != null) { + callback.fireActionEvent(null); + } + } + + + + @Override + public void capturePhoto(ActionListener response) { + if (getActivity() == null) { + throw new RuntimeException("Cannot capture photo in background mode"); + } + if (PermissionsHelper.requiresExternalStoragePermissionForMediaAccess()) { + if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to take a picture")){ + return; + } + } + + if (getRequestedPermissions().contains(Manifest.permission.CAMERA)) { + // Normally we don't need to request the CAMERA permission since we use + // the ACTION_IMAGE_CAPTURE intent, which handles permissions itself. + // BUT: If the camera permission is included in the Manifest file, the + // intent will defer to the app's permissions, and on Android 6, + // the permission is denied unless we do the runtime check for permission. + // See https://github.com/codenameone/CodenameOne/issues/2409#issuecomment-391696058 + if(!checkForPermission(Manifest.permission.CAMERA, "This is required to take a picture")){ + return; + } + } + callback = new EventDispatcher(); + callback.addListener(response); + Intent intent = new Intent(android.provider.MediaStore.ACTION_IMAGE_CAPTURE); + + File newFile = getOutputMediaFile(false); + newFile.getParentFile().mkdirs(); + newFile.getParentFile().setWritable(true, false); + //Uri imageUri = Uri.fromFile(newFile); + Uri imageUri = FileProvider.getUriForFile(getContext(), getContext().getPackageName()+".provider", newFile); + intent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, imageUri); + + String lastImageID = getLastImageId(); + Storage.getInstance().writeObject("imageUri", newFile.getAbsolutePath() + ";" + lastImageID); + + intent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, imageUri); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + + if (Build.VERSION.SDK_INT < 21) { + List resInfoList = getContext().getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); + for (ResolveInfo resolveInfo : resInfoList) { + String packageName = resolveInfo.activityInfo.packageName; + getContext().grantUriPermission(packageName, imageUri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + } + + getActivity().startActivityForResult(intent, CAPTURE_IMAGE); + } + + @Override + public void captureVideo(ActionListener response) { + captureVideo(null, response); + } + + @Override + public void captureVideo(VideoCaptureConstraints cnst, ActionListener response) { + if (getActivity() == null) { + throw new RuntimeException("Cannot capture video in background mode"); + } + if (PermissionsHelper.requiresExternalStoragePermissionForMediaAccess()) { + if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to take a video")){ + return; + } + } + + if (getRequestedPermissions().contains(Manifest.permission.CAMERA)) { + // Normally we don't need to request the CAMERA permission since we use + // the ACTION_VIDEO_CAPTURE intent, which handles permissions itself. + // BUT: If the camera permission is included in the Manifest file, the + // intent will defer to the app's permissions, and on Android 6, + // the permission is denied unless we do the runtime check for permission. + // See https://github.com/codenameone/CodenameOne/issues/2409#issuecomment-391696058 + if(!checkForPermission(Manifest.permission.CAMERA, "This is required to take a video")){ + return; + } + } + callback = new EventDispatcher(); + callback.addListener(response); + Intent intent = new Intent(android.provider.MediaStore.ACTION_VIDEO_CAPTURE); + if (cnst != null) { + switch (cnst.getQuality()) { + case VideoCaptureConstraints.QUALITY_LOW: + intent.putExtra(MediaStore.EXTRA_VIDEO_QUALITY, 0); + break; + case VideoCaptureConstraints.QUALITY_HIGH: + intent.putExtra(MediaStore.EXTRA_VIDEO_QUALITY, 1); + break; + } + + if (cnst.getMaxFileSize() > 0) { + intent.putExtra(MediaStore.EXTRA_SIZE_LIMIT, cnst.getMaxFileSize()); + } + if (cnst.getMaxLength() > 0) { + intent.putExtra(MediaStore.EXTRA_DURATION_LIMIT, cnst.getMaxLength()); + } + } + + + File newFile = getOutputMediaFile(true); + newFile.getParentFile().mkdirs(); + newFile.getParentFile().setWritable(true, false); + Uri videoUri = FileProvider.getUriForFile(getContext(), getContext().getPackageName()+".provider", newFile); + + Storage.getInstance().writeObject("videoUri", newFile.getAbsolutePath()); + + intent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, videoUri); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + if (Build.VERSION.SDK_INT < 21) { + List resInfoList = getContext().getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); + for (ResolveInfo resolveInfo : resInfoList) { + String packageName = resolveInfo.activityInfo.packageName; + getContext().grantUriPermission(packageName, videoUri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + } + + this.getActivity().startActivityForResult(intent, CAPTURE_VIDEO); + } + + public void captureAudio(final ActionListener response) { + + if(!checkForPermission(Manifest.permission.RECORD_AUDIO, "This is required to record the audio")){ + return; + } + + try { + final Form current = Display.getInstance().getCurrent(); + + final File temp = File.createTempFile("mtmp", ".3gpp"); + temp.deleteOnExit(); + + if (recorder != null) { + recorder.release(); + } + recorder = new MediaRecorder(); + recorder.setAudioSource(MediaRecorder.AudioSource.MIC); + recorder.setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP); + recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AMR_WB); + recorder.setOutputFile(temp.getAbsolutePath()); + + final Form recording = new Form("Recording"); + recording.setTransitionInAnimator(CommonTransitions.createEmpty()); + recording.setTransitionOutAnimator(CommonTransitions.createEmpty()); + recording.setLayout(new BorderLayout()); + + recorder.prepare(); + recorder.start(); + + final Label time = new Label("00:00"); + time.getAllStyles().setAlignment(Component.CENTER); + Font f = Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_LARGE); + f = f.derive(getDisplayHeight() / 10, Font.STYLE_PLAIN); + time.getAllStyles().setFont(f); + recording.addComponent(BorderLayout.CENTER, time); + + recording.registerAnimated(new Animation() { + + long current = System.currentTimeMillis(); + long zero = current; + int sec = 0; + + public boolean animate() { + long now = System.currentTimeMillis(); + if (now - current > 1000) { + current = now; + sec++; + return true; + } + return false; + } + + public void paint(Graphics g) { + int seconds = sec % 60; + int minutes = sec / 60; + + String secStr = seconds < 10 ? "0" + seconds : "" + seconds; + String minStr = minutes < 10 ? "0" + minutes : "" + minutes; + + String txt = minStr + ":" + secStr; + time.setText(txt); + } + }); + + Container south = new Container(new com.codename1.ui.layouts.GridLayout(1, 2)); + Command cancel = new Command("Cancel") { + + @Override + public void actionPerformed(ActionEvent evt) { + if (recorder != null) { + recorder.stop(); + recorder.release(); + recorder = null; + } + current.showBack(); + response.actionPerformed(null); + } + + }; + recording.setBackCommand(cancel); + south.add(new com.codename1.ui.Button(cancel)); + south.add(new com.codename1.ui.Button(new Command("Save") { + + @Override + public void actionPerformed(ActionEvent evt) { + if (recorder != null) { + recorder.stop(); + recorder.release(); + recorder = null; + } + current.showBack(); + response.actionPerformed(new ActionEvent(temp.getAbsolutePath())); + } + + })); + recording.addComponent(BorderLayout.SOUTH, south); + recording.show(); + + } catch (IOException ex) { + ex.printStackTrace(); + throw new RuntimeException("failed to start audio recording"); + } + + } + + /** + * Opens the device image gallery + * + * @param response callback for the resulting image + * + * + * DISABLING: openGallery() should take care of this + public void openImageGallery(ActionListener response) { + if (getActivity() == null) { + throw new RuntimeException("Cannot open image gallery in background mode"); + } + if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to browse the photos")){ + return; + } + + if(editInProgress()) { + stopEditing(true); + } + + callback = new EventDispatcher(); + callback.addListener(response); + Intent galleryIntent = new Intent(Intent.ACTION_PICK, android.provider.MediaStore.Images.Media.INTERNAL_CONTENT_URI); + this.getActivity().startActivityForResult(galleryIntent, OPEN_GALLERY); + } + * */ + + @Override + public boolean isGalleryTypeSupported(int type) { + if (super.isGalleryTypeSupported(type)) { + return true; + } + if (type == -9999 || type == -9998) { + return true; + } + if (android.os.Build.VERSION.SDK_INT >= 16) { + switch (type) { + + case Display.GALLERY_ALL_MULTI: + case Display.GALLERY_VIDEO_MULTI: + case Display.GALLERY_IMAGE_MULTI: + return true; + } + } + return false; + } + + + + public void openGallery(final ActionListener response, int type){ + if (!isGalleryTypeSupported(type)) { + throw new IllegalArgumentException("Gallery type "+type+" not supported on this platform."); + } + if (getActivity() == null) { + throw new RuntimeException("Cannot open galery in background mode"); + } + if (PermissionsHelper.requiresExternalStoragePermissionForMediaAccess()) { + if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to browse the photos")){ + return; + } + } + if(editInProgress()) { + stopEditing(true); + } + final boolean multi; + switch (type) { + case Display.GALLERY_ALL_MULTI: + multi=true; + type = Display.GALLERY_ALL; + break; + case Display.GALLERY_VIDEO_MULTI: + multi=true; + type = Display.GALLERY_VIDEO; + break; + case Display.GALLERY_IMAGE_MULTI: + multi = true; + type = Display.GALLERY_IMAGE; + break; + case -9998: + multi = true; + type = -9999; + break; + default: + multi = false; + } + + callback = new EventDispatcher(); + callback.addListener(response); + Intent galleryIntent = new Intent(Intent.ACTION_PICK, android.provider.MediaStore.Images.Media.INTERNAL_CONTENT_URI); + galleryIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + if (multi) { + galleryIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); + } + if(type == Display.GALLERY_VIDEO){ + galleryIntent.setType("video/*"); + }else if(type == Display.GALLERY_IMAGE){ + galleryIntent.setType("image/*"); + }else if(type == Display.GALLERY_ALL){ + galleryIntent.setType("image/* video/*"); + }else if (type == -9999) { + galleryIntent = new Intent(); + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { + galleryIntent.setAction(Intent.ACTION_OPEN_DOCUMENT); + } else { + galleryIntent.setAction(Intent.ACTION_GET_CONTENT); + } + galleryIntent.addCategory(Intent.CATEGORY_OPENABLE); + galleryIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { + galleryIntent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); + } + + // set MIME type for image + galleryIntent.setType("*/*"); + galleryIntent.putExtra(Intent.EXTRA_MIME_TYPES, Display.getInstance().getProperty("android.openGallery.accept", "*/*").split(",")); + }else{ + galleryIntent.setType("*/*"); + } + this.getActivity().startActivityForResult(galleryIntent, multi ? OPEN_GALLERY_MULTI: OPEN_GALLERY); + } + + @Override + public void openFileChooser(final ActionListener response, String accept) { + if (getActivity() == null) { + throw new RuntimeException("Cannot open file chooser in background mode"); + } + if(editInProgress()) { + stopEditing(true); + } + callback = new EventDispatcher(); + callback.addListener(response); + Intent pickerIntent = new Intent(); + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { + pickerIntent.setAction(Intent.ACTION_OPEN_DOCUMENT); + } else { + pickerIntent.setAction(Intent.ACTION_GET_CONTENT); + } + pickerIntent.addCategory(Intent.CATEGORY_OPENABLE); + pickerIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { + pickerIntent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); + } + String[] mimeTypes = getFileChooserMimeTypes(accept); + pickerIntent.setType("*/*"); + if (mimeTypes.length > 0) { + pickerIntent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes); + } + this.getActivity().startActivityForResult(pickerIntent, OPEN_GALLERY); + } + + private String[] getFileChooserMimeTypes(String accept) { + if (accept == null || accept.trim().length() == 0 || "*/*".equals(accept.trim())) { + return new String[0]; + } + ArrayList out = new ArrayList(); + String[] tokens = accept.split(","); + for (int iter = 0; iter < tokens.length; iter++) { + String token = tokens[iter].trim(); + if (token.length() == 0 || "*".equals(token)) { + continue; + } + if (token.indexOf('/') > 0) { + out.add(token); + } + } + if (out.isEmpty()) { + out.add("*/*"); + } + return out.toArray(new String[out.size()]); + } + + class NativeImage extends Image { + + public NativeImage(Bitmap nativeImage) { + super(nativeImage); + } + } + + /** + * Persist read permissions that were granted by an activity result so that media playback can + * continue after {@link Activity#onActivityResult(int, int, Intent)} returns. + * + *

Android 13 and newer revoke temporary grants immediately after the callback unless the + * app calls {@link ContentResolver#takePersistableUriPermission(Uri, int)}. Without this call + * {@link #createMedia(String, boolean, Runnable)} loses access to the {@code content://} URI + * provided by the system picker and playback fails on Android 15.

+ */ + private void takePersistablePermissionsFromIntent(Intent intent) { + if (intent == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { + return; + } + int takeFlags = intent.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + if (takeFlags == 0) { + return; + } + ContentResolver resolver = getContext().getContentResolver(); + if (resolver == null) { + return; + } + ClipData clip = intent.getClipData(); + if (clip != null) { + for (int i = 0; i < clip.getItemCount(); i++) { + Uri uri = clip.getItemAt(i).getUri(); + if (uri != null) { + try { + resolver.takePersistableUriPermission(uri, takeFlags); + } catch (SecurityException ignored) { + } + } + } + } + Uri dataUri = intent.getData(); + if (dataUri != null) { + try { + resolver.takePersistableUriPermission(dataUri, takeFlags); + } catch (SecurityException ignored) { + } + } + } + + /** + * Create a File for saving an image or video + */ + private File getOutputMediaFile(boolean isVideo) { + // To be safe, you should check that the SDCard is mounted + // using Environment.getExternalStorageState() before doing this. + if (getActivity() != null) { + return GetOutputMediaFile.getOutputMediaFile(isVideo, getActivity()); + } else { + return GetOutputMediaFile.getOutputMediaFile(isVideo, getContext(), "Video"); + } + } + + private static class GetOutputMediaFile { + + public static File getOutputMediaFile(boolean isVideo,Activity activity) { + activity.getComponentName(); + return getOutputMediaFile(isVideo, activity, activity.getTitle()); + } + + public static File getOutputMediaFile(boolean isVideo, Context activity, CharSequence title) { + + + File mediaStorageDir = new File(new File(getContext().getCacheDir(), "intent_files"), ""+title); + + // Create the storage directory if it does not exist + if (!mediaStorageDir.exists()) { + if (!mediaStorageDir.mkdirs()) { + Log.d(Display.getInstance().getProperty("AppName", "CodenameOne"), "failed to create directory"); + return null; + } + } + + // Create a media file name + String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date()); + File mediaFile = null; + if (!isVideo) { + mediaFile = new File(mediaStorageDir.getPath() + File.separator + + "IMG_" + timeStamp + ".jpg"); + } else { + mediaFile = new File(mediaStorageDir.getPath() + File.separator + + "VID_" + timeStamp + ".mp4"); + } + + return mediaFile; + } + } + + @Override + 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; + } + + @Override + public void registerPush(Hashtable metaData, boolean noFallback) { + if (getActivity() == null) { + return; + } + + if (android.os.Build.VERSION.SDK_INT >= 33) { + if(!checkForPermission("android.permission.POST_NOTIFICATIONS", "This is required to receive push notifications")){ + return; + } + } + + 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!"); + return; + } + String id = ""; + if (!huawei) { + id = (String)metaData.get(com.codename1.push.Push.GOOGLE_PUSH_KEY); + if (id == null) { + id = Display.getInstance().getProperty("gcm.sender_id", null); + } + } + Log.d("Codename One", "Sending async push request for id: " + id); + ((CodenameOneActivity) getActivity()).registerForPush(id); + } + + public static void stopPollingLoop() { + stopPolling(); + } + + public static void registerPolling() { + registerPollingFallback(); + } + + @Override + public void deregisterPush() { + boolean has = hasAndroidMarket() + || "huawei".equals(Display.getInstance().getProperty("cn1.push.transport", "")); + if (has) { + ((CodenameOneActivity) getActivity()).stopReceivingPush(); + deregisterPushFromServer(); + } else { + super.deregisterPush(); + } + } + + private static String convertImageUriToFilePath(Uri imageUri, Context activity) { + Cursor cursor = null; + String[] proj = {MediaStore.Images.Media.DATA}; + cursor = activity.getContentResolver().query(imageUri, proj, null, null, null); + int column_index = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA); + cursor.moveToFirst(); + String path = cursor.getString(column_index); + cursor.close(); + return path; + } + + class CN1MediaController extends MediaController { + + public CN1MediaController() { + super(getActivity()); + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + int keycode = event.getKeyCode(); + keycode = CodenameOneView.internalKeyCodeTranslate(keycode); + if (keycode == AndroidImplementation.DROID_IMPL_KEY_BACK) { + // Claim the gesture so the activity's OnBackInvokedCallback + // stands down; on Android 16 the platform can deliver both for + // one press. See PredictiveBackBridge. The claim brackets the + // DOWN and the UP even though this path answers each of them + // with a whole press/release pair of its own. + switch (event.getAction()) { + case KeyEvent.ACTION_DOWN: + PredictiveBackBridge.keyEventBackStarted(); + break; + case KeyEvent.ACTION_UP: + PredictiveBackBridge.keyEventBackFinished(); + break; + default: + break; + } + Display.getInstance().keyPressed(keycode); + Display.getInstance().keyReleased(keycode); + return true; + } else { + return super.dispatchKeyEvent(event); + } + } + } + private L10NManager l10n; + + /** + * @inheritDoc + */ + public L10NManager getLocalizationManager() { + if (l10n == null) { + final Locale l = Locale.getDefault(); + l10n = new L10NManager(l.getLanguage(), l.getCountry()) { + public double parseDouble(String localeFormattedDecimal) { + try { + return NumberFormat.getNumberInstance().parse(localeFormattedDecimal).doubleValue(); + } catch (ParseException err) { + return Double.parseDouble(localeFormattedDecimal); + } + } + + @Override + public String getLongMonthName(Date date) { + java.text.SimpleDateFormat fmt = new java.text.SimpleDateFormat("MMMM", l); + return fmt.format(date); + } + + @Override + public String getShortMonthName(Date date) { + java.text.SimpleDateFormat fmt = new java.text.SimpleDateFormat("MMM", l); + return fmt.format(date); + } + + + + public String format(int number) { + return NumberFormat.getNumberInstance().format(number); + } + + public String format(double number) { + return NumberFormat.getNumberInstance().format(number); + } + + public String formatCurrency(double currency) { + return NumberFormat.getCurrencyInstance().format(currency); + } + + public String formatDateLongStyle(Date d) { + return DateFormat.getDateInstance(DateFormat.LONG).format(d); + } + + public String formatDateShortStyle(Date d) { + return DateFormat.getDateInstance(DateFormat.SHORT).format(d); + } + + public String formatDateTime(Date d) { + return DateFormat.getDateTimeInstance().format(d); + } + + public String formatDateTimeMedium(Date d) { + DateFormat dd = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM); + return dd.format(d); + } + + public String formatDateTimeShort(Date d) { + DateFormat dd = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT); + return dd.format(d); + } + + public String getCurrencySymbol() { + return NumberFormat.getInstance().getCurrency().getSymbol(); + } + + public void setLocale(String locale, String language) { + super.setLocale(locale, language); + Locale l = new Locale(language, locale); + Locale.setDefault(l); + } + }; + } + return l10n; + } + private com.codename1.ui.util.ImageIO imIO; + + private com.codename1.media.VideoIO videoIO; + private boolean videoIOResolved; + + @Override + public com.codename1.media.VideoIO getVideoIO() { + if (!videoIOResolved) { + videoIOResolved = true; + if (android.os.Build.VERSION.SDK_INT >= 21) { + videoIO = new AndroidVideoIO(); + } + } + return videoIO; + } + + @Override + public com.codename1.ui.util.ImageIO getImageIO() { + if (imIO == null) { + imIO = new com.codename1.ui.util.ImageIO() { + @Override + public Dimension getImageSize(String imageFilePath) throws IOException { + BitmapFactory.Options o = new BitmapFactory.Options(); + o.inJustDecodeBounds = true; + o.inPreferredConfig = Bitmap.Config.ARGB_8888; + + InputStream fis = createFileInputStream(imageFilePath); + BitmapFactory.decodeStream(fis, null, o); + fis.close(); + + ExifInterface exif = new ExifInterface(removeFilePrefix(imageFilePath)); + + // if the image is in portrait mode + int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); + if(orientation == ExifInterface.ORIENTATION_ROTATE_90 || orientation == ExifInterface.ORIENTATION_ROTATE_270) { + return new Dimension(o.outHeight, o.outWidth); + } + return new Dimension(o.outWidth, o.outHeight); + } + + private Dimension getImageSizeNoRotation(String imageFilePath) throws IOException { + BitmapFactory.Options o = new BitmapFactory.Options(); + o.inJustDecodeBounds = true; + o.inPreferredConfig = Bitmap.Config.ARGB_8888; + + InputStream fis = createFileInputStream(imageFilePath); + BitmapFactory.decodeStream(fis, null, o); + fis.close(); + + return new Dimension(o.outWidth, o.outHeight); + } + + @Override + public void save(InputStream image, OutputStream response, String format, int width, int height, float quality) throws IOException { + Bitmap.CompressFormat f = Bitmap.CompressFormat.PNG; + if (FORMAT_JPEG.equals(format)) { + f = Bitmap.CompressFormat.JPEG; + } + Image img = Image.createImage(image).scaled(width, height); + Bitmap b = (Bitmap) img.getImage(); + b.compress(f, (int) (quality * 100), response); + } + + @Override + public String saveAndKeepAspect(String imageFilePath, String preferredOutputPath, String format, int width, int height, float quality, boolean onlyDownscale, boolean scaleToFill) throws IOException{ + ExifInterface exif = new ExifInterface(removeFilePrefix(imageFilePath)); + Dimension d = getImageSizeNoRotation(imageFilePath); + if(onlyDownscale) { + if(scaleToFill) { + if(d.getHeight() <= height || d.getWidth() <= width) { + return imageFilePath; + } + } else { + if(d.getHeight() <= height && d.getWidth() <= width) { + return imageFilePath; + } + } + } + + float ratio = ((float)d.getWidth()) / ((float)d.getHeight()); + int heightBasedOnWidth = (int)(((float)width) / ratio); + int widthBasedOnHeight = (int)(((float)height) * ratio); + if(scaleToFill) { + if(heightBasedOnWidth >= width) { + height = heightBasedOnWidth; + } else { + width = widthBasedOnHeight; + } + } else { + if(heightBasedOnWidth > width) { + width = widthBasedOnHeight; + } else { + height = heightBasedOnWidth; + } + } + sampleSizeOverride = Math.max(d.getWidth()/width, d.getHeight()/height); + OutputStream im = FileSystemStorage.getInstance().openOutputStream(preferredOutputPath); + Image i = Image.createImage(imageFilePath); + Image newImage = i.scaled(width, height); + int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); + + int angle = 0; + switch (orientation) { + case ExifInterface.ORIENTATION_ROTATE_90: + angle = 90; + break; + case ExifInterface.ORIENTATION_ROTATE_180: + angle = 180; + break; + case ExifInterface.ORIENTATION_ROTATE_270: + angle = 270; + break; + } + if (angle != 0) { + Matrix mat = new Matrix(); + mat.postRotate(angle); + Bitmap b = (Bitmap)newImage.getImage(); + Bitmap correctBmp = Bitmap.createBitmap(b, 0, 0, b.getWidth(), b.getHeight(), mat, true); + b.recycle(); + newImage.dispose(); + Image tmp = Image.createImage(correctBmp); + newImage = tmp; + save(tmp, im, format, quality); + } else { + save(imageFilePath, im, format, width, height, quality); + } + sampleSizeOverride = -1; + return preferredOutputPath; + } + + @Override + public void save(String imageFilePath, OutputStream response, String format, int width, int height, float quality) throws IOException { + Image i = Image.createImage(imageFilePath); + Image newImage = i.scaled(width, height); + save(newImage, response, format, quality); + newImage.dispose(); + i.dispose(); + } + + @Override + protected void saveImage(Image img, OutputStream response, String format, float quality) throws IOException { + Bitmap.CompressFormat f = Bitmap.CompressFormat.PNG; + if (FORMAT_JPEG.equals(format)) { + f = Bitmap.CompressFormat.JPEG; + } + Bitmap b = (Bitmap) img.getImage(); + b.compress(f, (int) (quality * 100), response); + } + + @Override + public boolean isFormatSupported(String format) { + return FORMAT_JPEG.equals(format) || FORMAT_PNG.equals(format); + } + }; + } + return imIO; + } + + @Override + public Database openOrCreateDB(String databaseName) throws IOException { + // Reserved first, and recovery run inside the reservation. The slot has to be taken + // before the engine opens anything, or a conversion reading the count during the open + // starts replacing the file this is about to hand back -- and recovery has to be inside + // it too, because a conversion that has just installed its converted file leaves the live + // file and the backup both present, which recovery would otherwise read as a completed + // conversion and act on by deleting the backup. + String nativePath = resolveNativeDatabasePath(databaseName); + reserveDatabaseConnection(nativePath); + SQLiteDatabase db; + try { + // A plaintext open of a database mid-conversion would create an empty one over the + // top of the real data, which nothing afterwards could undo. + // + // One connection is allowed to be open here, and it is the reservation taken above. + // Anything beyond that is somebody else's handle -- including one taken through the + // constructor that wraps an already-open connection -- and recovery moves the file + // out from under it. When that is the case and a conversion is waiting to be + // finished, this open is refused rather than handing back a file recovery is going + // to replace; with nothing waiting there is nothing to recover and the open goes + // ahead as before. + recoverIfSoleConnection(nativePath); + if (databaseName.startsWith("file://")) { + db = SQLiteDatabase.openOrCreateDatabase( + FileSystemStorage.getInstance().toNativePath(databaseName), null, + KEEP_ON_CORRUPTION); + } else { + db = getContext().openOrCreateDatabase(databaseName, getContext().MODE_PRIVATE, + null, KEEP_ON_CORRUPTION); + } + } catch (RuntimeException didNotOpen) { + databaseConnectionClosed(nativePath); + // The engine reports a file it cannot read by throwing an unchecked + // SQLiteDatabaseCorruptException, and an encrypted database opened without its key is + // exactly that to the plain engine. This API promises every failure as an IOException, + // so the caller can catch one thing rather than an unchecked type per platform. + throw new IOException("The database " + databaseName + " could not be opened: " + + didNotOpen.getMessage(), didNotOpen); + } catch (IOException didNotRecover) { + databaseConnectionClosed(nativePath); + throw didNotRecover; + } + return new AndroidDB(db, nativePath); + } + + @Override + public Database openOrCreateDB(String databaseName, com.codename1.db.DatabaseConfig config) throws IOException { + if (config == null || !config.isEncrypted()) { + return openOrCreateDB(databaseName); + } + // The slot is taken before the engine opens anything, for the reason given in + // openOrCreateDB. AndroidCipherFactory hands back a connection that already holds it. + String nativePath = resolveNativeDatabasePath(databaseName); + reserveDatabaseConnection(nativePath); + // The SQLCipher-backed package is deleted at build time for apps that never touch + // DatabaseConfig, so it has to be reached reflectively - the same arrangement the + // ARCore-backed AR implementation uses. + Object opened; + try { + Class c = Class.forName("com.codename1.impl.android.cipher.AndroidCipherFactory"); + java.lang.reflect.Method open = c.getMethod("open", String.class, String.class, + String.class); + // Cast outside the try, below: inside a block that catches Throwable, a wrong type + // from the reflective call would be swallowed and reported as the package being + // absent. The resolved file, not the name it was asked for: a managed key with no explicit + // alias is stored under whatever is passed here, so two accepted spellings of one + // database would derive two different keys and the second open would report a wrong + // key against data that is perfectly intact. + opened = open.invoke(null, + resolveNativeDatabasePath(databaseName), databaseName, + config.resolveKeyMaterial(databaseKey(nativePath))); + } catch (java.lang.reflect.InvocationTargetException err) { + releaseUnusedDatabaseConnection(nativePath); + Throwable cause = err.getCause(); + if (cause instanceof IOException) { + throw (IOException) cause; + } + throw new IOException(cause == null ? err.toString() : cause.getMessage(), cause); + } catch (IOException err) { + releaseUnusedDatabaseConnection(nativePath); + throw err; + } catch (ClassNotFoundException notBundled) { + // The only benign reason to land here: the build pruned the package because the + // application never referenced DatabaseConfig. + releaseUnusedDatabaseConnection(nativePath); + throw new com.codename1.db.DatabaseEncryptionException( + com.codename1.db.DatabaseEncryptionException.NOT_SUPPORTED, + "This build does not include encrypted database support", notBundled); + } catch (NoSuchMethodException broken) { + // The package is present but does not expose the entry point this reaches through. + // That is a broken build, not an unsupported platform, and reporting it as + // NOT_SUPPORTED would hide it: every caller would be told encryption is unavailable + // on a device that ships the engine. This is the failure mode a compiler would have + // caught if the seam were not reflective, so it has to be loud. + releaseUnusedDatabaseConnection(nativePath); + throw new IOException("The encrypted database implementation is present but does not " + + "expose the expected entry point. This build is inconsistent: " + + broken.getMessage(), broken); + } catch (Throwable err) { + releaseUnusedDatabaseConnection(nativePath); + throw new com.codename1.db.DatabaseEncryptionException( + com.codename1.db.DatabaseEncryptionException.NOT_SUPPORTED, + "This build does not include encrypted database support", err); + } + if (!(opened instanceof Database)) { + releaseUnusedDatabaseConnection(nativePath); + throw new IOException("The encrypted database implementation returned " + + (opened == null ? "nothing" : opened.getClass().getName()) + + " rather than a Database. This build is inconsistent."); + } + return (Database) opened; + } + + /// The file an implicit managed key is stored under; see the open path, which resolves the + /// same way so two spellings of one database derive one key. + @Override + public String databaseManagedKeyIdentity(String databaseName) { + // Canonical, like the connection registry: resolveNativeDatabasePath leaves a custom + // spelling as it was given, so "/data/app/./db.sqlite" and "/data/app/db.sqlite" would + // otherwise pick different stored keys for one file and report the second open as wrong. + return databaseKey(resolveNativeDatabasePath(databaseName)); + } + + @Override + public boolean isDatabaseEncryptionSupported() { + Object available; + try { + Class c = Class.forName("com.codename1.impl.android.cipher.AndroidCipherFactory"); + available = c.getMethod("isAvailable").invoke(null); + } catch (Throwable notPresent) { + return false; + } + // Tested rather than cast inside the try: the reflective answer is untyped, and + // anything but a Boolean means the feature is unavailable rather than absent. + return available instanceof Boolean && ((Boolean) available).booleanValue(); + } + + @Override + public boolean isDatabaseManagedKeyHardwareBacked() { + // Ask the key itself. An API level says only that the API exists: emulators, and plenty of + // real devices, back AndroidKeyStore keys in software. Applications are told they may use + // this to refuse to store sensitive data, so it has to describe the actual key. + return AndroidSecureStorage.isPlainKeyInsideSecureHardware(); + } + + /** + * Absolute filesystem path for a database name, converting a custom file:// URL. + * + * getDatabasePath() deliberately echoes a file:// URL back unchanged, which is right for + * callers that hand it to FileSystemStorage but wrong for anything constructing a java.io.File + * from it. + */ + /// Directory holding the encrypted-database migration's working files. + /// + /// A directory beside the database, so the rename that installs the converted file stays + /// within one filesystem and is therefore atomic. + /// + /// The location alone does not make these files ours. Custom paths mean an application can + /// point a database anywhere, including inside here, so ownership is established by the + /// marker's contents rather than by where a file sits or what it is called. Nothing is + /// deleted, renamed over or truncated without that proof. + public static final String DATABASE_MIGRATION_DIR = ".cn1migration"; + + /// Marker name for a database. Deterministic so recovery can find it; its contents, not its + /// name, are what establish that a conversion wrote it. + public static final String MIGRATION_MARKER = ".marker"; + + /// Fourth line of a marker whose installed file was never shown to open. + private static final String MIGRATION_UNVALIDATED = "unvalidated"; + + /// First line of a marker written by this port. + private static final String MIGRATION_MARKER_MAGIC = "codename1-database-migration-1"; + + /// The migration directory for a database, or null if the path has no parent. + public static File databaseMigrationDir(String path) { + File parent = new File(path).getParentFile(); + return parent == null ? null : new File(parent, DATABASE_MIGRATION_DIR); + } + + public static File databaseMigrationMarker(String path) { + File dir = databaseMigrationDir(path); + return dir == null ? null : new File(dir, new File(path).getName() + MIGRATION_MARKER); + } + + /// Reads a marker written by this port, or null when the file is not one of ours. + /// + /// A marker is trusted only if it opens with the magic line. Anything else - including an + /// application database that happens to live at this path - is left alone. + /// + /// The two entries after it are the file holding the original and the export being built, + /// either of which may be absent: the marker is written before the export is filled in and + /// rewritten once the original has been moved aside, so which files exist depends on how far + /// the conversion got. + /// + /// What this does NOT defend against, deliberately: an actor who can write in the migration + /// directory can still write a marker naming files inside it. The magic line is in the + /// source, so it authenticates nothing -- and there is no secret this port could sign a + /// marker with that the same actor could not read out of the application. The damage is + /// bounded to that one directory, which that actor can already write to and delete from + /// directly, so the check earns its keep by keeping the names inside it rather than by + /// pretending the file is trusted. + /// + /// A rejected marker is treated as somebody else's file: recovery leaves it alone and a + /// conversion refuses to start rather than overwriting it, with a message naming the file. A + /// crafted marker therefore stops conversions of that one database until it is removed, which + /// is the outcome to prefer over acting on it. + /// + /// @return the two names, either element null, or null if this is not our marker + private static String[] readDatabaseMigrationMarker(String path) { + File marker = databaseMigrationMarker(path); + if (marker == null || !marker.isFile()) { + return null; + } + BufferedReader reader = null; + try { + reader = new BufferedReader(new InputStreamReader(new FileInputStream(marker), + "UTF-8")); + if (!MIGRATION_MARKER_MAGIC.equals(reader.readLine())) { + return null; + } + String backup = reader.readLine(); + String target = reader.readLine(); + String state = reader.readLine(); + String backupName = backup == null || backup.length() == 0 ? null : backup; + String targetName = target == null || target.length() == 0 ? null : target; + // The names this port writes are basenames createTempFile produced in the migration + // directory, and they are read back as files to truncate, delete and rename over. A + // marker is a plain text file beside the database, so where the database sits + // somewhere another actor can write -- which a custom path can -- an entry like + // "../../../files/secret" would be resolved against that directory and handed to the + // cleanup, which truncates and deletes what it is given. Anything that is not a + // simple name inside this directory means the file is not one of ours, which is the + // answer that stops every caller: recovery leaves it alone and a conversion refuses + // to overwrite it rather than starting. + File dir = databaseMigrationDir(path); + if ((backupName != null && !isMigrationEntryName(backupName, dir)) + || (targetName != null && !isMigrationEntryName(targetName, dir))) { + return null; + } + return new String[] { + backupName, + targetName, + state == null || state.length() == 0 ? null : state, + }; + } catch (IOException unreadable) { + return null; + } finally { + if (reader != null) { + try { + reader.close(); + } catch (IOException ignored) { + // Nothing useful to do. + } + } + } + } + + /// Whether a name a marker carries is one this port could have written there. + /// + /// A generated basename, and a file that really is a direct child of the migration directory: + /// the first rejects a path that climbs out of it, the second rejects a name inside it that + /// is a link to somewhere else. Both are checked because either alone can be walked around -- + /// a name with no separator can still be a symlink, and a canonical check on its own would + /// accept "sub/dir/../file". + /// + /// #### Parameters + /// + /// - `name`: the entry read from the marker + /// - `directory`: the migration directory the marker lives in + /// + /// #### Returns + /// + /// true if the name is safe to resolve against that directory + private static boolean isMigrationEntryName(String name, File directory) { + if (directory == null || name.length() == 0 || ".".equals(name) || "..".equals(name)) { + return false; + } + if (name.indexOf('/') >= 0 || name.indexOf('\\') >= 0 || name.indexOf('\u0000') >= 0) { + return false; + } + try { + File resolved = new File(directory, name).getCanonicalFile(); + File parent = resolved.getParentFile(); + return parent != null && parent.equals(directory.getCanonicalFile()); + } catch (IOException cannotResolve) { + // A name that cannot be resolved is not one that gets acted on. + return false; + } + } + + /// Whether the marker for this database was written by this port. + /// + /// Distinct from having a backup: a marker written before the export was filled in names no + /// backup yet, and is still ours to rewrite. + private static boolean ownsDatabaseMigrationMarker(String path) { + return readDatabaseMigrationMarker(path) != null; + } + + /// Reads the backup a marker claims, or null when there is none. + public static File readDatabaseMigrationBackup(String path) { + String[] entry = readDatabaseMigrationMarker(path); + if (entry == null || entry[0] == null) { + return null; + } + return new File(databaseMigrationMarker(path).getParentFile(), entry[0]); + } + + /// Whether the marker says its installed file was never shown to open. + private static boolean isDatabaseMigrationUnvalidated(String path) { + String[] entry = readDatabaseMigrationMarker(path); + return entry != null && entry.length > 2 && MIGRATION_UNVALIDATED.equals(entry[2]); + } + + /// Reads the export a marker claims, or null when there is none. + /// + /// The export is a second complete copy of the data, and a plaintext one when the conversion + /// was a decryption, so it is recorded before anything is written into it. Otherwise a process + /// death between creating it and finishing the conversion would leave readable data behind + /// under a name nothing knows to look for. + public static File readDatabaseMigrationTarget(String path) { + String[] entry = readDatabaseMigrationMarker(path); + if (entry == null || entry[1] == null) { + return null; + } + return new File(databaseMigrationMarker(path).getParentFile(), entry[1]); + } + + /// Every database connection this port has open, by the file it is open on. + /// + /// Shared by both implementations on purpose. Only a conversion needs it, and a conversion is + /// not a statement: it renames a new file over the database while the process is running, and + /// Android lets that succeed while another connection holds the old one. That connection goes + /// on writing to a file that is no longer the database, is told each write succeeded, and + /// loses all of it when the backup is deleted. + /// + /// The connection it collides with is usually not another encrypted one -- the ordinary case + /// is an application holding `Database.openOrCreate(name)` open, which is a plaintext + /// connection, and then calling `Database.encrypt(name, ...)`. Counting only the encrypted + /// ones would miss exactly the case that happens. + private static final java.util.Map OPEN_DATABASE_CONNECTIONS = + new java.util.HashMap(); + + /// The key a database file is tracked under. + /// + /// Canonical, because two spellings of one file must not be two entries: a connection opened + /// as `/data/app/db.sqlite` has to be visible to a conversion started as + /// `/data/app/./db.sqlite`, or the file is replaced underneath it and its later writes -- each + /// one reported as successful -- disappear with the old inode. `toNativePath` only strips the + /// `file://` prefix, so a custom path arrives however the caller spelled it. + /// + /// Falls back to the absolute path when the file system cannot answer, which still collapses + /// the relative spellings; a canonical path that cannot be resolved is not a reason to refuse + /// to open a database. + /// The canonical identity of a database file, for callers outside this class. + /// + /// The cipher package resolves a managed key against it, so that its key change and the next + /// open agree on which file they are talking about. + public static String canonicalDatabaseKey(String path) { + return databaseKey(path); + } + + private static String databaseKey(String path) { + if (path == null) { + return null; + } + try { + return new File(path).getCanonicalPath(); + } catch (IOException cannotResolve) { + return new File(path).getAbsolutePath(); + } + } + + /// Records a connection opened on a database file. + public static synchronized void databaseConnectionOpened(String rawPath) { + String path = databaseKey(rawPath); + if (path == null) { + return; + } + Integer count = OPEN_DATABASE_CONNECTIONS.get(path); + OPEN_DATABASE_CONNECTIONS.put(path, + Integer.valueOf(count == null ? 1 : count.intValue() + 1)); + } + + /// Records a connection closed on a database file. + public static synchronized void databaseConnectionClosed(String rawPath) { + String path = databaseKey(rawPath); + if (path == null) { + return; + } + Integer count = OPEN_DATABASE_CONNECTIONS.get(path); + if (count == null) { + return; + } + if (count.intValue() <= 1) { + OPEN_DATABASE_CONNECTIONS.remove(path); + } else { + OPEN_DATABASE_CONNECTIONS.put(path, Integer.valueOf(count.intValue() - 1)); + } + } + + /// Database files a conversion currently owns exclusively. + private static final java.util.Set MIGRATING_DATABASES = + new java.util.HashSet(); + + /// Claims a database for a conversion, or refuses. + /// + /// Counting the connections and then converting are one decision, not two. Between a count + /// read on its own and the rename that ends the conversion, another thread can open the + /// database, and that connection then holds the file the rename replaces: its writes are + /// accepted and disappear when the backup goes. So the count is read and the claim taken + /// under the same lock the opens take, and an open that arrives afterwards is refused for as + /// long as the conversion runs. + /// + /// #### Parameters + /// + /// - `path`: the database file + /// + /// #### Throws + /// + /// - `IOException`: if the database is open elsewhere, or already being converted + public static synchronized void beginDatabaseMigration(String rawPath) throws IOException { + String path = databaseKey(rawPath); + if (MIGRATING_DATABASES.contains(path)) { + throw new IOException("The database " + path + " is already being converted."); + } + Integer count = OPEN_DATABASE_CONNECTIONS.get(path); + if (count != null && count.intValue() > 1) { + throw new IOException("The database " + path + " is open more than once, and " + + "converting it replaces the file underneath every connection to it. Close " + + "the other connections first; writes made through them during the " + + "conversion would be accepted and then lost."); + } + MIGRATING_DATABASES.add(path); + } + + /// Recovers an interrupted conversion, but only for an open that has the file to itself. + /// + /// Called from the open paths, plaintext and encrypted, each of which has already reserved + /// its own connection -- so one open connection is this caller and anything beyond it is + /// somebody else's handle, including one taken through the constructor that wraps an + /// already-open connection. Recovery renames the live file aside and puts a backup back, and + /// a connection attached to the displaced file keeps accepting writes that go nowhere, so it + /// is left for the next open that has the file alone. + /// + /// #### Parameters + /// + /// - `rawPath`: the database file + /// + /// #### Throws + /// + /// - `IOException`: if the recovery itself fails + public static void recoverIfSoleConnection(String rawPath) throws IOException { + if (claimDatabaseForRecovery(rawPath, 1)) { + try { + recoverInterruptedDatabaseMigration(rawPath); + } finally { + endDatabaseMigration(rawPath); + } + return; + } + if (hasInterruptedDatabaseMigration(rawPath)) { + // Recovery could not run and there is work waiting for it, which means the file this + // open would hand back is one recovery is going to replace. Two handles writing to it + // in the meantime would both be told their writes succeeded, and the next open with + // the file to itself would restore the backup over the top of them. Refusing is the + // only answer that does not accept writes it cannot keep. + throw new IOException("The database " + rawPath + " has a conversion that was " + + "interrupted, and it cannot be finished while another connection holds the " + + "file. Close the other connections and open it again; the data is intact " + + "and will be put back then."); + } + } + + /// Whether a conversion of this database was interrupted and still has work waiting. + /// + /// A marker this port wrote is the record of that. One written by something else is not ours + /// to read, and recovery leaves it alone for the same reason. + /// + /// #### Parameters + /// + /// - `rawPath`: the database file + /// + /// #### Returns + /// + /// true when recovery has something to do + private static boolean hasInterruptedDatabaseMigration(String rawPath) { + File marker = databaseMigrationMarker(rawPath); + return marker != null && marker.isFile() && ownsDatabaseMigrationMarker(rawPath); + } + + /// Takes the conversion claim for a recovery, or reports that a conversion already holds it. + /// + /// Recovery moves the same three files a conversion does, so the two must not overlap. The + /// claim is the conversion's own, so a conversion starting while recovery runs is refused by + /// `#beginDatabaseMigration(String)` exactly as a second conversion would be. + /// + /// #### Parameters + /// + /// - `rawPath`: the database file + /// + /// #### Returns + /// + /// true when the claim was taken and must be given back + private static synchronized boolean claimDatabaseForRecovery(String rawPath, + int connectionsOfOurOwn) { + String path = databaseKey(rawPath); + if (path == null || MIGRATING_DATABASES.contains(path)) { + return false; + } + Integer count = OPEN_DATABASE_CONNECTIONS.get(path); + if (count != null && count.intValue() > connectionsOfOurOwn) { + // Somebody else holds the file. Recovery renames the live file aside and puts a + // backup back, and a connection already attached to the displaced file keeps + // accepting writes that go nowhere -- worst of all for a conversion whose converted + // file was never validated, where the backup is what recovery installs. Refusing + // leaves the marker in place for the next open that has the file to itself. + return false; + } + MIGRATING_DATABASES.add(path); + return true; + } + + /// Whether a conversion currently owns a database file. + public static synchronized boolean isDatabaseBeingConverted(String rawPath) { + return MIGRATING_DATABASES.contains(databaseKey(rawPath)); + } + + /// Releases a database claimed by `#beginDatabaseMigration(String)`. + public static synchronized void endDatabaseMigration(String rawPath) { + MIGRATING_DATABASES.remove(databaseKey(rawPath)); + } + + /// Gives back a slot taken by `#reserveDatabaseConnection(String)` when no connection was + /// handed to the caller after all. + public static void releaseUnusedDatabaseConnection(String path) { + databaseConnectionClosed(path); + } + + /// Takes a connection slot on a database, or refuses because a conversion owns it. + /// + /// The check and the count are one step. Checking that no conversion is running and then + /// registering afterwards leaves a gap: the engine's open sits between them, and a conversion + /// that reads the count during it sees only its own connection, takes its claim, and starts + /// replacing the file the open is about to return a connection to. Taking the slot inside the + /// same lock as the check closes that -- a conversion either sees the slot and refuses, or + /// holds the claim and the open refuses. + /// + /// The caller releases the slot with `#databaseConnectionClosed(String)` if the open itself + /// then fails, and the connection releases it on close. + /// + /// #### Throws + /// + /// - `IOException`: if a conversion currently owns the file + public static synchronized void reserveDatabaseConnection(String rawPath) throws IOException { + String path = databaseKey(rawPath); + if (path != null && com.codename1.db.Database.isDatabaseBeingDeleted(path)) { + // The claim the delete holds, not one of this port's: it is taken before the count + // this method increments is read, so an open arriving mid-delete is refused here and + // an open that got in first is seen by that count. A claim of our own, taken when + // the delete reached this port, would have been too late -- the count had already + // been read by then, and an open landing in between would have been handed a file + // about to lose its name. + throw new IOException("The database " + path + " is being deleted and cannot be " + + "opened."); + } + if (path != null && MIGRATING_DATABASES.contains(path)) { + throw new IOException("The database " + path + " is being converted and cannot be " + + "opened until that finishes."); + } + databaseConnectionOpened(path); + } + + /// How many connections are open on a database file, encrypted or not. + public static synchronized int connectionsOpenOn(String rawPath) { + Integer count = OPEN_DATABASE_CONNECTIONS.get(databaseKey(rawPath)); + return count == null ? 0 : count.intValue(); + } + + /// Disposes of an export, and reports anything that survived. + /// + /// If the file cannot be unlinked it is truncated instead, which removes the contents even + /// where the directory entry survives. + /// + /// @return a sentence to append to a failure message, empty when nothing survived + public static String discardDatabaseMigrationExport(File target) { + if (target == null) { + return ""; + } + // The sidecars before anything else, and through the platform's own deletion, which knows + // the whole set: -wal, -shm, -journal and the master journals. A database written here + // leaves rows in those, so removing the file alone left the data behind under a name + // nobody was looking at -- which is the one thing this method exists to prevent. It is + // also the case that matters most, since the export is a complete copy of the database, + // in plaintext whenever the conversion was a decrypt. + android.database.sqlite.SQLiteDatabase.deleteDatabase(target); + String survivingSidecars = discardDatabaseSidecars(target); + if (!target.exists() || target.delete()) { + return survivingSidecars; + } + if (isSymbolicLink(target)) { + // Emptying follows the link, and what it would empty is whatever the link points at. + // The name was checked before any of this began, but a directory another actor can + // write to can have that name replaced afterwards, and unlinking a link that cannot + // be unlinked leaves this holding a name that now means somebody else's file. + // Reported instead: the export could not be removed, and nothing else is touched. + return " A complete copy of the data was left at " + target.getPath() + + ", which is now a link and was left alone; delete it." + survivingSidecars; + } + try { + new FileOutputStream(target).close(); + } catch (IOException cannotEmptyIt) { + return " A complete copy of the data was left at " + target.getPath() + + " and could not be removed; delete it." + survivingSidecars; + } + if (!target.exists() || target.delete()) { + return survivingSidecars; + } + return " An emptied file was left at " + target.getPath() + "." + survivingSidecars; + } + + /// Whether a name now resolves to something other than itself. + /// + /// Everything under the migration directory was checked to be a plain name inside it before + /// any of it was acted on. That check happens once, and a directory another actor can write to + /// can have an entry replaced between then and the cleanup -- so anything that opens a file + /// rather than unlinking it asks again, immediately before it opens it. + /// + /// Unlinking needs no such question: removing a link removes the link. Emptying does, because + /// a stream follows it and empties whatever it points at. + /// + /// Compares the canonical path with the absolute one rather than using a no-follow open, which + /// this port cannot reach at the API levels it supports. It does not close the window between + /// the question and the open, and cannot from Java; it does stop the case that makes the + /// window worth anything, which is a link that has been left in place because it could not be + /// unlinked. + /// + /// #### Parameters + /// + /// - `f`: the entry about to be opened + /// + /// #### Returns + /// + /// true if it is a link, or if that could not be determined + private static boolean isSymbolicLink(File f) { + try { + return !f.getCanonicalFile().equals(f.getAbsoluteFile()); + } catch (IOException cannotResolve) { + // Unresolvable is treated as a link: this only decides whether to open something, and + // not opening it costs a message where opening it could truncate another file. + return true; + } + } + + /// Disposes of the files SQLite keeps beside a database, and reports anything that survived. + /// + /// Called after the platform's own deletion rather than instead of it: that removes them in + /// the ordinary case, and this is what happens when one could not be unlinked. Emptying is + /// the fallback for the same reason it is for the database itself -- a file that cannot be + /// removed can still be stripped of what it holds. + /// + /// @param target the database file whose companions these are + /// @return a sentence to append to a failure message, empty when nothing survived + private static String discardDatabaseSidecars(File target) { + String[] suffixes = {"-wal", "-shm", "-journal"}; + StringBuilder left = new StringBuilder(); + for (int iter = 0; iter < suffixes.length; iter++) { + File sidecar = new File(target.getPath() + suffixes[iter]); + if (!sidecar.exists() || sidecar.delete()) { + continue; + } + if (isSymbolicLink(sidecar)) { + // As above: emptying a link empties its target, and the target is not ours. + left.append(" A working file was left at ").append(sidecar.getPath()) + .append(", which is now a link and was left alone."); + continue; + } + try { + new FileOutputStream(sidecar).close(); + } catch (IOException cannotEmptyIt) { + left.append(" Part of the data was left at ").append(sidecar.getPath()) + .append(" and could not be removed; delete it."); + continue; + } + if (sidecar.exists() && !sidecar.delete()) { + left.append(" An emptied file was left at ").append(sidecar.getPath()).append("."); + } + } + return left.toString(); + } + + /// Records that a conversion is under way and which file holds the original. + /// + /// The marker is the one file here whose name has to be predictable, because recovery has to + /// find it without being told. So it is the one place something could already be sitting - + /// an application may point a database at this exact path - and writing over it would + /// destroy that database. Anything already there that this port did not write means the + /// conversion does not start. + /// Marks a conversion whose installed file was never shown to open. + /// + /// Recovery reads a live file and a backup both being present as a completed conversion and + /// removes the backup. That is right when the converted file opened, and catastrophic when it + /// did not and could not be taken back out either: the last readable copy would go. This + /// records the difference, and recovery puts the backup back instead. + public static void markDatabaseMigrationUnvalidated(String path, File backup) + throws IOException { + writeMarker(path, backup, null, true); + } + + /// The same, for a conversion whose export has not been installed yet. + /// + /// The export has to stay named while it still exists under its own name, or recovery cannot + /// find it to clean it up -- and a conversion interrupted here leaves a complete copy of the + /// database in the migration directory, which after a decryption is a plaintext one. + /// + /// #### Parameters + /// + /// - `path`: the live database + /// - `backup`: the file the original was moved to + /// - `target`: the export, while it is still under its own name + /// + /// #### Throws + /// + /// - `IOException`: if the record cannot be written + public static void markDatabaseMigrationUnvalidated(String path, File backup, File target) + throws IOException { + writeMarker(path, backup, target, true); + } + + public static void writeDatabaseMigrationMarker(String path, File backup, File target) + throws IOException { + writeMarker(path, backup, target, false); + } + + private static void writeMarker(String path, File backup, File target, boolean unvalidated) + throws IOException { + File marker = databaseMigrationMarker(path); + if (marker == null) { + throw new IOException("The database " + path + " has no directory to convert it in"); + } + if (marker.exists() && !ownsDatabaseMigrationMarker(path)) { + throw new IOException("There is already a file at " + marker + " that this port did " + + "not write, so the conversion was not started rather than overwriting it. " + + "Move it aside if it is not a database you need."); + } + // Written beside the marker and renamed over it, never written into it. The second call + // updates a marker that is already valid and already naming a file holding data, and + // opening it for writing truncates it first: a process death in that window leaves a + // marker that recovery cannot recognise, so it acts on nothing and the export it named is + // orphaned. A rename is atomic, so the marker is only ever the old contents or the new. + // The marker's own name already carries the ".marker" suffix, so it is never short + // enough for createTempFile to reject the prefix. + File pending = File.createTempFile(marker.getName() + ".", ".pending", + marker.getParentFile()); + Writer writer = new OutputStreamWriter(new FileOutputStream(pending), "UTF-8"); + try { + writer.write(MIGRATION_MARKER_MAGIC); + writer.write("\n"); + writer.write(backup == null ? "" : backup.getName()); + writer.write("\n"); + writer.write(target == null ? "" : target.getName()); + writer.write("\n"); + writer.write(unvalidated ? MIGRATION_UNVALIDATED : ""); + writer.write("\n"); + } finally { + writer.close(); + } + // renameTo replaces an existing destination on the filesystems Android puts databases on. + // Deleting first would reopen exactly the window this is here to close. + if (!pending.renameTo(marker)) { + pending.delete(); + throw new IOException("The record of the conversion at " + marker + " could not be " + + "written, so the conversion was not started."); + } + } + + /// Restores a database whose conversion was interrupted between the two renames. + /// + /// Called before every open, encrypted or not. Encrypt and decrypt move the original aside + /// and install the converted file in its place, so a process death in that gap leaves a + /// complete database in the migration directory and nothing under the live name. Putting it + /// back is what makes that window recoverable rather than a silent empty database. + /// + /// Acts only on a marker this port wrote, and only on the backup that marker names. + public static void recoverInterruptedDatabaseMigration(String path) throws IOException { + if (path == null) { + return; + } + File marker = databaseMigrationMarker(path); + if (marker == null || !marker.isFile() || !ownsDatabaseMigrationMarker(path)) { + // Nothing of ours is here, and nothing of anybody else's gets touched. A file at this + // name that this port did not write belongs to someone -- a custom database path can + // legitimately put another database here -- and this runs before every open, so acting + // on it would mean that opening one database destroys an unrelated one. + return; + } + // The export first, whatever else is true. It is a second complete copy of the data, and + // a plaintext one when the conversion was a decryption, so an interrupted conversion must + // not leave it lying in the migration directory. It is only ever installed by being + // renamed over the live database, so anything still under its own name is an orphan. + File orphanedExport = readDatabaseMigrationTarget(path); + if (orphanedExport != null && orphanedExport.exists()) { + String surviving = discardDatabaseMigrationExport(orphanedExport); + if (surviving.length() > 0) { + throw new IOException("The database " + path + " has an interrupted conversion " + + "whose working copy could not be cleaned up." + surviving); + } + } + File backup = readDatabaseMigrationBackup(path); + if (backup == null) { + // No original was moved aside, so the conversion never reached the swap. Only the + // export existed, and it is gone. + marker.delete(); + return; + } + File live = new File(path); + if (!backup.isFile()) { + // The marker outlived its backup, so there is nothing to put back or clean up. + marker.delete(); + return; + } + if (!live.exists()) { + // Died between the two renames: the backup is the only copy. Put it back, and refuse + // to continue if that fails - opening would create an empty database over the top and + // the next conversion would remove the backup as stale, losing the data for good. + if (!backup.renameTo(live)) { + throw new IOException("The database " + path + " is mid-conversion and the copy " + + "holding its contents, at " + backup + ", could not be moved back. The " + + "data is intact in that file; the database was not opened rather than " + + "replacing it with an empty one."); + } + marker.delete(); + return; + } + if (isDatabaseMigrationUnvalidated(path)) { + // The converted file is in place but was never shown to open, and the conversion could + // not take it back out. Both files existing is not evidence of success here, so the + // backup goes back rather than away: deleting it would drop the last readable copy. + File displaced = unusedSibling(path + ".unvalidated"); + if (displaced == null) { + throw new IOException("The database " + path + " holds a converted file that was " + + "never shown to open, and there is nowhere to move it aside to. The " + + "original is intact at " + backup + "; nothing was overwritten."); + } + // Named in the marker before the first rename, in the slot an export is named in. + // The two renames below are not one step: a process dying between them leaves the + // converted file under a name nothing knows about, and the recovery after that takes + // the branch above -- restores the backup, deletes the marker, and leaves that file + // beside the database for good. After a failed decryption it is a plaintext copy. + // Recorded first, the next recovery finds it exactly where it finds an abandoned + // export, and discards it the same way. + try { + markDatabaseMigrationUnvalidated(path, backup, displaced); + } catch (IOException cannotRecord) { + throw new IOException("The database " + path + " holds a converted file that was " + + "never shown to open, and where it is about to be moved could not be " + + "recorded. The original is intact at " + backup + "; nothing was moved.", + cannotRecord); + } + if (!live.renameTo(displaced) || !backup.renameTo(live)) { + throw new IOException("The database " + path + " holds a converted file that was " + + "never shown to open, and the original at " + backup + " could not be " + + "put back. The data is in that file; it was left there rather than " + + "removed."); + } + // The same cleanup an abandoned export gets, and for the same reason: this file is a + // complete copy of the database, and after a failed decryption it is the plaintext + // one. A delete() whose result nobody reads would leave it beside the restored + // database under a predictable name while recovery reported success. + String surviving = discardDatabaseMigrationExport(displaced); + if (surviving.length() > 0) { + throw new IOException("The database " + path + " was restored from its backup, but" + + " the converted copy could not be removed." + surviving); + } + marker.delete(); + return; + } + // Both exist, so the swap completed and only the cleanup was lost. The backup is the + // database in its previous form, which after an encrypt is a plaintext copy of an + // encrypted database - the encryption-at-rest hole in slow motion. + if (!backup.delete() && backup.exists()) { + throw new IOException("The database " + path + " was converted, but the copy of its " + + "previous form at " + backup + " could not be removed. Delete it before " + + "relying on this database being encrypted."); + } + marker.delete(); + } + + /// A path near `preferred` that no file occupies, or null if too many are taken. + /// + /// The recovery moves the rejected file aside before putting the original back, and on these + /// filesystems a rename replaces whatever is at the destination. A custom database path can put + /// that destination anywhere the application also keeps files, so writing to it blind would let + /// a failed conversion destroy an unrelated file of the application's while reporting that it + /// recovered cleanly. + private static File unusedSibling(String preferred) { + File candidate = new File(preferred); + if (!candidate.exists()) { + return candidate; + } + for (int iter = 1; iter < 100; iter++) { + candidate = new File(preferred + "." + iter); + if (!candidate.exists()) { + return candidate; + } + } + return null; + } + + /// Removes the working files for a database, reporting anything it could not remove. + /// + /// Used by delete, where the caller's intent is that the data goes away. A failure here has + /// to stop the deletion: continuing would report success while a complete copy of the + /// database survives, and a later open would restore it. + static void discardDatabaseMigrationArtifacts(String path) throws IOException { + if (path == null) { + return; + } + File export = readDatabaseMigrationTarget(path); + if (export != null && export.exists()) { + String surviving = discardDatabaseMigrationExport(export); + if (surviving.length() > 0) { + throw new IOException("The database " + path + " was not deleted, because the " + + "working copy of its interrupted conversion could not be removed." + + surviving); + } + } + File backup = readDatabaseMigrationBackup(path); + if (backup == null) { + File onlyMarker = databaseMigrationMarker(path); + if (onlyMarker != null && onlyMarker.isFile() && ownsDatabaseMigrationMarker(path) + && !onlyMarker.delete() && onlyMarker.exists()) { + throw new IOException("The database " + path + " was not deleted, because the " + + "record of its interrupted conversion at " + onlyMarker + " could not " + + "be removed."); + } + return; + } + if (backup.exists() && !backup.delete() && backup.exists()) { + throw new IOException("The database " + path + " was not deleted, because the copy of " + + "it at " + backup + " could not be removed and a later open would restore " + + "it."); + } + File marker = databaseMigrationMarker(path); + if (marker.exists() && !marker.delete() && marker.exists()) { + throw new IOException("The database " + path + " was not deleted, because the record " + + "of its interrupted conversion at " + marker + " could not be removed."); + } + } + + /// Whether a marked migration backup is holding a database's contents. + static boolean hasRecoverableDatabaseBackup(String path) { + File backup = readDatabaseMigrationBackup(path); + return backup != null && backup.isFile(); + } + + /// Leaves a database that will not open where it is. + /// + /// The platform default answers corruption by deleting the file. An encrypted database opened + /// without its key is ciphertext to the plain engine, which is indistinguishable from + /// corruption -- so a single accidental openOrCreate(name) against an encrypted database + /// destroyed it, and destroyed it in the one case where the data was perfectly intact and one + /// correct-key open away from being readable. + /// + /// Keeping the file turns that into a failed open, which is what a wrong key should be. A + /// genuinely corrupt database is kept too, which is the answer every other port gives: + /// reporting the failure and leaving the bytes for a backup or a repair tool beats deleting + /// them on the application's behalf. + private static final class KeepDatabaseOnCorruption + implements android.database.DatabaseErrorHandler { + @Override + public void onCorruption(SQLiteDatabase databaseObject) { + com.codename1.io.Log.p("Database " + databaseObject.getPath() + " could not be read. " + + "It was left in place rather than deleted: an encrypted database opened " + + "without its key looks exactly like this."); + } + } + + private static final android.database.DatabaseErrorHandler KEEP_ON_CORRUPTION = + new KeepDatabaseOnCorruption(); + + private String resolveNativeDatabasePath(String databaseName) { + if (databaseName.startsWith("file://")) { + return FileSystemStorage.getInstance().toNativePath(databaseName); + } + return getDatabasePath(databaseName); + } + + @Override + public Database openOrCreateDBForRekey(String databaseName) throws IOException { + // The stock android.database.sqlite engine has no cipher, so a plaintext database opened + // through it can never be encrypted in place. Route the migration through SQLCipher, which + // opens an unencrypted file when given an empty key and can then rekey it. + if (!isDatabaseEncryptionSupported()) { + return openOrCreateDB(databaseName); + } + // The slot is taken before the engine opens anything, for the reason given in + // openOrCreateDB. AndroidCipherFactory hands back a connection that already holds it. + String nativePath = resolveNativeDatabasePath(databaseName); + reserveDatabaseConnection(nativePath); + Object opened; + try { + Class c = Class.forName("com.codename1.impl.android.cipher.AndroidCipherFactory"); + java.lang.reflect.Method open = c.getMethod("open", String.class, String.class, String.class); + // Cast below, outside the try, for the reason given in openOrCreateDB. + opened = open.invoke(null, + resolveNativeDatabasePath(databaseName), databaseName, ""); + } catch (java.lang.reflect.InvocationTargetException err) { + // The open threw, so no connection exists to release the slot later. A rekey open of + // a file that turns out to be encrypted lands here, and leaving the slot behind would + // make every later conversion of that database see a connection that is not there. + releaseUnusedDatabaseConnection(nativePath); + Throwable cause = err.getCause(); + if (cause instanceof IOException) { + throw (IOException) cause; + } + throw new IOException(cause == null ? err.toString() : cause.getMessage(), cause); + } catch (NoSuchMethodException broken) { + // Same reasoning as openOrCreateDB: falling back to the plaintext engine here would + // silently turn a re-key into a no-op on a build that does ship the cipher. + releaseUnusedDatabaseConnection(nativePath); + throw new IOException("The encrypted database implementation is present but does not " + + "expose the expected entry point. This build is inconsistent: " + + broken.getMessage(), broken); + } catch (Throwable err) { + releaseUnusedDatabaseConnection(nativePath); + return openOrCreateDB(databaseName); + } + if (!(opened instanceof Database)) { + releaseUnusedDatabaseConnection(nativePath); + throw new IOException("The encrypted database implementation returned " + + (opened == null ? "nothing" : opened.getClass().getName()) + + " rather than a Database. This build is inconsistent."); + } + return (Database) opened; + } + + @Override + public boolean isBlobQueryParameterSupported() { + return true; + } + + @Override + public boolean isDatabaseCustomPathSupported() { + return true; + } + + + + /// How many connections this port has open on a database, for the delete guard in core. + /// + /// This port counts connections in its own registry rather than the base class's, because the + /// conversion that consults them runs here. Answering from it is what makes + /// `Database.delete(String)` refuse on Android as it does everywhere else. + @Override + public int openDatabaseConnections(String databaseName) { + try { + return connectionsOpenOn(resolveNativeDatabasePath(databaseName)); + } catch (RuntimeException cannotResolve) { + // An unresolvable name cannot be matched against the registry. Reporting none leaves + // the delete to the checks below rather than refusing something that may be fine. + return 0; + } + } + + @Override + public void deleteDB(String databaseName) throws IOException { + String deletePath = resolveNativeDatabasePath(databaseName); + if (isDatabaseBeingConverted(deletePath)) { + // A conversion owns the file and its working copies. Deleting either underneath it + // would strand the data in whichever one the conversion has not installed yet. + throw new IOException("The database " + deletePath + " is being converted and cannot " + + "be deleted until that finishes."); + } + // The working files first. They survive deleting the live file, and the next open runs + // recovery and puts the backup back - so a database the caller was told had been deleted + // reappears, and after an interrupted encryption what reappears is the plaintext copy. + discardDatabaseMigrationArtifacts(deletePath); + if (databaseName.startsWith("file://")) { + // Through the platform's own deletion rather than by removing the file, which is what + // this used to do. A SQLite database is more than its file: a crash or a kill leaves + // -wal, -shm and -journal beside it, holding rows that were written, and for an + // encrypted database those rows are as readable as the pages they came from. Removing + // the file alone reported a successful delete and left them there, and the next open + // on the same name would read them back. deleteDatabase takes the sidecars and the + // master journals with it, which is exactly what the non-custom branch below has been + // getting from Context.deleteDatabase all along. + android.database.sqlite.SQLiteDatabase.deleteDatabase(new File(deletePath)); + } else { + getContext().deleteDatabase(databaseName); + } + requireDatabaseGone(deletePath); + } + + /// Reports anything the platform left behind, rather than trusting that it deleted it. + /// + /// Both calls above answer with a boolean and neither says what it could not remove -- + /// deleteDatabase ORs the results of deleting the file, the journal, the shared-memory index, + /// the write-ahead log and any master journals, so it answers true when the database file went + /// and a read-only or busy -wal stayed. Reading that boolean would therefore report success + /// over surviving pages just as ignoring it did, so this looks at the files instead. + /// + /// It matters most for the case this was added for: those files hold rows that were written, + /// and for an encrypted database they are as readable as the pages they came from. A caller + /// told the database was deleted has no reason to look, so the only chance to say so is here. + /// + /// #### Parameters + /// + /// - `path`: the database file, whose companions share its name + /// + /// #### Throws + /// + /// - `IOException`: naming whatever is still on disk + private void requireDatabaseGone(String path) throws IOException { + File database = new File(path); + StringBuilder left = new StringBuilder(); + if (database.exists()) { + left.append(' ').append(database.getPath()); + } + String[] sidecars = databaseSidecarPaths(path); + for (int iter = 0; iter < sidecars.length; iter++) { + File sidecar = new File(sidecars[iter]); + if (sidecar.exists()) { + left.append(' ').append(sidecar.getPath()); + } + } + // The master journals as well, which is why this lists the directory rather than checking + // three fixed names: SQLite names them -mj and there can be more than one. + File directory = database.getParentFile(); + if (directory != null) { + final String prefix = database.getName() + "-mj"; + File[] journals = directory.listFiles(); + if (journals != null) { + for (int iter = 0; iter < journals.length; iter++) { + if (journals[iter].getName().startsWith(prefix)) { + left.append(' ').append(journals[iter].getPath()); + } + } + } + } + if (left.length() > 0) { + throw new IOException("The database was not fully deleted. These files are still on " + + "disk and hold its data:" + left + ". Close every connection to it and try " + + "again, or remove them."); + } + } + + @Override + public boolean existsDB(String databaseName) { + // Recover first. A conversion interrupted between its two renames leaves the live name + // missing while the database itself sits complete in the migration directory, and + // reporting "does not exist" there would refuse a retry of encrypt or decrypt - the one + // operation that could put it right. + String path = resolveNativeDatabasePath(databaseName); + // The claim, not a look at it. Asking whether a conversion is running and then recovering + // are two steps, and a conversion starting in between would find recovery already moving + // its marker, target and backup around: depending on how far it had got, recovery would + // delete the export it was writing, restore the backup during the swap, or -- the worst + // of the three -- remove the backup before the converted file had been validated, which + // is the copy the conversion falls back to when the reopen fails. + if (!claimDatabaseForRecovery(path, 0)) { + // A conversion is mid-flight and owns both the live file and its working copies. + // Recovering underneath it would act on a half-installed state, so this answers from + // what the conversion has not yet consumed instead. + return hasRecoverableDatabaseBackup(path) || new File(path).exists(); + } + try { + recoverInterruptedDatabaseMigration(path); + } catch (IOException cannotRecover) { + // The data is still in the migration directory, so the database does exist even + // though it could not be moved back. Say so; the open will report the real problem. + return hasRecoverableDatabaseBackup(path); + } finally { + endDatabaseMigration(path); + } + if (databaseName.startsWith("file://")) { + return exists(databaseName); + } + File db = new File(getContext().getApplicationInfo().dataDir + "/databases/" + databaseName); + return db.exists(); + } + + public String getDatabasePath(String databaseName) { + if (databaseName.startsWith("file://")) { + return databaseName; + } + File db = new File(getContext().getApplicationInfo().dataDir + "/databases/" + databaseName); + return db.getAbsolutePath(); + } + + public boolean isNativeTitle() { + if(com.codename1.ui.Toolbar.isGlobalToolbar()) { + return false; + } + Form f = getCurrentForm(); + boolean nativeCommand; + if(f != null){ + nativeCommand = f.getMenuBar().getCommandBehavior() == Display.COMMAND_BEHAVIOR_NATIVE; + }else{ + nativeCommand = getCommandBehavior() == Display.COMMAND_BEHAVIOR_NATIVE; + } + return hasActionBar() && nativeCommand; + } + + public void refreshNativeTitle(){ + if (getActivity() == null || com.codename1.ui.Toolbar.isGlobalToolbar()) { + return; + } + Form f = getCurrentForm(); + if (f != null && isNativeTitle() && !(f instanceof Dialog)) { + getActivity().runOnUiThread(new SetCurrentFormImpl(getActivity(), f)); + } + } + + public void setCurrentForm(final Form f) { + if (getActivity() == null) { + return; + } + if(getCurrentForm() == null){ + flushGraphics(); + } + if(editInProgress()) { + stopEditing(true); + } + super.setCurrentForm(f); + if (isNativeTitle() && !(f instanceof Dialog)) { + getActivity().runOnUiThread(new SetCurrentFormImpl(getActivity(), f)); + } + } + + @Override + public void setNativeCommands(Vector commands) { + refreshNativeTitle(); + } + + @Override + public boolean isScreenLockSupported() { + return true; + } + + @Override + public void lockScreen(){ + ((CodenameOneActivity)getContext()).lockScreen(); + } + + @Override + public void unlockScreen(){ + ((CodenameOneActivity)getContext()).unlockScreen(); + } + + private static class SetCurrentFormImpl implements Runnable { + private Activity activity; + private Form f; + + public SetCurrentFormImpl(Activity activity, Form f) { + this.activity = activity; + this.f = f; + } + + @Override + public void run() { + if(com.codename1.ui.Toolbar.isGlobalToolbar()) { + return; + } + ActionBar ab = activity.getActionBar(); + String title = f.getTitle(); + boolean hasMenuBtn = false; + if(android.os.Build.VERSION.SDK_INT >= 14){ + try { + ViewConfiguration vc = ViewConfiguration.get(activity); + Method m = vc.getClass().getMethod("hasPermanentMenuKey", (Class[])null); + hasMenuBtn = ((Boolean)m.invoke(vc, (Object[])null)).booleanValue(); + } catch(Throwable t) { + t.printStackTrace(); + } + } + if((title != null && title.length() > 0) || (f.getCommandCount() > 0 && !hasMenuBtn)){ + activity.runOnUiThread(new NotifyActionBar(activity, true)); + }else{ + activity.runOnUiThread(new NotifyActionBar(activity, false)); + return; + } + + ab.setTitle(title); + ab.setDisplayHomeAsUpEnabled(f.getBackCommand() != null); + if(android.os.Build.VERSION.SDK_INT >= 14){ + Image icon = f.getTitleComponent().getIcon(); + try { + if(icon != null){ + ab.getClass().getMethod("setIcon", Drawable.class).invoke(ab, new BitmapDrawable(activity.getResources(), (Bitmap)icon.getImage())); + }else{ + if(activity.getApplicationInfo().icon != 0){ + ab.getClass().getMethod("setIcon", Integer.TYPE).invoke(ab, activity.getApplicationInfo().icon); + } + } + activity.runOnUiThread(new InvalidateOptionsMenuImpl(activity)); + } catch(Throwable t) { + t.printStackTrace(); + } + } + return; + } + + } + + private Purchase pur; + + @Override + public Purchase getInAppPurchase() { + try { + pur = ZoozPurchase.class.newInstance(); + return pur; + } catch(Throwable t) { + return super.getInAppPurchase(); + } + } + + @Override + public boolean isTimeoutSupported() { + return true; + } + + @Override + public void setTimeout(int t) { + timeout = t; + } + + @Override + public CodeScanner getCodeScanner() { + if(scannerInstance == null) { + scannerInstance = new CodeScannerImpl(); + } + return scannerInstance; + } + + public void addCookie(Cookie c, boolean addToWebViewCookieManager, boolean sync) { + if(addToWebViewCookieManager) { + CookieManager mgr; + CookieSyncManager syncer; + try { + syncer = CookieSyncManager.getInstance(); + mgr = getCookieManager(); + } catch(IllegalStateException ex) { + syncer = CookieSyncManager.createInstance(this.getContext()); + mgr = getCookieManager(); + } + java.text.SimpleDateFormat format = new java.text.SimpleDateFormat("EEE, dd-MMM-yyyy HH:mm:ss z"); + format.setTimeZone(TimeZone.getTimeZone("GMT")); + addCookie(c, mgr, format); + if(sync) { + syncer.sync(); + } + } + super.addCookie(c); + + + + } + + private void addCookie(Cookie c, CookieManager mgr, java.text.SimpleDateFormat format) { + + String d = c.getDomain(); + String port = ""; + if (d.contains(":")) { + // For some reason, the port must be stripped and stored separately + // or it won't retrieve it properly. + // https://github.com/codenameone/CodenameOne/issues/2804 + port = "; Port=" + d.substring(d.indexOf(":")+1); + d = d.substring(0, d.indexOf(":")); + } + String cookieString = c.getName() + "=" + c.getValue() + + "; Domain=" + d + + port + + "; Path=" + c.getPath() + + "; " + (c.isSecure() ? "Secure;" : "") + + (c.getExpires() != 0 ? (" Expires="+format.format(new Date(c.getExpires()))+";") : "") + + (c.isHttpOnly() ? "httpOnly;" : ""); + String cookieUrl = "http" + + (c.isSecure() ? "s" : "") + "://" + + d + + c.getPath(); + mgr.setCookie(cookieUrl, cookieString); + } + + public void addCookie(Cookie[] cs, boolean addToWebViewCookieManager, boolean sync) { + if(addToWebViewCookieManager) { + CookieManager mgr; + CookieSyncManager syncer; + try { + syncer = CookieSyncManager.getInstance(); + mgr = getCookieManager(); + } catch(IllegalStateException ex) { + syncer = CookieSyncManager.createInstance(this.getContext()); + mgr = getCookieManager(); + } + java.text.SimpleDateFormat format = new java.text.SimpleDateFormat("EEE, dd-MMM-yyyy HH:mm:ss z"); + format.setTimeZone(TimeZone.getTimeZone("GMT")); + + for (Cookie c : cs) { + addCookie(c, mgr, format); + + } + + if(sync) { + syncer.sync(); + } + } + super.addCookie(cs); + + + + } + + @Override + public void addCookie(Cookie c) { + if(isUseNativeCookieStore()) { + this.addCookie(c, true, true); + } else { + super.addCookie(c); + } + } + + + + @Override + public void addCookie(Cookie[] cookiesArray) { + if(isUseNativeCookieStore()) { + this.addCookie(cookiesArray, true); + } else { + super.addCookie(cookiesArray); + } + } + + public void addCookie(Cookie[] cookiesArray, boolean addToWebViewCookieManager){ + addCookie(cookiesArray, addToWebViewCookieManager, false); + + } + + + + class CodeScannerImpl extends CodeScanner implements IntentResultListener { + private ScanResult callback; + + @Override + public void scanQRCode(ScanResult callback) { + if (getActivity() == null) { + return; + } + if (getActivity() instanceof CodenameOneActivity) { + ((CodenameOneActivity) getActivity()).setIntentResultListener(this); + } + this.callback = callback; + IntentIntegrator in = new IntentIntegrator(getActivity()); + if(!in.initiateScan(IntentIntegrator.QR_CODE_TYPES, "QR_CODE_MODE")){ + // restore old activity handling + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + if(CodeScannerImpl.this != null && CodeScannerImpl.this.callback != null) { + CodeScannerImpl.this.callback.scanError(-1, "no scan app"); + CodeScannerImpl.this.callback = null; + } + } + }); + + if (getActivity() instanceof CodenameOneActivity) { + ((CodenameOneActivity) getActivity()).restoreIntentResultListener(); + } + } + } + + @Override + public void scanBarCode(ScanResult callback) { + if (getActivity() == null) { + return; + } + if (getActivity() instanceof CodenameOneActivity) { + ((CodenameOneActivity) getActivity()).setIntentResultListener(this); + } + this.callback = callback; + IntentIntegrator in = new IntentIntegrator(getActivity()); + Collection types = IntentIntegrator.PRODUCT_CODE_TYPES; + if(Display.getInstance().getProperty("scanAllCodeTypes", "false").equals("true")) { + types = IntentIntegrator.ALL_CODE_TYPES; + } + if(Display.getInstance().getProperty("android.scanTypes", null) != null) { + String[] arr = Display.getInstance().getProperty("android.scanTypes", null).split(";"); + types = Arrays.asList(arr); + } + + if(!in.initiateScan(types, "ONE_D_MODE")){ + // restore old activity handling + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + CodeScannerImpl.this.callback.scanError(-1, "no scan app"); + CodeScannerImpl.this.callback = null; + } + }); + + if (getActivity() instanceof CodenameOneActivity) { + ((CodenameOneActivity) getActivity()).restoreIntentResultListener(); + } + } + } + + public void onActivityResult(int requestCode, final int resultCode, Intent data) { + if (requestCode == IntentIntegrator.REQUEST_CODE && callback != null) { + final ScanResult sr = callback; + if (resultCode == Activity.RESULT_OK) { + final String contents = data.getStringExtra("SCAN_RESULT"); + final String formatName = data.getStringExtra("SCAN_RESULT_FORMAT"); + final byte[] rawBytes = data.getByteArrayExtra("SCAN_RESULT_BYTES"); + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + sr.scanCompleted(contents, formatName, rawBytes); + } + }); + } else if(resultCode == Activity.RESULT_CANCELED) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + sr.scanCanceled(); + } + }); + + } else { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + sr.scanError(resultCode, null); + } + }); + } + callback = null; + } + + // restore old activity handling + if (getActivity() instanceof CodenameOneActivity) { + ((CodenameOneActivity) getActivity()).restoreIntentResultListener(); + } + } + } + + public boolean hasCamera() { + try { + int numCameras = Camera.getNumberOfCameras(); + return numCameras > 0; + } catch(Throwable t) { + return true; + } + } + + @Override + public com.codename1.impl.CameraImpl createCameraImpl() { + Activity act = getActivity(); + if (act == null) return null; + return new AndroidCameraImpl(act); + } + + @Override + public com.codename1.impl.ARImpl createARImpl() { + Activity act = getActivity(); + if (act == null) { + return null; + } + // The ARCore-backed impl lives in a package the build deletes for + // apps that never reference com.codename1.ar (it compiles against + // com.google.ar.core which only exists when the AR gradle dependency + // was injected), so it must be reached reflectively. + try { + Class clazz = Class.forName("com.codename1.impl.android.ar.AndroidARImpl"); + return (com.codename1.impl.ARImpl) clazz + .getConstructor(Activity.class).newInstance(act); + } catch (Throwable t) { + return null; + } + } + + private AndroidNearbyBridge nearbyBridge; + + /// The nearby bridge, which finds its own implementation. + /// + /// Always returned rather than conditionally null: the shell answers every + /// capability query honestly whether or not the optional backend was + /// bundled, so the public API reports NOT_SUPPORTED without this getter + /// having to know how the app was built. + @Override + public synchronized com.codename1.nearby.spi.NearbyBridge + getNearbyBridge() { + // Synchronized, because two threads reaching nearby for the first + // time both saw null and both built a backend. Only one was kept, + // and the loser could already have prepared a UWB session or taken + // the companion chooser slot in state nothing could reach again -- + // so a later start or stop could not find its session, and the radio + // it had opened stayed open. + if (nearbyBridge == null) { + nearbyBridge = new AndroidNearbyBridge(getActivity()); + } + return nearbyBridge; + } + + private com.codename1.impl.android.call.AndroidCallBridge callBridge; + + private com.codename1.impl.android.vpn.AndroidVpnBridge vpnBridge; + + /// The call bridge, on Telecom. + /// + /// Always returned rather than conditionally null: the bridge answers + /// every capability query honestly, including reporting no support at all + /// below API 26 where a self-managed ConnectionService does not exist, so + /// the public API degrades without this getter having to know the OS + /// version. + /// + /// Synchronized for the reason the nearby getter is: the bridge holds the + /// registered PhoneAccount, and two threads racing this would each build + /// one, with the loser's registration unreachable. + @Override + public synchronized com.codename1.call.spi.CallBridge getCallBridge() { + if (callBridge == null) { + callBridge = new com.codename1.impl.android.call.AndroidCallBridge( + callServiceContext()); + } + return callBridge; + } + + /// The context the call and VPN bridges do their system work through. + /// + /// NOT getActivity(): Codename One can be initialised from a Service -- + /// which is what happens when a push wakes the app to report an incoming + /// call -- and getActivity() is null there. The bridge cached that null + /// for the life of the process, so even isSupported() threw on the + /// TelecomManager lookup, and foregrounding later did not repair it. + /// + /// An activity is only needed to SHOW something, and the two places that + /// need one look for it when they get there. + private Context callServiceContext() { + Context any = getActivity(); + if (any == null) { + any = getContext(); + } + if (any == null) { + return null; + } + // The APPLICATION context, never the Activity. Both bridges keep + // what they are given in a final field and are never cleared, so + // caching an Activity here held that Activity and its whole view + // hierarchy reachable for the rest of the process -- a leak renewed + // by every rotation. Nothing the bridges do with it needs an + // Activity: they look up system services, the package manager and + // the application label, and the two places that must SHOW + // something ask getActivity() at the point of showing, which is + // what the comment above already promised and what + // currentActivity() implements. + Context app = any.getApplicationContext(); + return app != null ? app : any; + } + + /// The VPN bridge, on the platform's managed IKEv2 client. + /// + /// Reports no support below API 30, where `VpnManager` does not exist. + @Override + public synchronized com.codename1.vpn.spi.VpnBridge getVpnBridge() { + if (vpnBridge == null) { + vpnBridge = new com.codename1.impl.android.vpn.AndroidVpnBridge( + callServiceContext()); + } + return vpnBridge; + } + + @Override + public com.codename1.impl.VisionImpl createVisionImpl() { + return (com.codename1.impl.VisionImpl) createOptionalAiBackend( + "com.codename1.impl.android.ai.AndroidVisionImpl"); + } + + @Override + public com.codename1.impl.InferenceImpl createInferenceImpl() { + return (com.codename1.impl.InferenceImpl) createOptionalAiBackend( + "com.codename1.impl.android.ai.AndroidInferenceImpl"); + } + + @Override + public com.codename1.impl.LanguageImpl createLanguageImpl() { + return (com.codename1.impl.LanguageImpl) createOptionalAiBackend( + "com.codename1.impl.android.ai.AndroidLanguageImpl"); + } + + private Object createOptionalAiBackend(String className) { + try { + return Class.forName(className).newInstance(); + } catch (Throwable t) { + return null; + } + } + + // Deeper-network connectivity platform factories. Each returns a small + // platform-specific class living under + // com.codename1.impl.android.connectivity. Those classes are loaded + // lazily on first call so apps that never reference WiFi / Bonjour / + // USB / NetworkTypeListener never pay the loading cost. + + @Override + protected com.codename1.io.wifi.WifiPlatform createWifiPlatform() { + return new com.codename1.impl.android.connectivity.AndroidWifiPlatform(); + } + + @Override + protected com.codename1.io.wifi.WifiDirectPlatform createWifiDirectPlatform() { + return new com.codename1.impl.android.connectivity.AndroidWifiDirectPlatform(); + } + + @Override + protected com.codename1.io.bonjour.BonjourPlatform createBonjourPlatform() { + return new com.codename1.impl.android.connectivity.AndroidBonjourPlatform(); + } + + @Override + protected com.codename1.io.usb.UsbPlatform createUsbPlatform() { + return new com.codename1.impl.android.connectivity.AndroidUsbPlatform(); + } + + @Override + protected com.codename1.io.NetworkTypePlatform createNetworkTypePlatform() { + return new com.codename1.impl.android.connectivity.AndroidNetworkTypePlatform(); + } + + public String getCurrentAccessPoint() { + + ConnectivityManager cm = (ConnectivityManager) getContext().getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo info = cm.getActiveNetworkInfo(); + if (info == null) { + return null; + } + String apName = info.getTypeName() + "_" + info.getSubtypeName(); + if (info.getExtraInfo() != null) { + apName += "_" + info.getExtraInfo(); + } + return apName; + } + + @Override + public boolean isVPNDetectionSupported() { + return true; + } + + @Override + public boolean isVPNActive() { + try { + ConnectivityManager cm = (ConnectivityManager) getContext().getSystemService(Context.CONNECTIVITY_SERVICE); + if (cm != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + android.net.Network network = cm.getActiveNetwork(); + if (network != null) { + android.net.NetworkCapabilities capabilities = cm.getNetworkCapabilities(network); + if (capabilities != null && capabilities.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN)) { + return true; + } + } + } + + Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); + while (interfaces != null && interfaces.hasMoreElements()) { + NetworkInterface current = interfaces.nextElement(); + if (!current.isUp() || current.isLoopback()) { + continue; + } + String name = current.getName(); + if (name == null) { + continue; + } + name = name.toLowerCase(Locale.US); + if (name.startsWith("tun") || name.startsWith("ppp") || name.startsWith("tap") || name.startsWith("ipsec")) { + return true; + } + } + } catch (Throwable t) { + Log.d("Codename One", "VPN detection failed", t); + } + return false; + } + + /** + * @inheritDoc + */ + public String[] getAPIds() { + if (apIds == null) { + apIds = new HashMap(); + NetworkInfo[] aps = ((ConnectivityManager) getContext().getSystemService(Context.CONNECTIVITY_SERVICE)).getAllNetworkInfo(); + for (int i = 0; i < aps.length; i++) { + String apName = aps[i].getTypeName() + "_" + aps[i].getSubtypeName(); + if (aps[i].getExtraInfo() != null) { + apName += "_" + aps[i].getExtraInfo(); + } + apIds.put(apName, aps[i]); + } + } + if (apIds.isEmpty()) { + return null; + } + String[] ret = new String[apIds.size()]; + Iterator iter = apIds.keySet().iterator(); + for (int i = 0; iter.hasNext(); i++) { + ret[i] = iter.next().toString(); + } + return ret; + + } + + /** + * @inheritDoc + */ + public int getAPType(String id) { + if (apIds == null) { + getAPIds(); + } + NetworkInfo info = (NetworkInfo) apIds.get(id); + if (info == null) { + return NetworkManager.ACCESS_POINT_TYPE_UNKNOWN; + } + int type = info.getType(); + int subType = info.getSubtype(); + if (type == ConnectivityManager.TYPE_WIFI) { + return NetworkManager.ACCESS_POINT_TYPE_WLAN; + } else if (type == ConnectivityManager.TYPE_MOBILE) { + switch (subType) { + case TelephonyManager.NETWORK_TYPE_1xRTT: + return NetworkManager.ACCESS_POINT_TYPE_NETWORK2G; // ~ 50-100 kbps + case TelephonyManager.NETWORK_TYPE_CDMA: + return NetworkManager.ACCESS_POINT_TYPE_NETWORK2G; // ~ 14-64 kbps + case TelephonyManager.NETWORK_TYPE_EDGE: + return NetworkManager.ACCESS_POINT_TYPE_NETWORK2G; // ~ 50-100 kbps + case TelephonyManager.NETWORK_TYPE_EVDO_0: + return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 400-1000 kbps + case TelephonyManager.NETWORK_TYPE_EVDO_A: + return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 600-1400 kbps + case TelephonyManager.NETWORK_TYPE_GPRS: + return NetworkManager.ACCESS_POINT_TYPE_NETWORK2G; // ~ 100 kbps + case TelephonyManager.NETWORK_TYPE_HSDPA: + return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 2-14 Mbps + case TelephonyManager.NETWORK_TYPE_HSPA: + return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 700-1700 kbps + case TelephonyManager.NETWORK_TYPE_HSUPA: + return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 1-23 Mbps + case TelephonyManager.NETWORK_TYPE_UMTS: + return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 400-7000 kbps + /* + * Above API level 7, make sure to set android:targetSdkVersion + * to appropriate level to use these + */ + case TelephonyManager.NETWORK_TYPE_EHRPD: // API level 11 + return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 1-2 Mbps + case TelephonyManager.NETWORK_TYPE_EVDO_B: // API level 9 + return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 5 Mbps + case TelephonyManager.NETWORK_TYPE_HSPAP: // API level 13 + return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 10-20 Mbps + case TelephonyManager.NETWORK_TYPE_IDEN: // API level 8 + return NetworkManager.ACCESS_POINT_TYPE_NETWORK2G; // ~25 kbps + case TelephonyManager.NETWORK_TYPE_LTE: // API level 11 + return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 10+ Mbps + // Unknown + case TelephonyManager.NETWORK_TYPE_UNKNOWN: + default: + return NetworkManager.ACCESS_POINT_TYPE_NETWORK2G; + } + } else { + return NetworkManager.ACCESS_POINT_TYPE_UNKNOWN; + } + } + + /** + * @inheritDoc + */ + public void setCurrentAccessPoint(String id) { + + if (apIds == null) { + getAPIds(); + } + NetworkInfo info = (NetworkInfo) apIds.get(id); + if (info == null || info.isConnectedOrConnecting()) { + return; + + } + ConnectivityManager cm = (ConnectivityManager) getContext().getSystemService(Context.CONNECTIVITY_SERVICE); + cm.setNetworkPreference(info.getType()); + } + + private void scanMedia(File file) { + Uri uri = Uri.fromFile(file); + Intent scanFileIntent = new Intent( + Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, uri); + getActivity().sendBroadcast(scanFileIntent); + } + + /** + * Gets the last image id from the media store + * + * @return + */ + private String getLastImageId() { + int idVal = 0;; + final String[] imageColumns = {MediaStore.Images.Media._ID}; + final String imageOrderBy = MediaStore.Images.Media._ID + " DESC"; + final String imageWhere = null; + final String[] imageArguments = null; + Cursor imageCursor = getContext().getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, imageColumns, imageWhere, imageArguments, imageOrderBy); + if (imageCursor.moveToFirst()) { + int id = imageCursor.getInt(imageCursor.getColumnIndex(MediaStore.Images.Media._ID)); + imageCursor.close(); + idVal = id; + } + return "" + idVal; + } + + private void clearMediaDB(String lastId, String capturePath) { + final String[] imageColumns = {MediaStore.Images.Media.DATA, MediaStore.Images.Media.DATE_TAKEN, MediaStore.Images.Media.SIZE, MediaStore.Images.Media._ID}; + final String imageOrderBy = MediaStore.Images.Media._ID + " DESC"; + final String imageWhere = MediaStore.Images.Media._ID + ">?"; + final String[] imageArguments = {lastId}; + Cursor imageCursor = getContext().getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, imageColumns, imageWhere, imageArguments, imageOrderBy); + if (imageCursor.getCount() > 1) { + while (imageCursor.moveToNext()) { + int id = imageCursor.getInt(imageCursor.getColumnIndex(MediaStore.Images.Media._ID)); + String path = imageCursor.getString(imageCursor.getColumnIndex(MediaStore.Images.Media.DATA)); + Long takenTimeStamp = imageCursor.getLong(imageCursor.getColumnIndex(MediaStore.Images.Media.DATE_TAKEN)); + Long size = imageCursor.getLong(imageCursor.getColumnIndex(MediaStore.Images.Media.SIZE)); + if (path.contentEquals(capturePath)) { + // Remove it + ContentResolver cr = getContext().getContentResolver(); + cr.delete(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaStore.Images.Media._ID + "=?", new String[]{Long.toString(id)}); + break; + } + } + } + imageCursor.close(); + } + + + @Override + public boolean isNativePickerTypeSupported(int pickerType) { + if(android.os.Build.VERSION.SDK_INT >= 11) { + return pickerType == Display.PICKER_TYPE_DATE || pickerType == Display.PICKER_TYPE_TIME || pickerType == Display.PICKER_TYPE_STRINGS; + } + return pickerType == Display.PICKER_TYPE_DATE || pickerType == Display.PICKER_TYPE_TIME; + } + + @Override + public Object showNativePicker(final int type, final Component source, final Object currentValue, final Object data) { + if (getActivity() == null) { + return null; + } + final boolean [] canceled = new boolean[1]; + final boolean [] dismissed = new boolean[1]; + + if(editInProgress()) { + stopEditing(true); + } + if(type == Display.PICKER_TYPE_TIME) { + + class TimePick implements TimePickerDialog.OnTimeSetListener, TimePickerDialog.OnCancelListener, Runnable { + int result = ((Integer)currentValue).intValue(); + public void onTimeSet(TimePicker tp, int hour, int minute) { + result = hour * 60 + minute; + dismissed[0] = true; + synchronized(this) { + notify(); + } + } + + public void run() { + while(!dismissed[0]) { + synchronized(this) { + try { + wait(50); + } catch(InterruptedException er) {} + } + } + } + + @Override + public void onCancel(DialogInterface di) { + dismissed[0] = true; + canceled[0] = true; + synchronized (this) { + notify(); + } + } + } + final TimePick pickInstance = new TimePick(); + getActivity().runOnUiThread(new Runnable() { + public void run() { + int hour = ((Integer)currentValue).intValue() / 60; + int minute = ((Integer)currentValue).intValue() % 60; + TimePickerDialog tp = new TimePickerDialog(getActivity(), pickInstance, hour, minute, true){ + + @Override + public void cancel() { + super.cancel(); + dismissed[0] = true; + canceled[0] = true; + } + + @Override + public void dismiss() { + super.dismiss(); + dismissed[0] = true; + } + + }; + tp.setOnCancelListener(pickInstance); + //DateFormat.is24HourFormat(activity)); + tp.show(); + } + }); + Display.getInstance().invokeAndBlock(pickInstance); + if(canceled[0]) { + return null; + } + return new Integer(pickInstance.result); + } + if(type == Display.PICKER_TYPE_DATE) { + final java.util.Calendar cl = java.util.Calendar.getInstance(); + if(currentValue != null) { + cl.setTime((Date)currentValue); + } + class DatePick implements DatePickerDialog.OnDateSetListener,DatePickerDialog.OnCancelListener, Runnable { + Date result = (Date)currentValue; + + public void onDateSet(DatePicker dp, int year, int month, int day) { + java.util.Calendar c = java.util.Calendar.getInstance(); + c.set(java.util.Calendar.YEAR, year); + c.set(java.util.Calendar.MONTH, month); + c.set(java.util.Calendar.DAY_OF_MONTH, day); + result = c.getTime(); + dismissed[0] = true; + synchronized(this) { + notify(); + } + } + + public void run() { + while(!dismissed[0]) { + synchronized(this) { + try { + wait(50); + } catch(InterruptedException er) {} + } + } + } + + public void onCancel(DialogInterface di) { + result = null; + dismissed[0] = true; + canceled[0] = true; + synchronized(this) { + notify(); + } + } + } + final DatePick pickInstance = new DatePick(); + getActivity().runOnUiThread(new Runnable() { + public void run() { + DatePickerDialog tp = new DatePickerDialog(getActivity(), pickInstance, cl.get(java.util.Calendar.YEAR), cl.get(java.util.Calendar.MONTH), cl.get(java.util.Calendar.DAY_OF_MONTH)){ + + @Override + public void cancel() { + super.cancel(); + dismissed[0] = true; + canceled[0] = true; + } + + @Override + public void dismiss() { + super.dismiss(); + dismissed[0] = true; + } + + }; + tp.setOnCancelListener(pickInstance); + tp.show(); + } + }); + Display.getInstance().invokeAndBlock(pickInstance); + return pickInstance.result; + } + if(type == Display.PICKER_TYPE_STRINGS) { + final String[] values = (String[])data; + class StringPick implements Runnable, NumberPicker.OnValueChangeListener { + int result = -1; + + StringPick() { + } + + public void run() { + while(!dismissed[0]) { + synchronized(this) { + try { + wait(50); + } catch(InterruptedException er) {} + } + } + } + + public void cancel() { + dismissed[0] = true; + canceled[0] = true; + synchronized(this) { + notify(); + } + } + + public void ok() { + canceled[0] = false; + dismissed[0] = true; + synchronized(this) { + notify(); + } + } + + @Override + public void onValueChange(NumberPicker np, int oldVal, int newVal) { + result = newVal; + } + } + + final StringPick pickInstance = new StringPick(); + for(int iter = 0 ; iter < values.length ; iter++) { + if(values[iter].equals(currentValue)) { + pickInstance.result = iter; + break; + } + } + if (pickInstance.result == -1 && values.length > 0) { + // The picker will default to showing the first element anyways + // If we don't set the result to 0, then the user has to first + // scroll to a different number, then back to the first option + // to pick the first option. + pickInstance.result = 0; + } + + getActivity().runOnUiThread(new Runnable() { + public void run() { + NumberPicker picker = new NumberPicker(getActivity()); + if(source.getClientProperty("showKeyboard") == null) { + picker.setDescendantFocusability(NumberPicker.FOCUS_BLOCK_DESCENDANTS); + } + picker.setMinValue(0); + picker.setMaxValue(values.length - 1); + picker.setDisplayedValues(values); + picker.setOnValueChangedListener(pickInstance); + if(pickInstance.result > -1) { + picker.setValue(pickInstance.result); + } + RelativeLayout linearLayout = new RelativeLayout(getActivity()); + RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(50, 50); + RelativeLayout.LayoutParams numPicerParams = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT); + numPicerParams.addRule(RelativeLayout.CENTER_HORIZONTAL); + + linearLayout.setLayoutParams(params); + linearLayout.addView(picker,numPicerParams); + + AlertDialog.Builder alertDialogBuilder = new AlertDialog.Builder(getActivity()); + alertDialogBuilder.setView(linearLayout); + alertDialogBuilder + .setCancelable(false) + .setPositiveButton("Ok", + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, + int id) { + pickInstance.ok(); + } + }) + .setNegativeButton("Cancel", + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, + int id) { + dialog.cancel(); + pickInstance.cancel(); + } + }); + AlertDialog alertDialog = alertDialogBuilder.create(); + alertDialog.show(); + } + }); + Display.getInstance().invokeAndBlock(pickInstance); + if(canceled[0]) { + return null; + } + if(pickInstance.result < 0) { + return null; + } + return values[pickInstance.result]; + } + return null; + } + + private ServerSockets serverSockets; + private synchronized ServerSockets getServerSockets() { + if (serverSockets == null) { + serverSockets = new ServerSockets(); + } + return serverSockets; + } + + class ServerSockets { + Map socks = new HashMap(); + Map loopbackSocks = new HashMap(); + + public synchronized ServerSocket get(int port) throws IOException { + return get(port, false); + } + + /** + * When loopbackOnly is set the socket binds 127.0.0.1 rather than the wildcard + * address, so the channel isn't published on every network interface. The two + * are cached in SEPARATE maps: a port that is already bound to the wildcard + * address must never be handed back to a caller that asked for loopback. + * Distinguishing them by sign within one map would collide on port 0, the + * ephemeral-port request, where -0 == 0. + * + * The IPv4 loopback is named explicitly rather than taken from + * InetAddress.getLoopbackAddress(), which answers ::1 when the runtime + * prefers IPv6. A client that then connects to 127.0.0.1 - which is what + * adb forward and attaching agents do, and what the iOS port binds - would + * find nothing listening, with the server reporting that it had started. + */ + public synchronized ServerSocket get(int port, boolean loopbackOnly) throws IOException { + Map cache = loopbackOnly ? loopbackSocks : socks; + Integer key = Integer.valueOf(port); + ServerSocket sock = cache.get(key); + if (sock == null || sock.isClosed()) { + sock = loopbackOnly + ? new ServerSocket(port, 50, InetAddress.getByName("127.0.0.1")) + : new ServerSocket(port); + cache.put(key, sock); + } + return sock; + } + + /** + * Closes and forgets the socket, so a thread blocked in accept returns and a + * later listener on this port binds a fresh one rather than sharing this. + */ + public synchronized void close(int port, boolean loopbackOnly) { + Map cache = loopbackOnly ? loopbackSocks : socks; + ServerSocket sock = cache.remove(Integer.valueOf(port)); + if (sock != null) { + try { + sock.close(); + } catch (IOException ignored) { + // best effort: the point is to unblock accept, and a socket that + // cannot be closed is already unusable + } + } + } + + + } + + class SocketImpl { + java.net.Socket socketInstance; + int errorCode = -1; + String errorMessage = null; + InputStream is; + OutputStream os; + + public boolean connect(String param, int param1, int connectTimeout) { + try { + socketInstance = new java.net.Socket(); + socketInstance.connect(new InetSocketAddress(param, param1), connectTimeout); + return true; + } catch(Exception err) { + err.printStackTrace(); + errorMessage = err.toString(); + return false; + } + } + + private InputStream getInput() throws IOException { + if(is == null) { + if(socketInstance != null) { + is = socketInstance.getInputStream(); + } else { + + } + } + return is; + } + + private OutputStream getOutput() throws IOException { + if(os == null) { + os = socketInstance.getOutputStream(); + } + return os; + } + + public int getAvailableInput() { + try { + return getInput().available(); + } catch(IOException err) { + errorMessage = err.toString(); + err.printStackTrace(); + } + return 0; + } + + public String getErrorMessage() { + return errorMessage; + } + + public byte[] readFromStream() { + try { + int av = getAvailableInput(); + if(av > 0) { + byte[] arr = new byte[av]; + int size = getInput().read(arr); + if(size == arr.length) { + return arr; + } + return shrink(arr, size); + } + byte[] arr = new byte[8192]; + int size = getInput().read(arr); + if(size == arr.length) { + return arr; + } + return shrink(arr, size); + } catch(IOException err) { + err.printStackTrace(); + errorMessage = err.toString(); + return null; + } + } + + private byte[] shrink(byte[] arr, int size) { + if(size == -1) { + return null; + } + byte[] n = new byte[size]; + System.arraycopy(arr, 0, n, 0, size); + return n; + } + + public void writeToStream(byte[] param) { + writeToStream(param, 0, param.length); + } + + public void writeToStream(byte[] param, int offset, int len) { + try { + OutputStream os = getOutput(); + os.write(param, offset, len); + os.flush(); + } catch(IOException err) { + errorMessage = err.toString(); + err.printStackTrace(); + } + } + + public void disconnect() { + try { + if(socketInstance != null) { + if(is != null) { + try { + is.close(); + } catch(IOException err) {} + } + if(os != null) { + try { + os.close(); + } catch(IOException err) {} + } + socketInstance.close(); + socketInstance = null; + } + } catch(IOException err) { + errorMessage = err.toString(); + err.printStackTrace(); + } + } + + public Object listen(int param) { + return listen(param, false); + } + + public Object listen(int param, boolean loopbackOnly) { + ServerSocket serverSocketInstance = null; + try { + serverSocketInstance = getServerSockets().get(param, loopbackOnly); + socketInstance = serverSocketInstance.accept(); + SocketImpl si = new SocketImpl(); + si.socketInstance = socketInstance; + return si; + } catch(Exception err) { + errorMessage = err.toString(); + // A closed socket here is the deliberate stop path: stopping a + // listener closes it precisely to bring this accept back. Printing a + // stack trace for that would put an alarming fake failure in the log + // every time a listener is stopped. + if(serverSocketInstance == null || !serverSocketInstance.isClosed()) { + err.printStackTrace(); + } + return null; + } + } + + public boolean isConnected() { + return socketInstance != null; + } + + public int getErrorCode() { + return errorCode; + } + } + + @Override + public Object connectSocket(String host, int port) { + return connectSocket(host, port, 0); + } + + + + @Override + public Object connectSocket(String host, int port, int connectTimeout) { + SocketImpl i = new SocketImpl(); + if(i.connect(host, port, connectTimeout)) { + return i; + } + return null; + } + + @Override + public Object listenSocket(int port) { + return new SocketImpl().listen(port); + } + + @Override + public boolean isLoopbackServerSocketAvailable() { + return true; + } + + @Override + public Object listenSocketLoopback(int port) { + return new SocketImpl().listen(port, true); + } + + @Override + public void stopListeningSocket(int port, boolean loopbackOnly) { + getServerSockets().close(port, loopbackOnly); + } + + /** + * A debuggable package is one built for development: the flag is set by the + * build for a debug variant and cleared for a release variant, so this reads the + * distinction straight off the installed application rather than guessing. + */ + @Override + public boolean isDebuggableBuild() { + Context ctx = getContext(); + if (ctx == null) { + return false; + } + ApplicationInfo info = ctx.getApplicationInfo(); + return info != null && (info.flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0; + } + + @Override + public String getHostOrIP() { + try { + InetAddress i = java.net.InetAddress.getLocalHost(); + if(i.isLoopbackAddress()) { + Enumeration nie = NetworkInterface.getNetworkInterfaces(); + while(nie.hasMoreElements()) { + NetworkInterface current = nie.nextElement(); + if(!current.isLoopback()) { + Enumeration iae = current.getInetAddresses(); + while(iae.hasMoreElements()) { + InetAddress currentI = iae.nextElement(); + if(!currentI.isLoopbackAddress()) { + return currentI.getHostAddress(); + } + } + } + } + } + return i.getHostAddress(); + } catch(Throwable t) { + com.codename1.io.Log.e(t); + return null; + } + } + + @Override + public void disconnectSocket(Object socket) { + ((SocketImpl)socket).disconnect(); + } + + @Override + public boolean isSocketConnected(Object socket) { + return ((SocketImpl)socket).isConnected(); + } + + + + @Override + public boolean isServerSocketAvailable() { + return true; + } + + @Override + public boolean isSocketAvailable() { + return true; + } + + @Override + public String getSocketErrorMessage(Object socket) { + return ((SocketImpl)socket).getErrorMessage(); + } + + @Override + public int getSocketErrorCode(Object socket) { + return ((SocketImpl)socket).getErrorCode(); + } + + @Override + public int getSocketAvailableInput(Object socket) { + return ((SocketImpl)socket).getAvailableInput(); + } + + @Override + public byte[] readFromSocketStream(Object socket) { + return ((SocketImpl)socket).readFromStream(); + } + + @Override + public void writeToSocketStream(Object socket, byte[] data) { + ((SocketImpl)socket).writeToStream(data); + } + + @Override + public boolean isWebSocketSupported() { + return true; + } + + @Override + public com.codename1.impl.WebSocketImpl createWebSocketImpl(String url) { + return new AndroidWebSocketImpl(url); + } + + @Override + public void writeToSocketStream(Object socket, byte[] data, int offset, int len) { + ((SocketImpl)socket).writeToStream(data, offset, len); + } + + //Begin new Graphics Work + @Override + public boolean isShapeSupported(Object graphics) { + return true; + } + + @Override + public boolean isTransformSupported(Object graphics) { + return true; + } + + @Override + public boolean isPerspectiveTransformSupported(Object graphics){ + return android.os.Build.VERSION.SDK_INT >= 14; + } + + @Override + public void fillShape(Object graphics, com.codename1.ui.geom.Shape shape) { + AndroidGraphics ag = (AndroidGraphics)graphics; + Path p = cn1ShapeToAndroidPath(shape); + ag.fillPath(p); + } + + @Override + public void fillShapeShadow(Object graphics, com.codename1.ui.geom.Shape shape, int fillColor, + int fillAlpha, int shadowColor, float shadowOpacity, int blurRadius, int offsetX, int offsetY) { + AndroidGraphics ag = (AndroidGraphics)graphics; + Path p = cn1ShapeToAndroidPath(shape); + ag.fillPathShadow(p, fillColor, fillAlpha, shadowColor, shadowOpacity, blurRadius, offsetX, offsetY); + } + + @Override + public boolean isShapeShadowSupported(Object graphics) { + // Android's Canvas has no cheap GPU shadow for arbitrary shapes: BlurMaskFilter is ignored on + // the hardware canvas, and Paint.setShadowLayer collapses the whole view to software rendering + // (severe jank/ANR). Fall back to the cached-image path; the RAM cost is bounded by keeping the + // number of live shadowed components small (windowed lists) or disabling the per-border cache. + return false; + } + + @Override + public void drawShape(Object graphics, com.codename1.ui.geom.Shape shape, com.codename1.ui.Stroke stroke) { + AndroidGraphics ag = (AndroidGraphics)graphics; + Path p = cn1ShapeToAndroidPath(shape); + ag.drawPath(p, stroke); + + } + + @Override + public void drawShadow(Object graphics, Object image, int x, int y, int offsetX, int offsetY, int blurRadius, int spreadRadius, int color, float opacity) { + AndroidGraphics ag = (AndroidGraphics)graphics; + + ag.drawShadow(image, x, y, offsetX, offsetY, blurRadius, spreadRadius, color, opacity); + } + + @Override + public boolean isDrawShadowSupported() { + return true; + } + + @Override + public boolean isDrawShadowFast() { + return false; + } + // BEGIN TRANSFORMATION METHODS--------------------------------------------------------- + + + + @Override + public boolean transformEqualsImpl(Transform t1, Transform t2) { + Object o1 = null; + if(t1 != null) { + o1 = t1.getNativeTransform(); + } + Object o2 = null; + if(t2 != null) { + o2 = t2.getNativeTransform(); + } + return transformNativeEqualsImpl(o1, o2); + } + + @Override + public boolean transformNativeEqualsImpl(Object t1, Object t2) { + if ( t1 != null ){ + CN1Matrix4f m1 = (CN1Matrix4f)t1; + CN1Matrix4f m2 = (CN1Matrix4f)t2; + return m1.equals(m2); + } else { + return t2 == null; + } + } + + + @Override + public boolean isTransformSupported() { + return true; + } + + @Override + public boolean isPerspectiveTransformSupported() { + + return true; + } + + @Override + public Object makeTransformAffine(double m00, double m10, double m01, double m11, double m02, double m12) { + CN1Matrix4f t = CN1Matrix4f.make(new float[]{ + (float)m00, (float)m10, 0, 0, + (float)m01, (float)m11, 0, 0, + 0, 0, 1, 0, + (float)m02, (float)m12, 0, 1 + }); + return t; + } + + @Override + public void setTransformAffine(Object nativeTransform, double m00, double m10, double m01, double m11, double m02, double m12) { + ((CN1Matrix4f)nativeTransform).setData(new float[]{ + (float)m00, (float)m10, 0, 0, + (float)m01, (float)m11, 0, 0, + 0, 0, 1, 0, + (float)m02, (float)m12, 0, 1 + }); + } + + + @Override + public Object makeTransformTranslation(float translateX, float translateY, float translateZ) { + return CN1Matrix4f.makeTranslation(translateX, translateY, translateZ); + } + + @Override + public void setTransformTranslation(Object nativeTransform, float translateX, float translateY, float translateZ) { + CN1Matrix4f m = (CN1Matrix4f)nativeTransform; + m.reset(); + m.translate(translateX, translateY, translateZ); + } + + @Override + public Object makeTransformScale(float scaleX, float scaleY, float scaleZ) { + CN1Matrix4f t = CN1Matrix4f.makeIdentity(); + t.scale(scaleX, scaleY, scaleZ); + return t; + } + + @Override + public void setTransformScale(Object nativeTransform, float scaleX, float scaleY, float scaleZ) { + CN1Matrix4f t = (CN1Matrix4f)nativeTransform; + t.reset(); + t.scale(scaleX, scaleY, scaleZ); + } + + @Override + public Object makeTransformRotation(float angle, float x, float y, float z) { + return CN1Matrix4f.makeRotation(angle, x, y, z); + } + + @Override + public void setTransformRotation(Object nativeTransform, float angle, float x, float y, float z) { + CN1Matrix4f m = (CN1Matrix4f)nativeTransform; + m.reset(); + m.rotate(angle, x, y, z); + } + + @Override + public Object makeTransformPerspective(float fovy, float aspect, float zNear, float zFar) { + return CN1Matrix4f.makePerspective(fovy, aspect, zNear, zFar); + } + + @Override + public void setTransformPerspective(Object nativeGraphics, float fovy, float aspect, float zNear, float zFar) { + CN1Matrix4f m = (CN1Matrix4f)nativeGraphics; + m.setPerspective(fovy, aspect, zNear, zFar); + } + + @Override + public Object makeTransformOrtho(float left, float right, float bottom, float top, float near, float far) { + return CN1Matrix4f.makeOrtho(left, right, bottom, top, near, far); + } + + @Override + public void setTransformOrtho(Object nativeGraphics, float left, float right, float bottom, float top, float near, float far) { + CN1Matrix4f m = (CN1Matrix4f)nativeGraphics; + m.setOrtho(left, right, bottom, top, near, far); + } + + @Override + public Object makeTransformCamera(float eyeX, float eyeY, float eyeZ, float centerX, float centerY, float centerZ, float upX, float upY, float upZ) { + return CN1Matrix4f.makeCamera(eyeX, eyeY, eyeZ, centerX, centerY, centerZ, upX, upY, upZ); + } + + @Override + public void setTransformCamera(Object nativeGraphics, float eyeX, float eyeY, float eyeZ, float centerX, float centerY, float centerZ, float upX, float upY, float upZ) { + CN1Matrix4f m = (CN1Matrix4f)nativeGraphics; + m.setCamera(eyeX, eyeY, eyeZ, centerX, centerY, centerZ, upX, upY, upZ); + } + + + @Override + public void transformRotate(Object nativeTransform, float angle, float x, float y, float z) { + ((CN1Matrix4f)nativeTransform).rotate(angle, x, y, z); + } + + @Override + public void transformTranslate(Object nativeTransform, float x, float y, float z) { + //((Matrix) nativeTransform).preTranslate(x, y); + ((CN1Matrix4f)nativeTransform).translate(x, y, z); + } + + @Override + public void transformScale(Object nativeTransform, float x, float y, float z) { + //((Matrix) nativeTransform).preScale(x, y); + ((CN1Matrix4f)nativeTransform).scale(x, y, z); + } + + @Override + public Object makeTransformInverse(Object nativeTransform) { + + CN1Matrix4f inverted = CN1Matrix4f.makeIdentity(); + inverted.setData(((CN1Matrix4f)nativeTransform).getData()); + if( inverted.invert()){ + return inverted; + } + return null; + + //Matrix inverted = new Matrix(); + //if(((Matrix) nativeTransform).invert(inverted)){ + // return inverted; + //} + //return null; + } + + @Override + public void setTransformInverse(Object nativeTransform) throws com.codename1.ui.Transform.NotInvertibleException { + + CN1Matrix4f m = (CN1Matrix4f)nativeTransform; + if (!m.invert()) { + throw new com.codename1.ui.Transform.NotInvertibleException(); + } + } + + @Override + public void setTransformIdentity(Object transform) { + CN1Matrix4f m = (CN1Matrix4f)transform; + m.setIdentity(); + } + + @Override + public Object makeTransformIdentity() { + return CN1Matrix4f.makeIdentity(); + } + + @Override + public void copyTransform(Object src, Object dest) { + CN1Matrix4f t1 = (CN1Matrix4f) src; + CN1Matrix4f t2 = (CN1Matrix4f) dest; + t2.setData(t1.getData()); + } + + @Override + public void concatenateTransform(Object t1, Object t2) { + //((Matrix) t1).preConcat((Matrix) t2); + ((CN1Matrix4f)t1).concatenate((CN1Matrix4f)t2); + } + + @Override + public void transformPoint(Object nativeTransform, float[] in, float[] out) { + //Matrix t = (Matrix) nativeTransform; + //t.mapPoints(in, 0, out, 0, 2); + ((CN1Matrix4f)nativeTransform).transformCoord(in, out); + } + + @Override + public void setTransform(Object graphics, Transform transform) { + AndroidGraphics ag = (AndroidGraphics) graphics; + Transform existing = ag.getTransform(); + if (existing == null) { + existing = transform == null ? Transform.makeIdentity() : transform.copy(); + ag.setTransform(existing); + } else { + if (transform == null) { + existing.setIdentity(); + } else { + existing.setTransform(transform); + } + ag.setTransform(existing); // sets dirty flag for transform + } + + } + + @Override + public com.codename1.ui.Transform getTransform(Object graphics) { + com.codename1.ui.Transform t = ((AndroidGraphics) graphics).getTransform(); + if (t == null) { + return Transform.makeIdentity(); + } + Transform t2 = Transform.makeIdentity(); + t2.setTransform(t); + return t2; + } + + @Override + public void getTransform(Object graphics, Transform transform) { + com.codename1.ui.Transform t = ((AndroidGraphics) graphics).getTransform(); + if (t == null) { + transform.setIdentity(); + } else { + transform.setTransform(t); + } + } + + + // END TRANSFORM STUFF + + + static Path cn1ShapeToAndroidPath(com.codename1.ui.geom.Shape shape, Path p) { + //Path p = new Path(); + p.rewind(); + + com.codename1.ui.geom.PathIterator it = shape.getPathIterator(); + switch (it.getWindingRule()) { + case GeneralPath.WIND_EVEN_ODD: + p.setFillType(Path.FillType.EVEN_ODD); + break; + case GeneralPath.WIND_NON_ZERO: + p.setFillType(Path.FillType.WINDING); + break; + } + //p.setWindingRule(it.getWindingRule() == com.codename1.ui.geom.PathIterator.WIND_EVEN_ODD ? GeneralPath.WIND_EVEN_ODD : GeneralPath.WIND_NON_ZERO); + float[] buf = new float[6]; + while (!it.isDone()) { + int type = it.currentSegment(buf); + switch (type) { + case com.codename1.ui.geom.PathIterator.SEG_MOVETO: + p.moveTo(buf[0], buf[1]); + break; + case com.codename1.ui.geom.PathIterator.SEG_LINETO: + p.lineTo(buf[0], buf[1]); + break; + case com.codename1.ui.geom.PathIterator.SEG_QUADTO: + p.quadTo(buf[0], buf[1], buf[2], buf[3]); + break; + case com.codename1.ui.geom.PathIterator.SEG_CUBICTO: + p.cubicTo(buf[0], buf[1], buf[2], buf[3], buf[4], buf[5]); + break; + case com.codename1.ui.geom.PathIterator.SEG_CLOSE: + p.close(); + break; + + } + it.next(); + } + + return p; + } + + static Path cn1ShapeToAndroidPath(com.codename1.ui.geom.Shape shape) { + return cn1ShapeToAndroidPath(shape, new Path()); + } + + /** + * The ID used for a local notification that should actually trigger a background + * fetch. This type of notification is handled specially by the {@link LocalNotificationPublisher}. It + * doesn't display a notification to the user, but instead just calls the {@link #performBackgroundFetch() } + * method. + */ + static final String BACKGROUND_FETCH_NOTIFICATION_ID="$$$CN1_BACKGROUND_FETCH$$$"; + + + /** + * Calls the background fetch callback. If the app is in teh background, this will + * check to see if the lifecycle class implements the {@link com.codename1.background.BackgroundFetch} + * interface. If it does, it will execute its {@link com.codename1.background.BackgroundFetch#performBackgroundFetch(long, com.codename1.util.Callback) } + * method. + * @param blocking True if this should block until it is complete. + */ + public static void performBackgroundFetch(boolean blocking) { + + if (Display.getInstance().isMinimized()) { + // By definition, background fetch should only occur if the app is minimized. + // This keeps it consistent with the iOS implementation that doesn't have a + // choice + final boolean[] complete = new boolean[1]; + final Object lock = new Object(); + final BackgroundFetch bgFetchListener = instance.getBackgroundFetchListener(); + final long timeout = System.currentTimeMillis()+25000; + if (bgFetchListener != null) { + Display.getInstance().callSerially(new Runnable() { + public void run() { + bgFetchListener.performBackgroundFetch(timeout, new Callback() { + + @Override + public void onSucess(Boolean value) { + // On Android the OS doesn't care whether it worked or not + // So we'll just consume this. + synchronized (lock) { + complete[0] = true; + lock.notify(); + } + } + + @Override + public void onError(Object sender, Throwable err, int errorCode, String errorMessage) { + com.codename1.io.Log.e(err); + synchronized (lock) { + complete[0] = true; + lock.notify(); + } + } + + }); + } + }); + + } + + while (blocking && !complete[0]) { + Util.wait(lock, 1000); + if (!complete[0]) { + System.out.println("Waiting for background fetch to complete. Make sure your background fetch handler calls onSuccess() or onError() in the callback when complete"); + + } + if (System.currentTimeMillis() > timeout) { + System.out.println("Background fetch exceeded time alotted. Not waiting for its completion"); + break; + } + + } + + + } + } + + /** + * Starts the background fetch service. + */ + public void startBackgroundFetchService() { + LocalNotification n = new LocalNotification(); + n.setId(BACKGROUND_FETCH_NOTIFICATION_ID); + cancelLocalNotification(BACKGROUND_FETCH_NOTIFICATION_ID); + // We schedule a local notification + // First callback will be at the repeat interval + // We don't specify a repeat interval because the scheduleLocalNotification will + // set that for us using the getPreferredBackgroundFetchInterval method. + scheduleLocalNotification(n, System.currentTimeMillis() + getPreferredBackgroundFetchInterval() * 1000, 0); + } + + public void stopBackgroundFetchService() { + cancelLocalNotification(BACKGROUND_FETCH_NOTIFICATION_ID); + } + + + private boolean backgroundFetchInitialized; + + @Override + public void setPreferredBackgroundFetchInterval(int seconds) { + int oldInterval = getPreferredBackgroundFetchInterval(); + super.setPreferredBackgroundFetchInterval(seconds); + + if (!backgroundFetchInitialized || oldInterval != seconds) { + backgroundFetchInitialized = true; + if (seconds > 0) { + startBackgroundFetchService(); + } else { + stopBackgroundFetchService(); + } + } + } + + + + @Override + public boolean isBackgroundFetchSupported() { + return true; + } + public static BackgroundFetch backgroundFetchListener; + + BackgroundFetch getBackgroundFetchListener() { + if (getActivity() != null && getActivity().getApp() instanceof BackgroundFetch) { + return (BackgroundFetch)getActivity().getApp(); + } else if (backgroundFetchListener != null) { + return backgroundFetchListener; + } else { + return null; + } + } + + /** + * Returns the fully qualified class name of the app's background fetch listener, or null + * when the app does not implement {@link com.codename1.background.BackgroundFetch}. The + * surfaces plumbing persists this name on publish so a home screen widget that rendered an + * exhausted timeline can start {@link BackgroundFetchHandler} and let the app republish + * fresh content while no activity exists. + * + * @return the listener class name or null + */ + public static String getBackgroundFetchListenerClassName() { + if (instance == null) { + return null; + } + BackgroundFetch listener = instance.getBackgroundFetchListener(); + return listener == null ? null : listener.getClass().getName(); + } + + public void scheduleLocalNotification(LocalNotification notif, long firstTime, int repeat) { + if (android.os.Build.VERSION.SDK_INT >= 33) { + if(!checkForPermission("android.permission.POST_NOTIFICATIONS", "This is required to receive notifications")){ + com.codename1.io.Log.e(new RuntimeException("Local notification was prevented the POST_NOTIFICATIONS permission was not granted by the user.")); + return; + } + } + final Intent notificationIntent = new Intent(getContext(), LocalNotificationPublisher.class); + notificationIntent.setAction(getContext().getApplicationInfo().packageName + "." + notif.getId()); + notificationIntent.putExtra(LocalNotificationPublisher.NOTIFICATION, createBundleFromNotification(notif)); + + Intent contentIntent = new Intent(); + if (activityComponentName != null) { + contentIntent.setComponent(activityComponentName); + } else { + try { + contentIntent.setComponent(getContext().getPackageManager().getLaunchIntentForPackage(getContext().getApplicationInfo().packageName).getComponent()); + } catch (Exception ex) { + System.err.println("Failed to get the component name for local notification. Local notification may not work."); + ex.printStackTrace(); + } + } + contentIntent.putExtra("LocalNotificationID", notif.getId()); + + if (BACKGROUND_FETCH_NOTIFICATION_ID.equals(notif.getId()) && getBackgroundFetchListener() != null) { + Context context = AndroidNativeUtil.getContext(); + + Intent intent = new Intent(context, BackgroundFetchHandler.class); + //there is an bug that causes this to not to workhttps://code.google.com/p/android/issues/detail?id=81812 + //intent.putExtra("backgroundClass", getBackgroundLocationListener().getName()); + //an ugly workaround to the putExtra bug + intent.setData(Uri.parse("http://codenameone.com/a?" + getBackgroundFetchListener().getClass().getName())); + PendingIntent pendingIntent = getPendingIntent(context, 0, + intent); + notificationIntent.putExtra(LocalNotificationPublisher.BACKGROUND_FETCH_INTENT, pendingIntent); + + } else { + contentIntent.setData(Uri.parse("http://codenameone.com/a?LocalNotificationID="+Uri.encode(notif.getId()))); + } + PendingIntent pendingContentIntent = createPendingIntent(getContext(), 0, contentIntent); + + notificationIntent.putExtra(LocalNotificationPublisher.NOTIFICATION_INTENT, pendingContentIntent); + // carry the configured content intent as a template so the publisher can build + // a distinct per-action PendingIntent (with the action id and any remote input) + if (!notif.getActions().isEmpty()) { + notificationIntent.putExtra(LocalNotificationPublisher.NOTIFICATION_CONTENT_TEMPLATE, contentIntent); + } + + + PendingIntent pendingIntent = getBroadcastPendingIntent(getContext(), 0, notificationIntent); + + AlarmManager alarmManager = (AlarmManager) getContext().getSystemService(Context.ALARM_SERVICE); + if (BACKGROUND_FETCH_NOTIFICATION_ID.equals(notif.getId())) { + alarmManager.setRepeating(AlarmManager.RTC_WAKEUP, firstTime, getPreferredBackgroundFetchInterval() * 1000, pendingIntent); + } else { + if(repeat == LocalNotification.REPEAT_NONE){ + alarmManager.set(AlarmManager.RTC_WAKEUP, firstTime, pendingIntent); + + }else if(repeat == LocalNotification.REPEAT_MINUTE){ + + alarmManager.setRepeating(AlarmManager.RTC_WAKEUP, firstTime, 60*1000, pendingIntent); + + }else if(repeat == LocalNotification.REPEAT_HOUR){ + + alarmManager.setInexactRepeating(AlarmManager.RTC_WAKEUP, firstTime, AlarmManager.INTERVAL_HALF_HOUR, pendingIntent); + + }else if(repeat == LocalNotification.REPEAT_DAY){ + + alarmManager.setInexactRepeating(AlarmManager.RTC_WAKEUP, firstTime, AlarmManager.INTERVAL_DAY, pendingIntent); + + }else if(repeat == LocalNotification.REPEAT_WEEK){ + + alarmManager.setRepeating(AlarmManager.RTC_WAKEUP, firstTime, AlarmManager.INTERVAL_DAY * 7, pendingIntent); + + } + } + } + + public void cancelLocalNotification(String notificationId) { + Intent notificationIntent = new Intent(getContext(), LocalNotificationPublisher.class); + notificationIntent.setAction(getContext().getApplicationInfo().packageName + "." + notificationId); + + PendingIntent pendingIntent = getBroadcastPendingIntent(getContext(), 0, notificationIntent); + AlarmManager alarmManager = (AlarmManager) getContext().getSystemService(Context.ALARM_SERVICE); + alarmManager.cancel(pendingIntent); + } + + static Bundle createBundleFromNotification(LocalNotification notif){ + Bundle b = new Bundle(); + b.putString("NOTIF_ID", notif.getId()); + b.putString("NOTIF_TITLE", notif.getAlertTitle()); + b.putString("NOTIF_BODY", notif.getAlertBody()); + b.putString("NOTIF_SOUND", notif.getAlertSound()); + b.putString("NOTIF_IMAGE", notif.getAlertImage()); + b.putInt("NOTIF_NUMBER", notif.getBadgeNumber()); + b.putString("NOTIF_CHANNEL", notif.getChannelId()); + b.putString("NOTIF_GROUP", notif.getGroupId()); + b.putBoolean("NOTIF_GROUP_SUMMARY", notif.isGroupSummary()); + b.putBoolean("NOTIF_FULLSCREEN", notif.isFullScreenIntent()); + b.putBoolean("NOTIF_TIME_SENSITIVE", notif.isTimeSensitive()); + b.putBoolean("NOTIF_ONGOING", notif.isOngoing()); + b.putInt("NOTIF_PROGRESS_MAX", notif.getProgressMax()); + b.putInt("NOTIF_PROGRESS", notif.getProgress()); + b.putBoolean("NOTIF_PROGRESS_INDETERMINATE", notif.isProgressIndeterminate()); + b.putString("NOTIF_CUSTOM_VIEW", notif.getCustomView()); + java.util.List actions = notif.getActions(); + if (!actions.isEmpty()) { + ArrayList ids = new ArrayList(); + ArrayList titles = new ArrayList(); + ArrayList icons = new ArrayList(); + ArrayList placeholders = new ArrayList(); + ArrayList buttons = new ArrayList(); + for (LocalNotification.Action a : actions) { + ids.add(a.getId()); + titles.add(a.getTitle() == null ? "" : a.getTitle()); + icons.add(a.getIcon() == null ? "" : a.getIcon()); + placeholders.add(a.getTextInputPlaceholder() == null ? "" : a.getTextInputPlaceholder()); + buttons.add(a.getTextInputButtonText() == null ? "" : a.getTextInputButtonText()); + } + b.putStringArrayList("NOTIF_ACTION_IDS", ids); + b.putStringArrayList("NOTIF_ACTION_TITLES", titles); + b.putStringArrayList("NOTIF_ACTION_ICONS", icons); + b.putStringArrayList("NOTIF_ACTION_PLACEHOLDERS", placeholders); + b.putStringArrayList("NOTIF_ACTION_BUTTONS", buttons); + } + LocalNotification.MessagingStyle ms = notif.getMessagingStyle(); + if (ms != null) { + b.putString("NOTIF_MSG_SELF", ms.getSelfDisplayName()); + b.putString("NOTIF_MSG_TITLE", ms.getConversationTitle()); + b.putBoolean("NOTIF_MSG_GROUP", ms.isGroupConversation()); + ArrayList texts = new ArrayList(); + ArrayList senders = new ArrayList(); + long[] times = new long[ms.getMessages().size()]; + int i = 0; + for (LocalNotification.MessagingStyle.Message m : ms.getMessages()) { + texts.add(m.getText() == null ? "" : m.getText()); + senders.add(m.getSenderName() == null ? "" : m.getSenderName()); + times[i++] = m.getTimestamp(); + } + b.putStringArrayList("NOTIF_MSG_TEXTS", texts); + b.putStringArrayList("NOTIF_MSG_SENDERS", senders); + b.putLongArray("NOTIF_MSG_TIMES", times); + } + return b; + } + + static LocalNotification createNotificationFromBundle(Bundle b){ + LocalNotification n = new LocalNotification(); + n.setId(b.getString("NOTIF_ID")); + n.setAlertTitle(b.getString("NOTIF_TITLE")); + n.setAlertBody(b.getString("NOTIF_BODY")); + n.setAlertSound(b.getString("NOTIF_SOUND")); + n.setAlertImage(b.getString("NOTIF_IMAGE")); + n.setBadgeNumber(b.getInt("NOTIF_NUMBER")); + // new fields are guarded so bundles serialized by older builds still parse + if (b.containsKey("NOTIF_CHANNEL")) { + n.setChannelId(b.getString("NOTIF_CHANNEL")); + } + if (b.containsKey("NOTIF_GROUP")) { + n.setGroup(b.getString("NOTIF_GROUP")); + } + n.setGroupSummary(b.getBoolean("NOTIF_GROUP_SUMMARY", false)); + n.setFullScreenIntent(b.getBoolean("NOTIF_FULLSCREEN", false)); + n.setTimeSensitive(b.getBoolean("NOTIF_TIME_SENSITIVE", false)); + n.setOngoing(b.getBoolean("NOTIF_ONGOING", false)); + int progressMax = b.getInt("NOTIF_PROGRESS_MAX", 0); + if (progressMax > 0) { + n.setProgress(progressMax, b.getInt("NOTIF_PROGRESS", 0)); + } + n.setIndeterminateProgress(b.getBoolean("NOTIF_PROGRESS_INDETERMINATE", false)); + if (b.containsKey("NOTIF_CUSTOM_VIEW")) { + n.setCustomView(b.getString("NOTIF_CUSTOM_VIEW")); + } + ArrayList ids = b.getStringArrayList("NOTIF_ACTION_IDS"); + if (ids != null) { + ArrayList titles = b.getStringArrayList("NOTIF_ACTION_TITLES"); + ArrayList icons = b.getStringArrayList("NOTIF_ACTION_ICONS"); + ArrayList placeholders = b.getStringArrayList("NOTIF_ACTION_PLACEHOLDERS"); + ArrayList buttons = b.getStringArrayList("NOTIF_ACTION_BUTTONS"); + for (int i = 0; i < ids.size(); i++) { + String placeholder = placeholders != null ? emptyToNull(placeholders.get(i)) : null; + String button = buttons != null ? emptyToNull(buttons.get(i)) : null; + if (placeholder != null || button != null) { + n.addInputAction(ids.get(i), titles.get(i), placeholder, button); + } else { + String icon = icons != null ? emptyToNull(icons.get(i)) : null; + n.addAction(new LocalNotification.Action(ids.get(i), titles.get(i), icon)); + } + } + } + if (b.containsKey("NOTIF_MSG_SELF")) { + LocalNotification.MessagingStyle ms = n.asMessagingStyle(b.getString("NOTIF_MSG_SELF")); + ms.conversationTitle(b.getString("NOTIF_MSG_TITLE")); + ms.groupConversation(b.getBoolean("NOTIF_MSG_GROUP", false)); + ArrayList texts = b.getStringArrayList("NOTIF_MSG_TEXTS"); + ArrayList senders = b.getStringArrayList("NOTIF_MSG_SENDERS"); + long[] times = b.getLongArray("NOTIF_MSG_TIMES"); + if (texts != null) { + for (int i = 0; i < texts.size(); i++) { + ms.addMessage(texts.get(i), + times != null && i < times.length ? times[i] : 0, + senders != null ? emptyToNull(senders.get(i)) : null); + } + } + } + return n; + } + + private static String emptyToNull(String s) { + return s == null || s.length() == 0 ? null : s; + } + + @Override + public void requestNotificationPermission(final NotificationPermissionRequest request, final NotificationPermissionCallback callback) { + if (callback == null) { + return; + } + final boolean granted; + if (android.os.Build.VERSION.SDK_INT >= 33) { + granted = checkForPermission("android.permission.POST_NOTIFICATIONS", "This is required to receive notifications", true); + } else { + // notifications are allowed by default below Android 13 + granted = true; + } + Display.getInstance().callSerially(new Runnable() { + public void run() { + callback.notificationPermissionResult(new NotificationPermissionResult(granted + ? NotificationPermissionResult.AuthorizationLevel.AUTHORIZED + : NotificationPermissionResult.AuthorizationLevel.DENIED)); + } + }); + } + + @Override + public void registerNotificationChannel(NotificationChannelBuilder builder) { + if (builder == null || android.os.Build.VERSION.SDK_INT < 26) { + return; + } + try { + NotificationManager nm = (NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE); + Class clsChannel = Class.forName("android.app.NotificationChannel"); + Constructor ctor = clsChannel.getConstructor(String.class, CharSequence.class, int.class); + // map our 0..5 importance onto the platform IMPORTANCE_* (NONE=0 .. MAX=5) + Object channel = ctor.newInstance(builder.getId(), builder.getName(), builder.getImportance()); + if (builder.getDescription() != null) { + clsChannel.getMethod("setDescription", String.class).invoke(channel, builder.getDescription()); + } + clsChannel.getMethod("enableLights", boolean.class).invoke(channel, builder.isLightsEnabled()); + if (builder.isLightsEnabled()) { + clsChannel.getMethod("setLightColor", int.class).invoke(channel, builder.getLightColor()); + } + clsChannel.getMethod("enableVibration", boolean.class).invoke(channel, builder.isVibrationEnabled()); + if (builder.getVibrationPattern() != null) { + clsChannel.getMethod("setVibrationPattern", long[].class).invoke(channel, (Object) builder.getVibrationPattern()); + } + clsChannel.getMethod("setLockscreenVisibility", int.class).invoke(channel, builder.getLockscreenVisibility()); + clsChannel.getMethod("setShowBadge", boolean.class).invoke(channel, builder.isShowBadge()); + if (builder.getGroup() != null) { + clsChannel.getMethod("setGroup", String.class).invoke(channel, builder.getGroup()); + } + String sound = builder.getSound(); + if (sound != null && sound.length() > 0) { + sound = sound.toLowerCase(); + Uri uri = Uri.parse("android.resource://" + getContext().getApplicationInfo().packageName + "/raw" + + sound.substring(0, sound.indexOf("."))); + android.media.AudioAttributes attrs = new android.media.AudioAttributes.Builder() + .setContentType(android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(android.media.AudioAttributes.USAGE_NOTIFICATION) + .build(); + clsChannel.getMethod("setSound", Uri.class, android.media.AudioAttributes.class).invoke(channel, uri, attrs); + } + nm.getClass().getMethod("createNotificationChannel", clsChannel).invoke(nm, channel); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + @Override + public void deleteNotificationChannel(String channelId) { + if (channelId == null || android.os.Build.VERSION.SDK_INT < 26) { + return; + } + try { + NotificationManager nm = (NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE); + nm.getClass().getMethod("deleteNotificationChannel", String.class).invoke(nm, channelId); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + @Override + public void createNotificationChannelGroup(String groupId, String groupName) { + if (groupId == null || android.os.Build.VERSION.SDK_INT < 26) { + return; + } + try { + NotificationManager nm = (NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE); + Class clsGroup = Class.forName("android.app.NotificationChannelGroup"); + Constructor ctor = clsGroup.getConstructor(String.class, CharSequence.class); + Object group = ctor.newInstance(groupId, groupName); + nm.getClass().getMethod("createNotificationChannelGroup", clsGroup).invoke(nm, group); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + @Override + public void subscribeToPushTopic(final String topic) { + invokeFirebaseTopic("subscribeToTopic", topic); + } + + @Override + public void unsubscribeFromPushTopic(final String topic) { + invokeFirebaseTopic("unsubscribeFromTopic", 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); + } + } + + @Override + public boolean isReceiveSharedContentSupported() { + return true; + } + + private static SharedContent pendingSharedContent; + + /// Delivers shared content received from another app. If the CN1 app instance is + /// running it is dispatched immediately on the EDT; otherwise it is held until the app + /// finishes starting and `#deliverPendingSharedContent()` is invoked. + static void deliverSharedContent(SharedContent content) { + if (content == null) { + return; + } + Object app = CodenameOneImplementation.getCurrentApplicationInstance(); + if (app != null && Display.isInitialized()) { + dispatchSharedContent(app, content); + } else { + pendingSharedContent = content; + } + } + + /// Invoked once the app has started to flush any shared content that arrived before the + /// app instance existed. + public static void deliverPendingSharedContent() { + SharedContent c = pendingSharedContent; + pendingSharedContent = null; + Object app = CodenameOneImplementation.getCurrentApplicationInstance(); + if (c != null && app != null) { + dispatchSharedContent(app, c); + } + } + + private static void dispatchSharedContent(final Object app, final SharedContent content) { + if (!(app instanceof com.codename1.system.Lifecycle)) { + return; + } + Display.getInstance().callSerially(new Runnable() { + public void run() { + ((com.codename1.system.Lifecycle) app).onReceivedSharedContent(content); + } + }); + } + + // ---- Constraint-aware background work (JobScheduler) ---- + + @Override + public boolean isBackgroundWorkSupported() { + return android.os.Build.VERSION.SDK_INT >= 21; + } + + private static int jobIdFor(String id) { + return (id.hashCode() & 0x7fffffff) % 1000000 + 1000; + } + + @Override + public void scheduleBackgroundWork(WorkRequest request) { + if (android.os.Build.VERSION.SDK_INT < 21) { + return; + } + try { + android.app.job.JobScheduler scheduler = + (android.app.job.JobScheduler) getContext().getSystemService(Context.JOB_SCHEDULER_SERVICE); + android.content.ComponentName component = + new android.content.ComponentName(getContext(), CodenameOneJobService.class); + android.app.job.JobInfo.Builder builder = + new android.app.job.JobInfo.Builder(jobIdFor(request.getId()), component); + + if (request.isRequiresUnmeteredNetwork()) { + builder.setRequiredNetworkType(android.app.job.JobInfo.NETWORK_TYPE_UNMETERED); + } else if (request.isRequiresNetwork()) { + builder.setRequiredNetworkType(android.app.job.JobInfo.NETWORK_TYPE_ANY); + } + builder.setRequiresCharging(request.isRequiresCharging()); + if (android.os.Build.VERSION.SDK_INT >= 23) { + builder.setRequiresDeviceIdle(request.isRequiresIdle()); + } + if (android.os.Build.VERSION.SDK_INT >= 26) { + builder.setRequiresBatteryNotLow(request.isRequiresBatteryNotLow()); + } + if (request.isPeriodic()) { + builder.setPeriodic(Math.max(15 * 60 * 1000L, request.getMinIntervalMillis())); + } else { + if (request.getInitialDelayMillis() > 0) { + builder.setMinimumLatency(request.getInitialDelayMillis()); + } + builder.setOverrideDeadline(Math.max(request.getInitialDelayMillis(), 0) + 60 * 60 * 1000L); + } + + PersistableBundle extras = new PersistableBundle(); + extras.putString(CodenameOneJobService.EXTRA_WORKER_CLASS, request.getWorkerClass()); + extras.putString(CodenameOneJobService.EXTRA_WORK_ID, request.getId()); + for (java.util.Map.Entry e : request.getInputData().entrySet()) { + extras.putString(CodenameOneJobService.INPUT_PREFIX + e.getKey(), e.getValue()); + } + builder.setExtras(extras); + scheduler.schedule(builder.build()); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + @Override + public void cancelBackgroundWork(String workId) { + if (android.os.Build.VERSION.SDK_INT < 21) { + return; + } + try { + android.app.job.JobScheduler scheduler = + (android.app.job.JobScheduler) getContext().getSystemService(Context.JOB_SCHEDULER_SERVICE); + scheduler.cancel(jobIdFor(workId)); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + @Override + public boolean isBackgroundProcessingSupported() { + return android.os.Build.VERSION.SDK_INT >= 21; + } + + @Override + public void scheduleBackgroundProcessing(String id, long earliestBeginEpochMs, boolean requiresNetwork, boolean requiresPower, Runnable task) { + if (android.os.Build.VERSION.SDK_INT < 21 || task == null) { + return; + } + try { + CodenameOneJobService.registerProcessingRunnable(id, task); + android.app.job.JobScheduler scheduler = + (android.app.job.JobScheduler) getContext().getSystemService(Context.JOB_SCHEDULER_SERVICE); + android.content.ComponentName component = + new android.content.ComponentName(getContext(), CodenameOneJobService.class); + android.app.job.JobInfo.Builder builder = + new android.app.job.JobInfo.Builder(jobIdFor("proc-" + id), component); + if (requiresNetwork) { + builder.setRequiredNetworkType(android.app.job.JobInfo.NETWORK_TYPE_ANY); + } + builder.setRequiresCharging(requiresPower); + long delay = earliestBeginEpochMs <= 0 ? 0 : Math.max(0, earliestBeginEpochMs - System.currentTimeMillis()); + if (delay > 0) { + builder.setMinimumLatency(delay); + } + builder.setOverrideDeadline(delay + 60 * 60 * 1000L); + PersistableBundle extras = new PersistableBundle(); + extras.putString(CodenameOneJobService.EXTRA_PROCESSING_ID, id); + builder.setExtras(extras); + scheduler.schedule(builder.build()); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + @Override + public void cancelBackgroundProcessing(String id) { + CodenameOneJobService.unregisterProcessingRunnable(id); + if (android.os.Build.VERSION.SDK_INT < 21) { + return; + } + try { + android.app.job.JobScheduler scheduler = + (android.app.job.JobScheduler) getContext().getSystemService(Context.JOB_SCHEDULER_SERVICE); + scheduler.cancel(jobIdFor("proc-" + id)); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + // ---- Foreground service ---- + + @Override + public boolean isForegroundServiceSupported() { + return true; + } + + @Override + public Object startForegroundService(String channelId, String title, String body, String iconName, ForegroundService.Task task, ForegroundService handle) { + int token = CodenameOneForegroundService.registerTask(task, handle, channelId, title, body, iconName); + try { + Intent intent = new Intent(getContext(), CodenameOneForegroundService.class); + intent.setAction(CodenameOneForegroundService.ACTION_START); + intent.putExtra(CodenameOneForegroundService.EXTRA_TOKEN, token); + intent.putExtra(CodenameOneForegroundService.EXTRA_CHANNEL, channelId); + intent.putExtra(CodenameOneForegroundService.EXTRA_TITLE, title); + intent.putExtra(CodenameOneForegroundService.EXTRA_BODY, body); + intent.putExtra(CodenameOneForegroundService.EXTRA_ICON, iconName); + if (android.os.Build.VERSION.SDK_INT >= 26) { + getContext().startForegroundService(intent); + } else { + getContext().startService(intent); + } + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + return Integer.valueOf(token); + } + + @Override + public void updateForegroundServiceNotification(Object nativeHandle, String title, String body) { + try { + Intent intent = new Intent(getContext(), CodenameOneForegroundService.class); + intent.setAction(CodenameOneForegroundService.ACTION_UPDATE); + if (nativeHandle instanceof Integer) { + intent.putExtra(CodenameOneForegroundService.EXTRA_TOKEN, ((Integer) nativeHandle).intValue()); + } + intent.putExtra(CodenameOneForegroundService.EXTRA_TITLE, title); + intent.putExtra(CodenameOneForegroundService.EXTRA_BODY, body); + getContext().startService(intent); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + @Override + public void stopForegroundService(Object nativeHandle) { + try { + Intent intent = new Intent(getContext(), CodenameOneForegroundService.class); + intent.setAction(CodenameOneForegroundService.ACTION_STOP); + if (nativeHandle instanceof Integer) { + intent.putExtra(CodenameOneForegroundService.EXTRA_TOKEN, ((Integer) nativeHandle).intValue()); + } + getContext().startService(intent); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + boolean brokenGaussian; + public Image gaussianBlurImage(Image image, float radius) { + try { + Bitmap outputBitmap = Bitmap.createBitmap((Bitmap)image.getImage()); + + RenderScript rs = RenderScript.create(getContext()); + try { + ScriptIntrinsicBlur theIntrinsic = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs)); + Allocation tmpIn = Allocation.createFromBitmap(rs, (Bitmap)image.getImage()); + Allocation tmpOut = Allocation.createFromBitmap(rs, outputBitmap); + theIntrinsic.setRadius(radius); + theIntrinsic.setInput(tmpIn); + theIntrinsic.forEach(tmpOut); + tmpOut.copyTo(outputBitmap); + tmpIn.destroy(); + tmpOut.destroy(); + theIntrinsic.destroy(); + } finally { + rs.destroy(); + } + + return new NativeImage(outputBitmap); + } catch(Throwable t) { + brokenGaussian = true; + return image; + } + } + + public boolean isGaussianBlurSupported() { + return (!brokenGaussian) && android.os.Build.VERSION.SDK_INT >= 11; + } + + @Override + public boolean blurRegion(Object graphics, int x, int y, int width, int height, float radius) { + if (radius <= 0f || width <= 0 || height <= 0 || !isGaussianBlurSupported()) { + return radius <= 0f || width <= 0 || height <= 0; + } + // In-place CSS backdrop-filter:blur on a mutable-image target. Read/write the + // backing Bitmap directly at absolute coordinates (bypassing the canvas + // transform), Gaussian-blur the region via RenderScript. The live screen + // canvas has no backing Bitmap here -> returns false (component paints + // without the blur). + if (!(graphics instanceof AndroidGraphics)) { + return false; + } + Bitmap dest = ((AndroidGraphics) graphics).underlyingBitmap; + if (dest == null || !dest.isMutable()) { + return false; + } + try { + int rx = Math.max(0, x), ry = Math.max(0, y); + int rw = Math.min(width, dest.getWidth() - rx); + int rh = Math.min(height, dest.getHeight() - ry); + if (rw <= 0 || rh <= 0) { + return true; + } + int[] pix = new int[rw * rh]; + dest.getPixels(pix, 0, rw, rx, ry, rw, rh); + Bitmap region = Bitmap.createBitmap(pix, rw, rh, Bitmap.Config.ARGB_8888); + Bitmap blurred = Bitmap.createBitmap(region); + RenderScript rs = RenderScript.create(getContext()); + try { + ScriptIntrinsicBlur theIntrinsic = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs)); + Allocation tmpIn = Allocation.createFromBitmap(rs, region); + Allocation tmpOut = Allocation.createFromBitmap(rs, blurred); + // RenderScript blur radius is capped at 25. + theIntrinsic.setRadius(Math.min(25f, radius)); + theIntrinsic.setInput(tmpIn); + theIntrinsic.forEach(tmpOut); + tmpOut.copyTo(blurred); + tmpIn.destroy(); + tmpOut.destroy(); + theIntrinsic.destroy(); + } finally { + rs.destroy(); + } + blurred.getPixels(pix, 0, rw, 0, 0, rw, rh); + dest.setPixels(pix, 0, rw, rx, ry, rw, rh); + return true; + } catch (Throwable t) { + brokenGaussian = true; + return false; + } + } + + public static boolean checkForPermission(String permission, String description){ + return checkForPermission(permission, description, false); + } + + public static void setPermissionPromptCallback(PermissionPromptCallback callback) { + permissionPromptCallback = callback; + } + + public static PermissionPromptCallback getPermissionPromptCallback() { + return permissionPromptCallback; + } + + private static String getPermissionText(String key, String defaultValue) { + return UIManager.getInstance().localize(key, Display.getInstance().getProperty(key, defaultValue)); + } + + private static boolean showPermissionPrompt(String permission, String title, String body, String positiveButtonText, String negativeButtonText) { + if (permissionPromptCallback != null) { + return permissionPromptCallback.showPermissionPrompt(permission, title, body, positiveButtonText, negativeButtonText); + } + return Dialog.show(title, body, positiveButtonText, negativeButtonText); + } + + private static void showPermissionMessage(String permission, String title, String body, String okButtonText) { + if (permissionPromptCallback != null) { + permissionPromptCallback.showPermissionMessage(permission, title, body, okButtonText); + return; + } + Dialog.show(title, body, okButtonText, null); + } + + /** + * Return a list of all of the permissions that have been requested by the app (granted or no). + * This can be used to see which permissions are included in the manifest file. + * @return + */ + public static List getRequestedPermissions() { + PackageManager pm = getContext().getPackageManager(); + try + { + PackageInfo packageInfo = pm.getPackageInfo(getContext().getPackageName(), PackageManager.GET_PERMISSIONS); + String[] requestedPermissions = null; + if (packageInfo != null) { + requestedPermissions = packageInfo.requestedPermissions; + return Arrays.asList(requestedPermissions); + } + return new ArrayList(); + } + catch (PackageManager.NameNotFoundException e) + { + com.codename1.io.Log.e(e); + return new ArrayList(); + } + } + + public static boolean checkForPermission(String permission, String description, boolean forceAsk){ + //before sdk 23 no need to ask for permission + if(android.os.Build.VERSION.SDK_INT < 23){ + return true; + } + + if (android.os.Build.VERSION.SDK_INT >= 30 && "android.permission.ACCESS_BACKGROUND_LOCATION".equals(permission)) { + if (android.support.v4.content.ContextCompat.checkSelfPermission(getContext(), permission) == PackageManager.PERMISSION_GRANTED) { + return true; + } + if (getActivity() == null) { + return false; + } + + String prompt = getPermissionText(permission, description); + String title = getPermissionText("android.permission.ACCESS_BACKGROUND_LOCATION.title", "Requires permission"); + String settingsBtn = getPermissionText("android.permission.ACCESS_BACKGROUND_LOCATION.settings", "Settings"); + String cancelBtn = getPermissionText("android.permission.ACCESS_BACKGROUND_LOCATION.cancel", "Cancel"); + + if(showPermissionPrompt(permission, title, prompt, settingsBtn, cancelBtn)){ + Intent intent = new Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS); + Uri uri = Uri.fromParts("package", getContext().getPackageName(), null); + intent.setData(uri); + getActivity().startActivity(intent); + + String explanationTitle = getPermissionText("android.permission.ACCESS_BACKGROUND_LOCATION.explanation_title", "Permission Required"); + String explanationBody = getPermissionText("android.permission.ACCESS_BACKGROUND_LOCATION.explanation_body", "Please enable 'Allow all the time' in the settings, then press OK."); + String okBtn = getPermissionText("android.permission.ACCESS_BACKGROUND_LOCATION.ok", "OK"); + + showPermissionMessage(permission, explanationTitle, explanationBody, okBtn); + return android.support.v4.content.ContextCompat.checkSelfPermission(getActivity(), permission) == PackageManager.PERMISSION_GRANTED; + } else { + return false; + } + } + + String prompt = getPermissionText(permission, description); + + if (android.support.v4.content.ContextCompat.checkSelfPermission(getContext(), + permission) + != PackageManager.PERMISSION_GRANTED) { + + if (getActivity() == null) { + return false; + } + + // Should we show an explanation? + if (!forceAsk && android.support.v4.app.ActivityCompat.shouldShowRequestPermissionRationale(getActivity(), + permission)) { + + // Show an expanation to the user *asynchronously* -- don't block + String title = getPermissionText(permission + ".title", "Requires permission"); + String askAgain = getPermissionText(permission + ".askAgain", "Ask again"); + String dontAsk = getPermissionText(permission + ".dontAsk", "Don't Ask"); + if(showPermissionPrompt(permission, title, prompt, askAgain, dontAsk)){ + return checkForPermission(permission, description, true); + }else { + return false; + } + } else { + + // No explanation needed, we can request the permission. + ((CodenameOneActivity)getActivity()).setRequestForPermission(true); + ((CodenameOneActivity)getActivity()).setWaitingForPermissionResult(true); + android.support.v4.app.ActivityCompat.requestPermissions(getActivity(), + new String[]{permission}, + 1); + //wait for a response + Display.getInstance().invokeAndBlock(new Runnable() { + @Override + public void run() { + while(((CodenameOneActivity)getActivity()).isRequestForPermission()) { + try { + Thread.sleep(50); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + } + }); + //check again if the permission is given after the dialog was displayed + return android.support.v4.content.ContextCompat.checkSelfPermission(getActivity(), + permission) == PackageManager.PERMISSION_GRANTED; + + } + } + return true; + } + + public boolean isJailbrokenDevice() { + try { + Runtime.getRuntime().exec("su"); + return true; + } catch(Throwable t) { + com.codename1.io.Log.e(t); + } + return false; + } + + @Override + public boolean isAttestationSupported() { + try { + Class.forName("com.google.android.play.core.integrity.IntegrityManagerFactory"); + return true; + } catch(Throwable t) { + return false; + } + } + + @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); + } + return result; + } + + @Override + public boolean isDeviceCompromised() { + return getCompromiseReasons().length > 0; + } + + /** + * Base64 SHA-256 digests of the certificates this APK is actually signed with. + * + *

Uses the v2/v3 signing-block API on API 28 and up, which reports the full + * signing lineage after a key rotation; below that only the legacy v1 signature + * is available. Note that under Play App Signing the digest seen here is + * Google's app signing key, not the developer's upload key -- comparing + * against the upload key is the classic way to make every production install + * report itself as repackaged.

+ */ + @Override + public String[] getAppSignerDigests() { + try { + Context ctx = getContext(); + if (ctx == null) { + return new String[0]; + } + PackageManager pm = ctx.getPackageManager(); + String pkg = ctx.getPackageName(); + Signature[] signatures = null; + if (android.os.Build.VERSION.SDK_INT >= 28) { + // Reflection because the port compiles against an older android.jar + // than the devices it runs on, the same reason the Play Integrity + // call in this file is reflective. + signatures = signingCertificatesViaReflection(pm, pkg); + } + if (signatures == null) { + PackageInfo info = pm.getPackageInfo(pkg, PackageManager.GET_SIGNATURES); + signatures = info.signatures; + } + if (signatures == null) { + return new String[0]; + } + java.util.ArrayList out = new java.util.ArrayList(); + for (int i = 0; i < signatures.length; i++) { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update(signatures[i].toByteArray()); + out.add(com.codename1.util.Base64.encodeNoNewline(md.digest())); + } + return out.toArray(new String[out.size()]); + } catch (Throwable t) { + // Reporting nothing is better than failing a request over a + // package-manager quirk on some OEM build. + com.codename1.io.Log.e(t); + return new String[0]; + } + } + + /** + * PackageManager.GET_SIGNING_CERTIFICATES. Inlined because the port compiles + * against an android.jar that predates it. + */ + private static final int FLAG_GET_SIGNING_CERTIFICATES = 0x08000000; + + /** + * Reads the v2/v3 signing certificates on API 28+, or null when unavailable so + * the caller falls back to the legacy v1 signatures. + */ + private static Signature[] signingCertificatesViaReflection(PackageManager pm, String pkg) { + try { + PackageInfo info = pm.getPackageInfo(pkg, FLAG_GET_SIGNING_CERTIFICATES); + java.lang.reflect.Field signingInfoField = + PackageInfo.class.getField("signingInfo"); + Object signingInfo = signingInfoField.get(info); + if (signingInfo == null) { + return null; + } + Class signingInfoClass = signingInfo.getClass(); + boolean multipleSigners = ((Boolean) signingInfoClass + .getMethod("hasMultipleSigners").invoke(signingInfo)).booleanValue(); + // With one signer the history includes the pre-rotation certificates, + // which a server comparing against an older build still needs to accept. + String method = multipleSigners + ? "getApkContentsSigners" + : "getSigningCertificateHistory"; + return (Signature[]) signingInfoClass.getMethod(method).invoke(signingInfo); + } catch (Throwable t) { + return null; + } + } + + @Override + public String[] getCompromiseReasons() { + java.util.ArrayList reasons = new java.util.ArrayList(); + if(isRootedViaRootBeer() || isJailbrokenDevice()) { + reasons.add("root"); + } + try { + if(FridaDetectionUtil.isFridaDetected()) { + reasons.add("frida"); + } + } catch(Throwable t) { + // detection must never crash the host app + } + if(isProbablyEmulator()) { + reasons.add("emulator"); + } + return reasons.toArray(new String[reasons.size()]); + } + + private boolean isRootedViaRootBeer() { + try { + Class rootBeerClass = Class.forName("com.scottyab.rootbeer.RootBeer"); + Object rootBeer = rootBeerClass.getConstructor(Context.class).newInstance(getContext()); + Object rooted = rootBeerClass.getMethod("isRooted").invoke(rootBeer); + return Boolean.TRUE.equals(rooted); + } catch(Throwable t) { + // RootBeer not bundled (android.rootCheck off) - caller falls back to the su probe + return false; + } + } + + private boolean isProbablyEmulator() { + try { + String fingerprint = Build.FINGERPRINT; + if(fingerprint != null && (fingerprint.startsWith("generic") || fingerprint.startsWith("unknown") + || fingerprint.contains("emulator"))) { + return true; + } + String model = Build.MODEL; + if(model != null && (model.contains("google_sdk") || model.contains("Emulator") + || model.contains("Android SDK built for"))) { + return true; + } + String manufacturer = Build.MANUFACTURER; + if(manufacturer != null && manufacturer.contains("Genymotion")) { + return true; + } + String product = Build.PRODUCT; + if(product != null && (product.contains("sdk_gphone") || product.equals("google_sdk") + || product.contains("emulator") || product.contains("simulator"))) { + return true; + } + String hardware = Build.HARDWARE; + if(hardware != null && (hardware.contains("goldfish") || hardware.contains("ranchu"))) { + return true; + } + } catch(Throwable t) { + // ignore + } + return false; + } + + @Override + public String[] getEnabledAccessibilityServices() { + Context context = getContext(); + if(context == null) { + return new String[0]; + } + try { + AccessibilityManager am = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); + if(am != null) { + java.util.List list = + am.getEnabledAccessibilityServiceList( + android.accessibilityservice.AccessibilityServiceInfo.FEEDBACK_ALL_MASK); + if(list != null && !list.isEmpty()) { + java.util.ArrayList ids = new java.util.ArrayList(); + for(android.accessibilityservice.AccessibilityServiceInfo info : list) { + String id = info.getId(); + if(id != null && id.length() > 0) { + ids.add(id); + } + } + return ids.toArray(new String[ids.size()]); + } + } + } catch(Throwable t) { + // fall through to the Settings.Secure based lookup below + } + try { + String enabled = Settings.Secure.getString(context.getContentResolver(), + Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES); + if(enabled != null && enabled.length() > 0) { + return enabled.split(":"); + } + } catch(Throwable t) { + com.codename1.io.Log.e(t); + } + return new String[0]; + } + + @Override + public void setSecureScreen(final boolean secure) { + final Activity act = getActivity(); + if(act == null) { + return; + } + act.runOnUiThread(new Runnable() { + public void run() { + try { + if(secure) { + act.getWindow().addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE); + } else { + act.getWindow().clearFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE); + } + } catch(Throwable t) { + com.codename1.io.Log.e(t); + } + } + }); + } + + @Override + public boolean isHideOverlayWindowsSupported() { + // The permission half matters as much as the API level. Window.setHideOverlayWindows + // throws SecurityException without HIDE_OVERLAY_WINDOWS; reflection wraps it and the + // catch below only logs it, so reporting support on the API level alone would tell an + // app its native peers were protected when in fact nothing happened. It is a normal + // permission, granted at install once the manifest declares it, which the + // android.tapjackingGuard / android.hideOverlayWindows build hints arrange. + return Build.VERSION.SDK_INT >= 31 && hasHideOverlayWindowsPermission(); + } + + /** The last value passed to setHideOverlayWindows, replayed onto a recreated window. */ + private boolean hideOverlayWindowsRequested; + + private boolean hasHideOverlayWindowsPermission() { + try { + Context ctx = getContext(); + if (ctx == null) { + return false; + } + return ctx.checkSelfPermission("android.permission.HIDE_OVERLAY_WINDOWS") + == android.content.pm.PackageManager.PERMISSION_GRANTED; + } catch (Throwable t) { + return false; + } + } + + @Override + public void setHideOverlayWindows(final boolean hide) { + // Recorded before the guards below because it is a request, not a result: the flag + // lives on the Window, and a configuration change destroys and recreates the activity + // without touching this implementation instance. initSurface() replays it onto the new + // window, otherwise an app that hid overlays on a sensitive screen would come back from + // a rotation with them allowed again and no way to notice. + hideOverlayWindowsRequested = hide; + if (Build.VERSION.SDK_INT < 31) { + return; + } + if (!hasHideOverlayWindowsPermission()) { + // Said out loud rather than left to the swallowed SecurityException below: an app + // that calls this without the build hint would otherwise see no effect and no + // explanation for why its overlays were never hidden. + com.codename1.io.Log.p("Codename One: setHideOverlayWindows ignored, the app does " + + "not hold android.permission.HIDE_OVERLAY_WINDOWS. Enable the " + + "android.tapjackingGuard or android.hideOverlayWindows build hint."); + return; + } + final Activity act = getActivity(); + if (act == null) { + return; + } + act.runOnUiThread(new Runnable() { + public void run() { + try { + // Window.setHideOverlayWindows(boolean) is API 31 and absent from the + // android.jar this port compiles against, so it is reached reflectively -- + // the same approach the port uses for the Play Integrity API. + android.view.Window w = act.getWindow(); + if (w == null) { + return; + } + java.lang.reflect.Method m = android.view.Window.class.getMethod( + "setHideOverlayWindows", boolean.class); + m.invoke(w, Boolean.valueOf(hide)); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + }); + } + + @Override + public void announceForAccessibility(final Component cmp, final String text) { + final Activity act = getActivity(); + if (act == null) { + return; + } + act.runOnUiThread(new Runnable() { + @Override + public void run() { + View view = null; + if (cmp instanceof PeerComponent) { + Object peer = ((PeerComponent) cmp).getNativePeer(); + if (peer instanceof View) { + view = (View) peer; + } + } + if (view == null) { + view = act.getWindow().getDecorView(); + } + if (view == null) { + return; + } + if (Build.VERSION.SDK_INT >= 16) { + view.announceForAccessibility(text); + } else { + AccessibilityManager manager = (AccessibilityManager) act.getSystemService(Context.ACCESSIBILITY_SERVICE); + if (manager != null && manager.isEnabled()) { + AccessibilityEvent event = AccessibilityEvent.obtain(AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED); + event.getText().add(text); + event.setSource(view); + manager.sendAccessibilityEvent(event); + } + } + } + }); + } + + @Override + public boolean isHighContrastEnabled() { + try { + AccessibilityManager manager = (AccessibilityManager)getContext() + .getSystemService(Context.ACCESSIBILITY_SERVICE); + if (android.os.Build.VERSION.SDK_INT >= 21 && manager != null) { + Object enabled = AccessibilityManager.class.getMethod("isHighTextContrastEnabled") + .invoke(manager); + return enabled instanceof Boolean && ((Boolean)enabled).booleanValue(); + } + } catch (Throwable t) { + // Fall through to the secure settings used by older Android stubs. + } + return secureSettingEnabled("high_text_contrast_enabled") + || secureSettingEnabled("accessibility_display_high_text_contrast_enabled"); + } + + @Override + public boolean isDifferentiateWithoutColorEnabled() { + return secureSettingEnabled("accessibility_display_daltonizer_enabled"); + } + + @Override + public AccessibilityColorVisionDeficiency getColorVisionDeficiency() { + if (!secureSettingEnabled("accessibility_display_daltonizer_enabled")) { + return AccessibilityColorVisionDeficiency.NONE; + } + try { + int mode = Settings.Secure.getInt(getContext().getContentResolver(), + "accessibility_display_daltonizer"); + switch (mode) { + case 0: return AccessibilityColorVisionDeficiency.MONOCHROMACY; + case 11: return AccessibilityColorVisionDeficiency.PROTANOPIA; + case 12: return AccessibilityColorVisionDeficiency.DEUTERANOPIA; + case 13: return AccessibilityColorVisionDeficiency.TRITANOPIA; + default: return AccessibilityColorVisionDeficiency.UNKNOWN; + } + } catch (Throwable t) { + return AccessibilityColorVisionDeficiency.UNKNOWN; + } + } + + @Override + public boolean isReduceMotionEnabled() { + try { + return Settings.Global.getFloat(getContext().getContentResolver(), + Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f; + } catch (Throwable t) { + return false; + } + } + + @Override + public boolean isBoldTextEnabled() { + try { + Object value = Configuration.class.getField("fontWeightAdjustment") + .get(getContext().getResources().getConfiguration()); + return value instanceof Integer && ((Integer)value).intValue() >= 300; + } catch (Throwable t) { + return false; + } + } + + @Override + public boolean isInvertColorsEnabled() { + return secureSettingEnabled("accessibility_display_inversion_enabled"); + } + + @Override + public boolean isGrayscaleEnabled() { + return getColorVisionDeficiency() == AccessibilityColorVisionDeficiency.MONOCHROMACY; + } + + @Override + public boolean isScreenReaderEnabled() { + try { + AccessibilityManager manager = (AccessibilityManager)getContext() + .getSystemService(Context.ACCESSIBILITY_SERVICE); + return manager != null && manager.isEnabled() && manager.isTouchExplorationEnabled(); + } catch (Throwable t) { + return false; + } + } + + private boolean secureSettingEnabled(String key) { + try { + return Settings.Secure.getInt(getContext().getContentResolver(), key, 0) == 1; + } catch (Throwable t) { + return false; + } + } + + @Override + public void accessibilityTreeChanged(final int changeType) { + final Activity act = getActivity(); + if (act == null || accessibilityProvider == null) return; + act.runOnUiThread(new Runnable() { + public void run() { + if (accessibilityProvider != null) accessibilityProvider.invalidate(changeType); + } + }); + } + + @Override + public boolean isAccessibilityTreeSupported() { + return Build.VERSION.SDK_INT >= 16; + } + + @Override + public boolean isAccessibilityTreeUpdateRequired() { + return accessibilityTreeUpdateRequired; + } + + void setAccessibilityTreeUpdateRequired(boolean required) { + accessibilityTreeUpdateRequired = required; + } + + // ================================================================ + // Crypto bridge -- routes com.codename1.security onto the standard + // Android JCE provider. + + private static java.security.SecureRandom androidSecureRandom; + private static final Object androidSecureRandomSync = new Object(); + + private static java.security.SecureRandom androidSecureRandom() { + synchronized (androidSecureRandomSync) { + if (androidSecureRandom == null) { + androidSecureRandom = new java.security.SecureRandom(); + } + return androidSecureRandom; + } + } + + @Override + public void secureRandomBytes(byte[] out) { + if (out == null) return; + androidSecureRandom().nextBytes(out); + } + + @Override + public byte[] aesEncrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] plaintext) { + return androidAes(transformation, key, iv, aad, plaintext, javax.crypto.Cipher.ENCRYPT_MODE); + } + + @Override + public byte[] aesDecrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] ciphertext) { + return androidAes(transformation, key, iv, aad, ciphertext, javax.crypto.Cipher.DECRYPT_MODE); + } + + private static byte[] androidAes(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] input, int mode) { + try { + javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation); + javax.crypto.spec.SecretKeySpec keySpec = new javax.crypto.spec.SecretKeySpec(key, "AES"); + String tu = transformation == null ? "" : transformation.toUpperCase(); + if (tu.indexOf("GCM") >= 0) { + cipher.init(mode, keySpec, new javax.crypto.spec.GCMParameterSpec(128, iv)); + } else if (iv != null) { + cipher.init(mode, keySpec, new javax.crypto.spec.IvParameterSpec(iv)); + } else { + cipher.init(mode, keySpec); + } + if (aad != null && aad.length > 0) { + cipher.updateAAD(aad); + } + return cipher.doFinal(input); + } catch (java.security.GeneralSecurityException e) { + throw new RuntimeException("AES " + (mode == javax.crypto.Cipher.ENCRYPT_MODE ? "encrypt" : "decrypt") + " failed: " + e.getMessage()); + } + } + + /// The RSA transformations this port implements, matched exactly. + /// + /// A substring test for "OAEP" would answer every OAEP name -- including + /// RSA/ECB/OAEPWithSHA-1AndMGF1Padding -- with the SHA-256 parameters below, + /// producing ciphertext no standards-compliant peer could read under the name + /// it asked for. The native ports already accept only these two, so refusing + /// anything else here keeps every port answering the same question. + private static boolean cn1IsOaepTransformation(String transformation) { + return com.codename1.security.Cipher.RSA_OAEP_SHA256.equals(transformation); + } + + private static void cn1CheckRsaTransformation(String transformation) { + if (!cn1IsOaepTransformation(transformation) + && !com.codename1.security.Cipher.RSA_PKCS1.equals(transformation)) { + throw new RuntimeException("unsupported cipher transformation: " + transformation); + } + } + + /// The OAEP parameters every port agrees on. + /// + /// The JCE transformation name "OAEPWithSHA-256AndMGF1Padding" leaves MGF1 on + /// SHA-1 by default, which no other backend here can reproduce: Web Crypto's + /// RSA-OAEP uses one hash for the label and the mask, and so does Apple's + /// SecKey. Naming SHA-256 for both is the only pairing all six ports can + /// produce, so it is what the portable constant means -- stated explicitly + /// rather than inherited from a provider default. + private static javax.crypto.spec.OAEPParameterSpec cn1OaepParameters() { + return new javax.crypto.spec.OAEPParameterSpec("SHA-256", "MGF1", + java.security.spec.MGF1ParameterSpec.SHA256, + javax.crypto.spec.PSource.PSpecified.DEFAULT); + } + + @Override + public byte[] rsaEncrypt(String transformation, byte[] publicKeyX509, byte[] plaintext) { + try { + javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation); + java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); + java.security.PublicKey key = kf.generatePublic(new java.security.spec.X509EncodedKeySpec(publicKeyX509)); + cn1CheckRsaTransformation(transformation); + if (cn1IsOaepTransformation(transformation)) { + cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key, cn1OaepParameters()); + } else { + cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key); + } + return cipher.doFinal(plaintext); + } catch (java.security.GeneralSecurityException e) { + throw new RuntimeException("RSA encrypt failed: " + e.getMessage()); + } + } + + @Override + public byte[] rsaDecrypt(String transformation, byte[] privateKeyPkcs8, byte[] ciphertext) { + try { + javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation); + java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); + java.security.PrivateKey key = kf.generatePrivate(new java.security.spec.PKCS8EncodedKeySpec(privateKeyPkcs8)); + cn1CheckRsaTransformation(transformation); + if (cn1IsOaepTransformation(transformation)) { + cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key, cn1OaepParameters()); + } else { + cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key); + } + return cipher.doFinal(ciphertext); + } catch (java.security.GeneralSecurityException e) { + throw new RuntimeException("RSA decrypt failed: " + e.getMessage()); + } + } + + @Override + public byte[] cryptoSign(String algorithm, String keyAlgorithm, byte[] privateKeyPkcs8, byte[] data) { + try { + java.security.KeyFactory kf = java.security.KeyFactory.getInstance(keyAlgorithm); + java.security.PrivateKey priv = kf.generatePrivate(new java.security.spec.PKCS8EncodedKeySpec(privateKeyPkcs8)); + java.security.Signature sig = java.security.Signature.getInstance(algorithm); + sig.initSign(priv); + sig.update(data); + return sig.sign(); + } catch (java.security.GeneralSecurityException e) { + throw new RuntimeException("sign failed: " + e.getMessage()); + } + } + + @Override + public boolean cryptoVerify(String algorithm, String keyAlgorithm, byte[] publicKeyX509, byte[] data, byte[] signature) { + try { + java.security.KeyFactory kf = java.security.KeyFactory.getInstance(keyAlgorithm); + java.security.PublicKey pub = kf.generatePublic(new java.security.spec.X509EncodedKeySpec(publicKeyX509)); + java.security.Signature sig = java.security.Signature.getInstance(algorithm); + sig.initVerify(pub); + sig.update(data); + return sig.verify(signature); + } catch (java.security.GeneralSecurityException e) { + throw new RuntimeException("verify failed: " + e.getMessage()); + } + } + + @Override + public byte[][] generateRsaKeyPair(int bits) { + try { + java.security.KeyPairGenerator kpg = java.security.KeyPairGenerator.getInstance("RSA"); + kpg.initialize(bits); + java.security.KeyPair kp = kpg.generateKeyPair(); + return new byte[][]{ kp.getPublic().getEncoded(), kp.getPrivate().getEncoded() }; + } catch (java.security.GeneralSecurityException e) { + throw new RuntimeException("RSA keypair generation failed: " + e.getMessage()); + } + } +} diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java index caabd2312ff..aa354039c86 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java @@ -26,6 +26,7 @@ import com.codename1.analytics.AnalyticsConsent; import com.codename1.analytics.ConsentMode; import com.codename1.io.ConnectionRequest; +import com.codename1.io.Preferences; import com.codename1.io.Storage; import com.codename1.junit.FormTest; import com.codename1.junit.UITestBase; @@ -640,4 +641,78 @@ public void requestReferrer(InstallReferrerCallback callback) { + "so nothing restarted it and the invite stays unresolved until " + "the retry interval elapses or the app is launched again"); } + + /** + * A privacy reset during startup still reaches the referrer, even though + * nothing invite-related has been called yet. + * + *

resetClientId() is observed by being a registered provider, and the + * provider used to be installed only by an Invites entry point. An + * application that resets identity from its own init() therefore reset it + * with no provider registered: init() never saw the change, the erasure + * never ran, and the Play referrer -- whose one-shot flag was still + * unburnt -- was read by the first checkForInvite() afterwards and + * attributed the pre-reset referral to the identity the user had just + * asked for.

+ * + *

The state this sets up is deliberately not what freshInstall() leaves + * behind: registering a listener there installs the provider AND records a + * baseline, and either one alone would have caught the reset. A genuinely + * first launch has neither.

+ */ + @FormTest + void aPrivacyResetBeforeAnyInviteCallStillForgetsTheReferrer() { + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + + // The launch state: no provider, no baseline. + Analytics.clearProviders(); + Preferences.delete(InviteAttributionProvider.PREF_LAST_CLIENT_ID); + assertEquals("", Preferences.get(InviteAttributionProvider.PREF_LAST_CLIENT_ID, ""), + "the fixture left a baseline behind, which would catch the reset on its own " + + "and make this test pass for the wrong reason"); + + final boolean[] discarded = new boolean[1]; + // Both builders splice this call into the generated stub immediately + // before the application's own init(this), so it is the last thing + // that runs before application code could reset anything. + Invites.registerInstallReferrerSource(new InstallReferrerSource() { + public boolean isSupported() { + return true; + } + + public boolean discardReferrer() { + discarded[0] = true; + return true; + } + + public void requestReferrer(InstallReferrerCallback callback) { + // Play answers the same install for as long as the one-shot + // flag is unburnt, and stops once it has been discarded. That + // is the behaviour that makes an unconsumed referrer outlive a + // reset, so the fake has to have it. + if (discarded[0]) { + callback.onReferrer(null, 0L, 0L); + return; + } + callback.onReferrer("utm_source=cn1_invite&cn1_invite=PRERESET", 0L, 0L); + } + }); + + // The reset, from the application's init(). + Analytics.resetClientId(); + assertTrue(discarded[0], + "the reset did not reach the referrer, so the next check will attribute " + + "the pre-reset invite to the new identity"); + + // And the check that follows finds nothing to attribute. + Invites.checkForInvite(); + for (int i = 0; i < implementation.getQueuedRequests().size(); i++) { + String body = implementation.getQueuedRequests().get(i).getRequestBody(); + assertTrue(body == null || body.indexOf("PRERESET") < 0, + "the pre-reset referral was transmitted under the new client id: " + body); + } + assertNull(Invites.getAttribution(), + "the pre-reset referral resolved into an attribution after the reset"); + } } From 22fcf2d76b57044d7ebc44694ff934d60b14389f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 05:22:14 +0300 Subject: [PATCH 95/99] Clear the owed-erasure marker on a successful reset, and claim a routed link once Two review findings, both real and both revert-probed. An ordinary reset() retry could tombstone the install. A failed reset writes the durable InviteStore.ERASURE marker; a later successful reset cleared the in-memory erasurePending flag and left the marker. resumeOwedErasure() reads the MARKER, not the flag, so the next gated call read it as work still owed, ran eraseInternal() over records that were already gone, and wrote the permanent tombstone. The comment on the flag-clearing line describes exactly this harm -- "turning an application's ordinary reset() into a terminal state it never asked for" -- and only half of it had been fixed. The durable half now goes with the flag, and a delete that fails reports the erasure incomplete rather than leaving the two disagreeing, which is the direction eraseInternal() already takes. One tapped link could produce two claims. deferredStarted means "this process started the DEFERRED path", and a direct claim from handleUrl() never sets it: it writes the pending record, bumps the epoch and claims. The Android onNewIntent splice queues a checkForInvite() behind the same external-url dispatch, and now that handleUrl() consumes the argument that queued check finds nothing to handle and fell through to the deferred path -- where the state is PENDING and the record still holds the code. Both requests carried the epoch handleUrl() had just bumped to, so neither was discarded: the funnel event could be emitted twice and two of the five attempts went on one tap. beginDeferred() now asks lookupInFlight() as well, which is the same question resumeDeferred() and flush() already ask before they clear deferredStarted -- this covers the path where it was never set. Probes: with the guards disabled the two new tests fail on "the successful reset left the owed marker behind" and "the queued check claimed the same invite a second time". Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 45 ++++++++++ .../invite/InviteResilienceTest.java | 87 +++++++++++++++++++ 2 files changed, 132 insertions(+) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 6a0099dd558..99bce4dc1a1 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1403,6 +1403,30 @@ static boolean resetVerified() { // erasure, tombstone included, turning an application's ordinary // reset() into a terminal state it never asked for. erasurePending = false; + // And the DURABLE half, which is the one that actually decides. + // + // Clearing the flag fixed half of the paragraph above and left the + // half that survives the process. resumeOwedErasure() reads + // InviteStore.ERASURE, not the flag, so a marker written by an + // EARLIER failed reset outlived the retry that satisfied it: the + // next gated call read it as work still owed, ran eraseInternal() + // over records that were already gone, and wrote the tombstone -- + // exactly the terminal state this is supposed to prevent, reached + // by the ordinary route of calling reset() twice. + // + // Deleting unconditionally is safe: delete() answers true for a + // record that is not there, so the common case where no erasure + // was ever owed costs one existence check. + if (!InviteStore.delete(InviteStore.ERASURE)) { + // The same direction eraseInternal() takes when it cannot + // clear the marker: a marker that outlives its erasure is read + // as work still owed, so this erasure is reported INCOMPLETE + // rather than leaving the flag and the marker disagreeing. The + // records really did go; what failed is the bookkeeping, and + // the cost of saying so is a retry with nothing left to erase. + erasurePending = true; + cleared = false; + } } return cleared; } @@ -2485,6 +2509,27 @@ private static void beginDeferred() { if (deferredStarted) { return; } + // And not while a lookup is already on the wire. + // + // deferredStarted means "this process started the DEFERRED path", and + // a direct claim never sets it: handleUrl() writes the pending record, + // bumps the epoch and claims. The Android onNewIntent splice queues a + // checkForInvite() behind the same external-url dispatch that reached + // handleUrl(), and now that handleUrl() consumes the argument, that + // queued check finds nothing to handle and falls through to here -- + // where the state is PENDING and the record still holds the code, so + // it claimed the same invite a SECOND time. Both requests carry the + // epoch handleUrl() had just bumped to, so neither is discarded: the + // funnel event can be emitted twice and two of the five attempts are + // spent on one tap. + // + // resumeDeferred() and flush() both ask this before they clear + // deferredStarted; asking it here covers the path where it was never + // set. It is the same bound they rely on -- one attempt per + // lookupRetryDelay -- rather than a new rule. + if (lookupInFlight()) { + return; + } // Before anything is read off the disk. A failed erasure leaves the // PENDING record there with the code it carried, and the lookup below // would reload that code and claim it under the new client id -- the diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index fb06cdfe05c..82950b85c63 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -2675,4 +2675,91 @@ public void attributionUnavailable(String reason) { Invites.setInviteListener(l); assertEquals(1, received[0], "the attribution was never delivered at all"); } + + /** + * A reset that succeeds on the retry does not leave a terminal device + * behind. + * + *

The in-memory latch was already cleared on this path. The DURABLE + * marker was not, and it is the one that decides: resumeOwedErasure() + * reads InviteStore.ERASURE, so a marker written by the earlier FAILED + * reset outlived the retry that satisfied it. The next gated call read it + * as work still owed, ran a full erasure over records that were already + * gone, and wrote the tombstone -- turning "call reset() twice" into a + * permanently unattributable install.

+ */ + @FormTest + void aResetThatSucceedsOnTheRetryClearsTheOwedMarker() { + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.handleResolution( + InviteTestSupport.resolvedJson("RETRY1", "spring", "sms"), + Invites.MATCH_REFERRER, true); + assertNotNull(Invites.getAttribution(), "the fixture did not resolve"); + + // The first reset fails, which is what writes the marker. + InviteStore.failNextDeleteForTest(InviteStore.ATTRIBUTION); + Invites.reset(); + assertNotNull(InviteStore.read(InviteStore.ERASURE), + "the fixture did not leave an erasure owed, so there is nothing to retry"); + + // Storage recovers and the application resets again -- the ordinary + // way an app retries, through the public API rather than through the + // internal resume path. + Invites.reset(); + + Map owed = InviteStore.read(InviteStore.ERASURE); + assertTrue(owed == null || owed.isEmpty(), + "the successful reset left the owed marker behind, so the next gated call " + + "will erase again and tombstone this install"); + + // And the install is not terminal: a fresh invite can still resolve. + Invites.checkForInvite(); + Invites.handleResolution( + InviteTestSupport.resolvedJson("RETRY2", "summer", "email"), + Invites.MATCH_REFERRER, true); + assertNotNull(Invites.getAttribution(), + "an ordinary reset retry made the install permanently unattributable"); + } + + /** + * One tapped link produces one claim, even when the application routes the + * url itself AND the Android onNewIntent splice queues a check behind it. + * + *

handleUrl() consumes the argument, so the queued check finds nothing + * to handle -- and used to fall through to the deferred path, where the + * state is PENDING and the record still holds the code, and claim it a + * second time. deferredStarted does not catch that: a DIRECT claim never + * sets it. Both requests carried the epoch handleUrl() had just bumped to, + * so neither was discarded and two of the five attempts went on one tap.

+ */ + @FormTest + void aRoutedLinkFollowedByTheQueuedCheckClaimsOnce() { + InviteTestSupport.freshInstall(); + implementation.setAutoProcessConnections(false); + Invites.setLinkBase("https://cloud.codenameone.com"); + + assertTrue(Invites.handleUrl("https://cloud.codenameone.com/i/demo/ONETAP1"), + "the fixture url was not recognised as an invite"); + int afterRouting = claimCount(); + assertEquals(1, afterRouting, "routing the url did not issue exactly one claim"); + + // What the generated onNewIntent splice does, one EDT cycle behind the + // dispatch that reached handleUrl(). + Invites.checkForInvite(); + assertEquals(1, claimCount(), + "the queued check claimed the same invite a second time"); + } + + /** Claims currently queued, which is what a duplicate shows up as. */ + private int claimCount() { + int n = 0; + for (int i = 0; i < implementation.getQueuedRequests().size(); i++) { + ConnectionRequest r = implementation.getQueuedRequests().get(i); + if (r.getUrl() != null && r.getUrl().indexOf("/claim") >= 0) { + n++; + } + } + return n; + } } From caafc530664d6156a1459480d4551bf1d955775f Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 05:37:15 +0300 Subject: [PATCH 96/99] Restore AndroidImplementation's CRLF line endings The bounded-share-receiver change was applied with a script that read and wrote the file through Python's universal newlines, which converted all 18455 CRLF terminators to LF. That turned a 61-line change into a whole-file rewrite, and a whole-file rewrite conflicts with anything master does to the same file -- which is what made this PR CONFLICTING, and therefore why GitHub stopped creating pull_request workflow runs for it: a conflicted PR has no merge ref for them to check out. PR CI had been silent since the conflict appeared, not because of a queue. The file was uniformly CRLF before and is again; the real diff is 61 insertions and 7 deletions. Co-Authored-By: Claude Opus 5 (1M context) --- .../impl/android/AndroidImplementation.java | 37012 ++++++++-------- 1 file changed, 18506 insertions(+), 18506 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 7eb89dee225..0b617806bc2 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -1,18506 +1,18506 @@ -/* - * 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.Manifest; -import android.annotation.TargetApi; -import com.codename1.impl.android.permissions.DevicePermission; -import com.codename1.impl.android.permissions.PermissionsHelper; -import com.codename1.location.AndroidLocationManager; -import android.app.*; -import android.content.pm.PackageManager.NameNotFoundException; -import android.media.AudioTimestamp; -import android.support.v4.content.ContextCompat; -import android.view.MotionEvent; -import com.codename1.codescan.ScanResult; -import com.codename1.media.Media; -import com.codename1.ui.geom.Dimension; - - -import android.webkit.CookieSyncManager; -import android.content.*; -import android.content.pm.*; -import android.content.res.AssetFileDescriptor; -import android.content.res.Configuration; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.Canvas; -import android.graphics.Paint; -import android.graphics.Rect; -import android.graphics.Typeface; -import android.graphics.Path; -import android.graphics.drawable.Drawable; -import android.media.AudioManager; -import android.net.Uri; -import android.os.Vibrator; -import android.os.PowerManager; -import android.provider.Settings; -import android.telephony.TelephonyManager; -import android.util.DisplayMetrics; -import android.util.Log; -import android.util.TypedValue; -import android.view.KeyEvent; -import android.view.View; -import android.view.ViewGroup; -import android.view.accessibility.AccessibilityManager; -import android.view.Window; -import android.webkit.WebSettings; -import android.webkit.WebView; -import android.webkit.WebViewClient; -import android.widget.RelativeLayout; -import android.widget.TextView; -import com.codename1.ui.BrowserComponent; -import com.codename1.ui.AccessibilityColorVisionDeficiency; - -import com.codename1.ui.Component; -import com.codename1.ui.Font; -import com.codename1.ui.Image; -import com.codename1.ui.PeerComponent; -import com.codename1.ui.ClipboardContent; -import com.codename1.ui.ClipboardDataProvider; -import com.codename1.ui.events.ActionEvent; -import com.codename1.impl.CodenameOneImplementation; -import com.codename1.impl.VirtualKeyboardInterface; -import com.codename1.ui.plaf.UIManager; -import com.codename1.ui.util.Resources; -import java.lang.ref.SoftReference; -import java.lang.reflect.Method; -import java.net.URISyntaxException; -import java.nio.charset.StandardCharsets; -import java.util.Vector; -import android.database.Cursor; -import android.database.sqlite.SQLiteDatabase; -import android.graphics.Matrix; -import android.graphics.drawable.BitmapDrawable; -import android.hardware.Camera; -import android.media.AudioFormat; -import android.media.AudioRecord; -import android.media.ExifInterface; -import android.media.MediaPlayer; -import android.media.MediaRecorder; -import android.net.ConnectivityManager; -import android.net.NetworkInfo; -import android.os.Build; -import android.os.Bundle; -import android.os.PersistableBundle; -import android.os.Environment; -import android.os.Handler; -import android.os.IBinder; -import android.os.Looper; -import android.os.RemoteException; -import android.provider.MediaStore; -import android.provider.Settings; -import android.provider.Settings.Secure; -import android.renderscript.Allocation; -import android.renderscript.Element; -import android.renderscript.RenderScript; -import android.renderscript.ScriptIntrinsicBlur; -import android.support.v4.app.NotificationCompat; -import android.support.v4.content.FileProvider; -import android.support.v4.media.MediaBrowserCompat; -import android.support.v4.media.session.MediaControllerCompat; -import android.support.v4.media.session.PlaybackStateCompat; -import android.telephony.SmsManager; -import android.telephony.gsm.GsmCellLocation; -import android.text.Html; -import android.view.*; -import android.view.View.MeasureSpec; -import android.view.accessibility.AccessibilityEvent; -import android.view.accessibility.AccessibilityManager; -import android.webkit.*; -import android.widget.*; -import com.codename1.background.BackgroundFetch; -import com.codename1.capture.VideoCaptureConstraints; -import com.codename1.codescan.CodeScanner; -import com.codename1.contacts.Contact; -import com.codename1.db.Database; -import com.codename1.impl.android.compat.app.NotificationCompatWrapper; -import com.codename1.impl.android.compat.app.NotificationCompatWrapper.ActionWrapper; -import com.codename1.impl.android.compat.app.RemoteInputWrapper; -import com.codename1.io.BufferedInputStream; -import com.codename1.io.BufferedOutputStream; -import com.codename1.io.*; -import com.codename1.l10n.L10NManager; -import com.codename1.location.LocationManager; -import com.codename1.media.AbstractMedia; -import com.codename1.media.AsyncMedia; -import com.codename1.media.AsyncMedia.MediaErrorType; -import com.codename1.media.AsyncMedia.MediaException; -import com.codename1.media.Audio; -import com.codename1.media.AudioService; -import com.codename1.media.BackgroundAudioService; -import com.codename1.media.MediaProxy; -import com.codename1.media.MediaRecorderBuilder; -import com.codename1.messaging.Message; -import com.codename1.notifications.LocalNotification; -import com.codename1.notifications.NotificationChannelBuilder; -import com.codename1.notifications.NotificationPermissionCallback; -import com.codename1.notifications.NotificationPermissionRequest; -import com.codename1.notifications.NotificationPermissionResult; -import com.codename1.background.ForegroundService; -import com.codename1.background.WorkRequest; -import com.codename1.share.SharedContent; -import com.codename1.payment.Purchase; -import com.codename1.push.PushAction; -import com.codename1.push.PushActionCategory; -import com.codename1.push.PushActionsProvider; -import com.codename1.push.PushCallback; -import com.codename1.push.PushContent; -import com.codename1.ui.*; -import com.codename1.ui.Dialog; -import com.codename1.ui.Display; -import com.codename1.ui.animations.Animation; -import com.codename1.ui.animations.CommonTransitions; -import com.codename1.ui.events.ActionListener; -import com.codename1.ui.geom.GeneralPath; -import com.codename1.ui.geom.Rectangle; -import com.codename1.ui.geom.Shape; -import com.codename1.ui.layouts.BorderLayout; -import com.codename1.ui.plaf.Style; -import com.codename1.ui.util.EventDispatcher; -import com.codename1.util.AsyncResource; -import com.codename1.util.Callback; -import java.io.File; -import java.io.BufferedReader; -import java.io.FileDescriptor; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.OutputStream; -import java.io.OutputStreamWriter; -import java.io.PrintWriter; -import java.io.RandomAccessFile; -import java.nio.channels.FileLock; -import java.io.Writer; -import java.lang.reflect.Constructor; -import java.net.HttpURLConnection; -import java.net.URI; -import java.net.URL; -import java.net.URLConnection; -import java.text.DateFormat; -import java.text.NumberFormat; -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Date; -import java.util.Hashtable; -import java.util.List; -import java.util.Locale; -import java.util.Set; -import java.util.logging.Level; -import java.util.logging.Logger; -import com.codename1.util.StringUtil; -import com.codename1.util.SuccessCallback; -import java.io.*; -import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Modifier; -import java.net.CookieHandler; -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.NetworkInterface; -import java.net.ServerSocket; -import java.security.MessageDigest; -import java.text.ParseException; -import java.util.*; -import java.util.concurrent.atomic.AtomicLong; -import javax.net.ssl.HttpsURLConnection; -import javax.xml.parsers.ParserConfigurationException; - -import org.json.JSONException; -import org.json.JSONObject; -import org.json.JSONStringer; -import org.xml.sax.SAXException; -//import android.webkit.JavascriptInterface; - -public class AndroidImplementation extends CodenameOneImplementation implements IntentResultListener { - private AndroidCalendarSource calendarSource; - private static final AtomicLong V3_NOTIFICATION_SEQUENCE = new AtomicLong(); - - public static final Thread.UncaughtExceptionHandler exceptionHandler = new Thread.UncaughtExceptionHandler() { - @Override - public void uncaughtException(Thread t, Throwable e) { - try { - com.codename1.crash.CrashProtection.capture(e); - } catch (Throwable ignore) { - } - } - }; - - public static final int FLAG_ONE_SHOT = 0x40000000; - public static final int FLAG_MUTABLE = 0x02000000; - - public static final int FLAG_IMMUTABLE = 0x04000000; - - /** - * make sure these important keys have a negative value when passed to - * Codename One or they might be interpreted as characters. - */ - static final int DROID_IMPL_KEY_LEFT = -23446; - static final int DROID_IMPL_KEY_RIGHT = -23447; - static final int DROID_IMPL_KEY_UP = -23448; - static final int DROID_IMPL_KEY_DOWN = -23449; - static final int DROID_IMPL_KEY_FIRE = -23450; - static final int DROID_IMPL_KEY_MENU = -23451; - static final int DROID_IMPL_KEY_BACK = -23452; - static final int DROID_IMPL_KEY_BACKSPACE = -23453; - static final int DROID_IMPL_KEY_CLEAR = -23454; - static final int DROID_IMPL_KEY_SEARCH = -23455; - static final int DROID_IMPL_KEY_CALL = -23456; - static final int DROID_IMPL_KEY_VOLUME_UP = -23457; - static final int DROID_IMPL_KEY_VOLUME_DOWN = -23458; - static final int DROID_IMPL_KEY_MUTE = -23459; - static final int DROID_IMPL_KEY_ENTER = -23460; - static final int DROID_IMPL_KEY_TAB = -23461; - static final int DROID_IMPL_KEY_ESCAPE = -23462; - static final int DROID_IMPL_KEY_HOME = -23463; - static final int DROID_IMPL_KEY_END = -23464; - static final int DROID_IMPL_KEY_PAGE_UP = -23465; - static final int DROID_IMPL_KEY_PAGE_DOWN = -23466; - static final int DROID_IMPL_KEY_INSERT = -23467; - static final int DROID_IMPL_KEY_FORWARD_DEL = -23468; - static final int DROID_IMPL_KEY_F1 = -23469; - static final int DROID_IMPL_KEY_F2 = -23470; - static final int DROID_IMPL_KEY_F3 = -23471; - static final int DROID_IMPL_KEY_F4 = -23472; - static final int DROID_IMPL_KEY_F5 = -23473; - static final int DROID_IMPL_KEY_F6 = -23474; - static final int DROID_IMPL_KEY_F7 = -23475; - static final int DROID_IMPL_KEY_F8 = -23476; - static final int DROID_IMPL_KEY_F9 = -23477; - static final int DROID_IMPL_KEY_F10 = -23478; - static final int DROID_IMPL_KEY_F11 = -23479; - static final int DROID_IMPL_KEY_F12 = -23480; - static int[] leftSK = new int[]{DROID_IMPL_KEY_MENU}; - - /** - * @return the activity - */ - public static CodenameOneActivity getActivity() { - return activity; - } - - // ---- low level text input source (pure Codename One editors) ---- - - private static volatile com.codename1.ui.TextInputClient activeInputClient; - private static volatile com.codename1.ui.TextInputState activeInputState; - private static volatile com.codename1.ui.TextInputConfig activeInputConfig; - /// Synchronous mirror of edits the input connection has posted but the EDT has not yet - /// applied and echoed back. IMEs (notably Gboard) commit text and immediately re-read the - /// surrounding text; without this mirror they would see pre-commit text and desync their - /// suggestion model. Cleared when the authoritative state from the EDT has caught up with - /// every posted edit (the seq pair below). - private static volatile com.codename1.ui.TextInputState pendingInputState; - /// Generation of the last edit the input connection posted (written on the IME thread). - private static volatile int pendingPostedSeq; - /// Generation of the last posted edit the EDT applied (written on the EDT). - private static volatile int pendingAppliedSeq; - - /// Returns the editing state as the IME must see it right now: the pending synchronous - /// mirror when an edit is in flight, otherwise the last state pushed from the EDT. - static com.codename1.ui.TextInputState currentInputState() { - com.codename1.ui.TextInputState pending = pendingInputState; - return pending != null ? pending : activeInputState; - } - - /// Records the input connection's synchronous mirror of an in-flight edit and returns the - /// edit's generation; the connection marks it applied from the EDT runnable that delivers - /// the edit to the client. - static int setPendingInputState(com.codename1.ui.TextInputState state) { - pendingInputState = state; - return ++pendingPostedSeq; - } - - /// Marks a posted edit as applied on the EDT (called right before the client mutation whose - /// state push may then retire the mirror). - static void markPendingApplied(int seq) { - pendingAppliedSeq = seq; - } - - /// Routes a hardware (Bluetooth / Chromebook) key event to the bound text input client. - /// Hardware keys bypass the IME entirely, and the pure editor's raw key path is disabled - /// while a platform session is active, so without this they would be silently dropped. - /// Returns true when the event was consumed for the client (including the matching key-up - /// of a consumed key-down); false leaves the event to the regular Codename One pipeline - /// (BACK, D-pad game keys on non-editor forms, ...). - static boolean routeHardwareKeyToActiveClient(boolean down, android.view.KeyEvent event) { - com.codename1.ui.TextInputClient client = activeInputClient; - if (client == null || event == null) { - return false; - } - return CN1TextInputConnection.deliverHardwareKey(client, event, down); - } - - /// Re-requests the soft keyboard for the bound text input client. Called on every tap so a - /// keyboard the user dismissed (back gesture) returns when the editor is tapped again, the - /// same behavior a native EditText has. No-op when no client is bound. - static void showSoftInputForActiveClient() { - if (activeInputClient == null) { - return; - } - final CodenameOneActivity a = getActivity(); - final CodenameOneSurface view = instance != null ? instance.myView : null; - if (a == null || view == null) { - return; - } - a.runOnUiThread(new Runnable() { - public void run() { - if (activeInputClient == null) { - return; - } - android.view.View v = view.getAndroidView(); - v.requestFocus(); - android.view.inputmethod.InputMethodManager imm = (android.view.inputmethod.InputMethodManager) - a.getSystemService(android.content.Context.INPUT_METHOD_SERVICE); - if (imm != null) { - imm.showSoftInput(v, 0); - } - } - }); - } - - static com.codename1.ui.TextInputConfig currentInputConfig() { - return activeInputConfig; - } - - /// Called by the rendering view's `onCreateInputConnection` to supply the custom input connection - /// when a pure editor is bound. Returns null when no client is active so the view keeps its default - /// behavior. - static android.view.inputmethod.InputConnection createEditorInputConnection(android.view.View view, android.view.inputmethod.EditorInfo editorInfo) { - com.codename1.ui.TextInputClient client = activeInputClient; - if (client == null) { - return null; - } - configureEditorInfo(editorInfo, activeInputConfig); - return new CN1TextInputConnection(view, client); - } - - /// True when a pure editor text input client is currently bound. - static boolean hasActiveInputClient() { - return activeInputClient != null; - } - - /// The Android autofill hint for a one-time code, spelled out rather than referenced as - /// `View.AUTOFILL_HINT_SMS_OTP` because the constant is newer than the SDK this port - /// compiles against. The string is the contract: it is what an autofill service matches on. - private static final String AUTOFILL_HINT_SMS_OTP = "smsOTPCode"; - - /// What the platform may fill into the currently bound field, or null when it is not a field - /// the platform can fill. - /// - /// Only the one-time code is offered. The rendering surface is a single view standing in for - /// whichever field is being edited, so claiming a hint puts the whole surface forward as that - /// kind of field -- true only while the code field holds the session, which is why the hint is - /// applied when a session starts and dropped when it ends. - private static String[] editorAutofillHints() { - com.codename1.ui.TextInputConfig cfg = activeInputConfig; - if (cfg != null && (cfg.getConstraint() & com.codename1.ui.TextArea.ONE_TIME_CODE) != 0) { - return new String[]{AUTOFILL_HINT_SMS_OTP}; - } - return null; - } - - /// Puts the surface forward as an autofillable field, or withdraws it, to match the field the - /// input session is bound to. Called on the UI thread as a session starts and stops. - /// - /// #### Parameters - /// - /// - `v`: the rendering view - /// - /// - `sessionActive`: true while a client is bound - static void updateEditorAutofill(android.view.View v, boolean sessionActive) { - if (v == null || android.os.Build.VERSION.SDK_INT < 26) { - return; - } - android.view.autofill.AutofillManager afm = - (android.view.autofill.AutofillManager) v.getContext() - .getSystemService(android.view.autofill.AutofillManager.class); - String[] hints = sessionActive ? editorAutofillHints() : null; - if (hints == null) { - v.setImportantForAutofill(android.view.View.IMPORTANT_FOR_AUTOFILL_NO); - v.setAutofillHints((String[]) null); - if (afm != null) { - afm.notifyViewExited(v); - } - return; - } - v.setAutofillHints(hints); - v.setImportantForAutofill(android.view.View.IMPORTANT_FOR_AUTOFILL_YES); - if (afm != null) { - // the session only starts once the framework is told the view was entered; a view - // that merely carries hints is never offered anything - afm.notifyViewEntered(v); - } - } - - /// Applies a value the platform filled in, replacing whatever the field held. Called by the - /// rendering view on the UI thread; the edit itself belongs to the EDT. - /// - /// #### Parameters - /// - /// - `value`: the value the autofill service supplied - /// - /// #### Returns - /// - /// true when the value was taken - static boolean autofillEditor(android.view.autofill.AutofillValue value) { - final com.codename1.ui.TextInputClient client = activeInputClient; - if (client == null || value == null || !value.isText()) { - return false; - } - // Only into a field that asked for this. The hint lives on the surface and is put - // there and taken away on Android's UI thread, while the session it describes changes - // on the EDT, so for a moment after the user moves from a code field to an ordinary - // one the view still advertises smsOTPCode while the session behind it is something - // else. A fill delivered in that gap would otherwise land a code in whatever the user - // tapped into. Asking what the CURRENT session advertises closes it: the answer is - // read from the same field the identity check below uses. - if (editorAutofillHints() == null) { - return false; - } - com.codename1.ui.Display.getInstance().callSerially( - new ApplyAutofilledText(client, value.getTextValue().toString())); - return true; - } - - private static final class ApplyAutofilledText implements Runnable { - private final com.codename1.ui.TextInputClient client; - private final String text; - - ApplyAutofilledText(com.codename1.ui.TextInputClient client, String text) { - this.client = client; - this.text = text; - } - - public void run() { - // The session may be gone: the platform fills on the UI thread and this runs a hop - // later on the EDT, and in between the user can have moved to another field or left - // the screen. Applying it then would edit a field nothing is bound to any more and - // fire its listeners -- and an OtpField's completion listener submits a code, so a - // late fill would verify one for a flow the user has already left. The rest of this - // bridge guards its callbacks the same way. - if (client != activeInputClient || editorAutofillHints() == null) { - return; - } - // A filled value replaces the field rather than being inserted at the caret: the - // platform is answering "the value is this", not typing into what is there. It - // still arrives as a commit rather than a raw range replacement, because a field - // filters what it accepts and a filled value has no more right to bypass that - // than a typed one -- an OTP field asked for six digits and can be handed - // "123-456" by an autofill service that kept the separator, and a replacement - // would leave the field holding a value it would never have let anyone type, - // never reaching the length that completes it. - // Ending any composition first. A commit replaces the composed range in - // preference to the selection, so selecting the whole field is not enough to - // replace the whole field while an input method is mid-word: the filled value - // would land inside the composition and leave whatever surrounded it, which - // for a code field means a full-length wrong code that submits itself. - client.finishComposing(); - client.setSelectionRange(0, client.getTextLength()); - client.commitText(text); - } - } - - /// The value the platform should see for the bound field, or null when nothing is bound. - /// - /// Answered from the state snapshot rather than the editor itself. This runs on Android's UI - /// thread whenever an autofill service asks what the field holds, while the document belongs - /// to the EDT, and reading a length and then a range out of a document another thread is - /// editing is two reads of something that can change in between. Clamped offsets would not - /// rescue it either, since the buffer underneath can be restructured mid-read. The snapshot - /// is immutable and is what the rest of this bridge already uses to answer the platform - /// across that boundary; a value one edit out of date is the correct trade against a crash - /// inside somebody else's autofill query. - static android.view.autofill.AutofillValue editorAutofillValue() { - // Read the state AFTER the guards and confirm the session did not move under it. - // The three fields are assigned separately on the EDT, so taking the state first - // and validating afterwards can pair one field's text with the next field's - // configuration -- and the pairing that matters is a password field's text with a - // code field's hint. One session snapshot would express this better than three - // fields and a re-check, but that is the whole input bridge's shape rather than - // this method's, and the property needed here is only that nothing is returned - // for a session other than the one that was checked. - // - // Gated the same way the write path is, and for a sharper reason: between the EDT - // moving to another field and the UI thread taking the hint off the view, the - // surface still looks like a code field over a session that is something else -- - // and answering this query then would hand that field's text to an SMS autofill - // service. The field after a code field is as likely to be a password as anything. - com.codename1.ui.TextInputClient client = activeInputClient; - if (client == null || editorAutofillHints() == null) { - return null; - } - com.codename1.ui.TextInputState state = activeInputState; - if (state == null || client != activeInputClient) { - return null; - } - String text = state.getText(); - return android.view.autofill.AutofillValue.forText(text == null ? "" : text); - } - - private static void configureEditorInfo(android.view.inputmethod.EditorInfo editorInfo, com.codename1.ui.TextInputConfig cfg) { - int constraint = cfg == null ? 0 : cfg.getConstraint(); - int inputType; - switch (constraint & 0xffff) { - case com.codename1.ui.TextArea.NUMERIC: - inputType = android.text.InputType.TYPE_CLASS_NUMBER - | android.text.InputType.TYPE_NUMBER_FLAG_SIGNED; - break; - case com.codename1.ui.TextArea.DECIMAL: - inputType = android.text.InputType.TYPE_CLASS_NUMBER - | android.text.InputType.TYPE_NUMBER_FLAG_SIGNED - | android.text.InputType.TYPE_NUMBER_FLAG_DECIMAL; - break; - case com.codename1.ui.TextArea.PHONENUMBER: - inputType = android.text.InputType.TYPE_CLASS_PHONE; - break; - case com.codename1.ui.TextArea.EMAILADDR: - inputType = android.text.InputType.TYPE_CLASS_TEXT - | android.text.InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS; - break; - case com.codename1.ui.TextArea.URL: - inputType = android.text.InputType.TYPE_CLASS_TEXT - | android.text.InputType.TYPE_TEXT_VARIATION_URI; - break; - default: - inputType = android.text.InputType.TYPE_CLASS_TEXT; - break; - } - boolean text = (inputType & android.text.InputType.TYPE_MASK_CLASS) == android.text.InputType.TYPE_CLASS_TEXT; - boolean password = (constraint & com.codename1.ui.TextArea.PASSWORD) != 0; - if (password) { - inputType = text - ? inputType | android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD - : android.text.InputType.TYPE_CLASS_NUMBER | android.text.InputType.TYPE_NUMBER_VARIATION_PASSWORD; - text = (inputType & android.text.InputType.TYPE_MASK_CLASS) == android.text.InputType.TYPE_CLASS_TEXT; - } - boolean multiline = cfg == null || cfg.isMultiline(); - if (text) { - if (multiline) { - inputType |= android.text.InputType.TYPE_TEXT_FLAG_MULTI_LINE; - } - if (password || (cfg != null && !cfg.isAutoCorrect())) { - inputType |= android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS; - } - if (!password && cfg != null && cfg.isAutoCapitalize()) { - inputType |= android.text.InputType.TYPE_TEXT_FLAG_CAP_SENTENCES; - } - } - if ((constraint & com.codename1.ui.TextArea.ONE_TIME_CODE) != 0 && text) { - // a code is not a word: prediction would offer completions for it and, worse, learn it - inputType |= android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS; - } - editorInfo.inputType = inputType; - editorInfo.imeOptions = android.view.inputmethod.EditorInfo.IME_FLAG_NO_EXTRACT_UI; - if (multiline) { - editorInfo.imeOptions |= android.view.inputmethod.EditorInfo.IME_ACTION_NONE; - } else { - editorInfo.imeOptions |= imeActionFor(cfg == null - ? com.codename1.ui.TextInputConfig.ACTION_DEFAULT : cfg.getActionType()); - } - editorInfo.initialSelStart = activeInputState != null ? activeInputState.getSelectionStart() : 0; - editorInfo.initialSelEnd = activeInputState != null ? activeInputState.getSelectionEnd() : 0; - } - - private static int imeActionFor(int actionType) { - switch (actionType) { - case com.codename1.ui.TextInputConfig.ACTION_NEXT: - return android.view.inputmethod.EditorInfo.IME_ACTION_NEXT; - case com.codename1.ui.TextInputConfig.ACTION_SEARCH: - return android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH; - case com.codename1.ui.TextInputConfig.ACTION_SEND: - return android.view.inputmethod.EditorInfo.IME_ACTION_SEND; - case com.codename1.ui.TextInputConfig.ACTION_DONE: - default: - return android.view.inputmethod.EditorInfo.IME_ACTION_DONE; - } - } - - /// Maps an Android `EditorInfo.IME_ACTION_*` code back to the `TextInputConfig` action constant - /// delivered to `TextInputClient.onEditorAction`. - static int textInputActionFor(int imeActionCode) { - switch (imeActionCode) { - case android.view.inputmethod.EditorInfo.IME_ACTION_NEXT: - return com.codename1.ui.TextInputConfig.ACTION_NEXT; - case android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH: - return com.codename1.ui.TextInputConfig.ACTION_SEARCH; - case android.view.inputmethod.EditorInfo.IME_ACTION_SEND: - return com.codename1.ui.TextInputConfig.ACTION_SEND; - case android.view.inputmethod.EditorInfo.IME_ACTION_DONE: - return com.codename1.ui.TextInputConfig.ACTION_DONE; - default: - return com.codename1.ui.TextInputConfig.ACTION_DEFAULT; - } - } - - @Override - public boolean isTextInputSupported() { - return true; - } - - @Override - public Object startTextInput(com.codename1.ui.TextInputClient client, com.codename1.ui.TextInputConfig config) { - activeInputClient = client; - activeInputConfig = config; - activeInputState = client.getEditingState(); - pendingInputState = null; - final CodenameOneActivity a = getActivity(); - final CodenameOneSurface view = myView; - if (a == null || view == null) { - return client; - } - a.runOnUiThread(new Runnable() { - public void run() { - android.view.View v = view.getAndroidView(); - v.setFocusable(true); - v.setFocusableInTouchMode(true); - v.requestFocus(); - android.view.inputmethod.InputMethodManager imm = (android.view.inputmethod.InputMethodManager) - a.getSystemService(android.content.Context.INPUT_METHOD_SERVICE); - if (imm != null) { - imm.restartInput(v); - imm.showSoftInput(v, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT); - } - updateEditorAutofill(v, true); - } - }); - return client; - } - - @Override - public void updateTextInputState(Object handle, com.codename1.ui.TextInputState state) { - if (handle == null || handle != activeInputClient || state == null) { - // a stale handle (an unbalanced session that was already replaced) must not - // disturb the currently bound client - return; - } - activeInputState = state; - // retire the connection's synchronous mirror only when this push reflects every posted - // edit; clearing early would hide an in-flight edit from the IME's immediate re-reads - if (pendingAppliedSeq == pendingPostedSeq) { - pendingInputState = null; - } - final CodenameOneActivity a = getActivity(); - final CodenameOneSurface view = myView; - if (a == null || view == null) { - return; - } - a.runOnUiThread(new Runnable() { - public void run() { - android.view.inputmethod.InputMethodManager imm = (android.view.inputmethod.InputMethodManager) - a.getSystemService(android.content.Context.INPUT_METHOD_SERVICE); - if (imm != null && activeInputClient != null) { - com.codename1.ui.TextInputState s = activeInputState; - imm.updateSelection(view.getAndroidView(), s.getSelectionStart(), s.getSelectionEnd(), - s.getComposingStart(), s.getComposingEnd()); - } - } - }); - } - - @Override - public void stopTextInput(Object handle) { - if (handle == null || handle != activeInputClient) { - return; - } - activeInputClient = null; - activeInputState = null; - activeInputConfig = null; - pendingInputState = null; - final CodenameOneActivity a = getActivity(); - final CodenameOneSurface view = myView; - if (a == null || view == null) { - return; - } - a.runOnUiThread(new Runnable() { - public void run() { - android.view.inputmethod.InputMethodManager imm = (android.view.inputmethod.InputMethodManager) - a.getSystemService(android.content.Context.INPUT_METHOD_SERVICE); - if (imm != null) { - imm.hideSoftInputFromWindow(view.getAndroidView().getWindowToken(), 0); - imm.restartInput(view.getAndroidView()); - } - updateEditorAutofill(view.getAndroidView(), false); - } - }); - } - - - @Override - public void setDisableScreenshots(final boolean disable) { - final CodenameOneActivity a = getActivity(); - if (a == null || a.getWindow() == null) { - return; - } - a.runOnUiThread(new Runnable() { - @Override - public void run() { - if (disable) { - a.getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE); - } else { - a.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE); - } - } - }); - } - - /** - * @param aActivity the activity to set - */ - public static void setActivity(CodenameOneActivity aActivity) { - activity = aActivity; - if (activity != null) { - activityComponentName = activity.getComponentName(); - } - - } - CodenameOneSurface myView = null; - private AndroidAccessibilityProvider accessibilityProvider; - private volatile boolean accessibilityTreeUpdateRequired; - CodenameOneTextPaint defaultFont; - private final char[] tmpchar = new char[1]; - private final Rect tmprect = new Rect(); - protected int defaultFontHeight; - private Vibrator v = null; - private boolean vibrateInitialized = false; - private int displayWidth; - private int displayHeight; - static CodenameOneActivity activity; - static ComponentName activityComponentName; - private static PowerManager.WakeLock pushWakeLock; - public static synchronized void acquirePushWakeLock(long timeout) { - if (getContext() == null) return; - try { - if (pushWakeLock == null) { - PowerManager pm = (PowerManager) getContext().getSystemService(Context.POWER_SERVICE); - pushWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "CN1:PushWakeLock"); - } - pushWakeLock.acquire(timeout); - } catch (Exception ex) { - com.codename1.io.Log.e(ex); - } - } - - private static Context context; - private static PermissionPromptCallback permissionPromptCallback; - RelativeLayout relativeLayout; - final Vector nativePeers = new Vector(); - int lastDirectionalKeyEventReceivedByWrapper; - private EventDispatcher callback; - private int timeout = -1; - private CodeScannerImpl scannerInstance; - private HashMap apIds; - private static View viewBelow; - private static View viewAbove; - private static int aboveSpacing; - private static int belowSpacing; - public static boolean asyncView = false; - public static boolean textureView = false; - private AudioService background; - private boolean asyncEditMode = false; - private boolean compatPaintMode; - private MediaRecorder recorder = null; - - private boolean statusBarHidden; - private boolean superPeerMode = true; - - - private ValueCallback mUploadMessage; - public ValueCallback uploadMessage; - - /** - * Keeps track of running contexts. - * @see #startContext(Context) - * @see #stopContext(Context) - */ - private static HashSet activeContexts = new HashSet(); - - /** - * A method to be called when a Context begins its execution. This adds the - * context to the context set. When the contenxt's execution completes, it should - * call {@link #stopContext} to clear up resources. - * @param ctx The context that is starting. - * @see #stopContext(Context) - */ - public static void startContext(Context ctx) { - - while (deinitializingEdt) { - // It is possible that deinitialize was called just before the - // last context was destroyed so there is a pending deinitialize - // working its way through the system. Give it some time - // before forcing the deinitialize - System.out.println("Waiting for deinitializing to complete before starting a new initialization"); - Util.sleep(30); - } - if (deinitializing && instance != null) { - instance.deinitialize(); - } - synchronized(activeContexts) { - activeContexts.add(ctx); - if (instance == null) { - // If this is our first rodeo, just call Display.init() as that should - // be sufficient to set everything up. - Display.init(ctx); - } else { - // If we've initialized before, we should "re-initialize" the implementation - // Reinitializing will force views to be created even if the EDT was already - // running in background mode. - reinit(ctx); - } - } - } - - /** - * Cleans up resources in the given context. This method should be called by - * any Activity or Service that called startContext() when it started. - * @param ctx The context to stop. - * - * @see #startContext(Context) - */ - public static void stopContext(Context ctx) { - synchronized(activeContexts) { - activeContexts.remove(ctx); - if (activeContexts.isEmpty()) { - // If we are the last context, we should deinitialize - syncDeinitialize(); - } else { - if (instance != null && getActivity() != null) { - // if this is an activity, then we should clean up - // our UI resources anyways because the last context - // to be cleaned up might not have access to the UI thread. - instance.deinitialize(); - } - } - } - } - - @Override - public void screenshot(SuccessCallback callback) { - final Activity activity = (Activity) getContext(); - final AndroidScreenshotTask task = new AndroidScreenshotTask(myView, activity, callback); - activity.runOnUiThread(task); - } - - @Override - public void setPlatformHint(String key, String value) { - if(key.equals("platformHint.compatPaintMode")) { - compatPaintMode = value.equalsIgnoreCase("true"); - return; - } - if(key.equals("platformHint.legacyPaint")) { - AndroidAsyncView.legacyPaintLogic = value.equalsIgnoreCase("true");; - } - } - - - /** - * This method in used internally for ads - * @param above shown above the view - * @param below shown below the view - */ - public static void setViewAboveBelow(View above, View below, int spacingAbove, int spacingBelow) { - viewBelow = below; - viewAbove = above; - aboveSpacing = spacingAbove; - belowSpacing = spacingBelow; - } - - static boolean hasViewAboveBelow(){ - return viewBelow != null || viewAbove != null; - } - - /** - * Copy the input stream into the output stream, closes both streams when finishing or in - * a case of an exception - * - * @param i source - * @param o destination - */ - private static void copy(InputStream i, OutputStream o) throws IOException { - copy(i, o, 8192); - } - - /** - * Copy the input stream into the output stream, closes both streams when finishing or in - * a case of an exception - * - * @param i source - * @param o destination - * @param bufferSize the size of the buffer, which should be a power of 2 large enoguh - */ - private static void copy(InputStream i, OutputStream o, int bufferSize) throws IOException { - try { - byte[] buffer = new byte[bufferSize]; - int size = i.read(buffer); - while(size > -1) { - o.write(buffer, 0, size); - size = i.read(buffer); - } - } finally { - sCleanup(o); - sCleanup(i); - } - } - - private static void sCleanup(Object o) { - try { - if(o != null) { - if(o instanceof InputStream) { - ((InputStream)o).close(); - return; - } - if(o instanceof OutputStream) { - ((OutputStream)o).close(); - return; - } - } - } catch(Throwable t) {} - } - - /** - * Copied here since the cleanup method in util would crash append notification that runs when the app isn't in the foreground - */ - private static byte[] readInputStream(InputStream i) throws IOException { - ByteArrayOutputStream b = new ByteArrayOutputStream(); - copy(i, b); - return b.toByteArray(); - } - - - public static void appendNotification(String type, String body, Context a) { - appendNotification(type, body, null, null, a); - } - - /** Receives the managed typed envelope from FCM without applying legacy push decoding. */ - public static void handleV3Push(final String envelope, Context context, - boolean appRunning, Class appStubClass) { - if (appRunning && Display.isInitialized() - && com.codename1.push.PushClient.hasActiveClient()) { - Display.getInstance().callSerially(new Runnable() { - public void run() { - com.codename1.push.PushClient.dispatch(envelope); - } - }); - return; - } - try { - org.json.JSONObject message = new org.json.JSONObject(envelope); - // The pending-push file explicitly encodes whether a legacy type is present. - // A missing type is the sentinel for a typed V3 envelope and is replayed intact. - appendNotification(null, envelope, context); - if (message.optBoolean("silent", false)) { - return; - } - String title = message.optString("title", ""); - String body = message.optString("body", ""); - String image = message.optString("image", ""); - if (title.length() == 0 && body.length() == 0 && image.length() == 0) { - return; - } - if (title.length() == 0) { - title = context.getApplicationInfo().loadLabel(context.getPackageManager()).toString(); - } - Intent intent = new Intent(context, appStubClass); - PendingIntent contentIntent = createPendingIntent(context, 0, intent); - int smallIcon = context.getResources().getIdentifier("ic_stat_notify", "drawable", - context.getPackageName()); - if (smallIcon == 0) { - smallIcon = context.getApplicationInfo().icon; - } - NotificationCompat.Builder builder = new NotificationCompat.Builder(context) - .setContentTitle(title) - .setContentText(body) - .setSmallIcon(smallIcon) - .setContentIntent(contentIntent) - .setAutoCancel(true) - .setWhen(System.currentTimeMillis()); - NotificationManager manager = (NotificationManager) - context.getSystemService(Context.NOTIFICATION_SERVICE); - setNotificationChannel(manager, builder, context); - String collapseKey = message.optString("collapseKey", null); - String messageId = message.optString("id", null); - String notificationTag; - if (collapseKey != null && collapseKey.length() > 0) { - notificationTag = v3NotificationTag("CN1_PUSH_V3_COLLAPSE:", collapseKey); - } else if (messageId != null && messageId.length() > 0) { - notificationTag = v3NotificationTag("CN1_PUSH_V3_MESSAGE:", messageId); - } else { - notificationTag = "CN1_PUSH_V3_EPHEMERAL:" + System.currentTimeMillis() - + ":" + V3_NOTIFICATION_SEQUENCE.incrementAndGet(); - } - manager.notify(notificationTag, 0, builder.build()); - } catch (Exception error) { - Log.e("Codename One", "Failed to handle a Push V3 envelope", error); - } - } - - private static String v3NotificationTag(String prefix, String value) { - if (prefix.length() + value.length() <= 128) { - return prefix + value; - } - try { - byte[] digest = MessageDigest.getInstance("SHA-256") - .digest(value.getBytes(StandardCharsets.UTF_8)); - StringBuilder out = new StringBuilder(prefix.length() + digest.length * 2); - out.append(prefix); - for (byte item : digest) { - int unsigned = item & 0xff; - if (unsigned < 0x10) { - out.append('0'); - } - out.append(Integer.toHexString(unsigned)); - } - return out.toString(); - } catch (Exception error) { - return prefix + Integer.toHexString(value.hashCode()); - } - } - - public static void appendNotification(String type, String body, String image, String category, Context a) { - try { - String[] fileList = a.fileList(); - byte[] data = null; - for (int iter = 0; iter < fileList.length; iter++) { - if (fileList[iter].equals("CN1$AndroidPendingNotifications")) { - InputStream is = a.openFileInput("CN1$AndroidPendingNotifications"); - if(is != null) { - data = readInputStream(is); - sCleanup(a); - break; - } - } - } - DataOutputStream os = new DataOutputStream(a.openFileOutput("CN1$AndroidPendingNotifications", 0)); - if(data != null) { - data[0]++; - os.write(data); - } else { - os.writeByte(1); - } - String bodyType = type; - if (image != null || category != null) { - type = "99"; - } - if(type != null) { - os.writeBoolean(true); - os.writeUTF(type); - } else { - os.writeBoolean(false); - } - if ("99".equals(type)) { - String msg = "body="+java.net.URLEncoder.encode(body, "UTF-8") - +"&type="+java.net.URLEncoder.encode(bodyType, "UTF-8"); - if (category != null) { - msg += "&category="+java.net.URLEncoder.encode(category, "UTF-8"); - } - if (image != null) { - msg += "&image="+java.net.URLEncoder.encode(image, "UTF-8"); - } - os.writeUTF(msg); - - } else { - os.writeUTF(body); - } - os.writeLong(System.currentTimeMillis()); - } catch(IOException err) { - err.printStackTrace(); - } - } - - private static Map splitQuery(String urlencodeQueryString) { - String[] parts = urlencodeQueryString.split("&"); - Map out = new HashMap(); - for (String part : parts) { - int pos = part.indexOf("="); - String k,v; - if (pos > 0) { - k = part.substring(0, pos); - v = part.substring(pos+1); - } else { - k = part; - v = ""; - } - try { - k = java.net.URLDecoder.decode(k, "UTF-8"); - v = java.net.URLDecoder.decode(v, "UTF-8"); - } catch (UnsupportedEncodingException ex) { - // won't happen - com.codename1.io.Log.e(ex); - } - out.put(k, v); - } - return out; - } - - public String getStackTrace(Thread parentThread, Throwable t) { - System.out.println("CN1SS:ERR:Invoking getStackTrace in AndroidImplementation"); - ByteArrayOutputStream bos = new ByteArrayOutputStream(); - PrintWriter w = new PrintWriter(new OutputStreamWriter(bos, StandardCharsets.UTF_8)); - t.printStackTrace(w); - w.close(); - System.out.println("CN1SS:ERR:AndroidImplementation getStackTrace completed"); - return new String(bos.toByteArray(), StandardCharsets.UTF_8); - } - - public static void initPushContent(String message, String image, String messageType, String category, Context context) { - com.codename1.push.PushContent.reset(); - - int iMessageType = 1; - try {iMessageType = Integer.parseInt(messageType);}catch(Throwable t){} - - String actionId = null; - String reply = null; - boolean cancel = true; - if (context instanceof Activity) { - Activity activity = (Activity)context; - Bundle extras = activity.getIntent().getExtras(); - if (extras != null) { - actionId = extras.getString("pushActionId"); - extras.remove("pushActionId"); - - if (actionId != null && RemoteInputWrapper.isSupported()) { - Bundle textExtras = RemoteInputWrapper.getResultsFromIntent(activity.getIntent()); - if (textExtras != null) { - CharSequence cs = textExtras.getCharSequence(actionId + "$Result"); - if (cs != null) { - reply = cs.toString(); - } - } - - - } - } - - } - if (cancel) { - PushNotificationService.cancelNotification(context); - } - com.codename1.push.PushContent.setType(iMessageType); - com.codename1.push.PushContent.setCategory(category); - if (actionId != null) { - com.codename1.push.PushContent.setActionId(actionId); - } - if (reply != null) { - com.codename1.push.PushContent.setTextResponse(reply); - } - switch (iMessageType) { - case 1: - case 5: - com.codename1.push.PushContent.setBody(message);break; - case 2: com.codename1.push.PushContent.setMetaData(message);break; - case 3: { - String[] parts = message.split(";"); - com.codename1.push.PushContent.setMetaData(parts[1]); - com.codename1.push.PushContent.setBody(parts[0]); - break; - } - case 4: { - String[] parts = message.split(";"); - com.codename1.push.PushContent.setTitle(parts[0]); - com.codename1.push.PushContent.setBody(parts[1]); - break; - } - case 101: { - com.codename1.push.PushContent.setBody(message.substring(message.indexOf(" ") + 1)); - com.codename1.push.PushContent.setType(1); - break; - } - case 102: { - String[] parts = message.split(";"); - com.codename1.push.PushContent.setTitle(parts[1]); - com.codename1.push.PushContent.setBody(parts[2]); - com.codename1.push.PushContent.setType(2); - break; - } - } - } - - // Name of file where we install the push notification categories as an XML file - // if the main class implements PushActiosProvider - private static String FILE_NAME_NOTIFICATION_CATEGORIES = "CN1$AndroidNotificationCategories"; - - - - /** - * Action categories are defined on the Main class by implementing the PushActionsProvider, however - * the main class may not be available to the push receiver, so we need to save these categories - * to the file system when the app is installed, then the push receiver can load these actions - * when it sends a push while the app isn't running. - * @param provider A reference to the App's main class - * @throws IOException - */ - public static void installNotificationActionCategories(PushActionsProvider provider) throws IOException { - // Assume that CN1 is running... this will run when the app starts - // up - Context context = getContext(); - boolean requiresUpdate = false; - - File categoriesFile = new File(activity.getFilesDir().getAbsolutePath() + "/" + FILE_NAME_NOTIFICATION_CATEGORIES); - if (!categoriesFile.exists()) { - requiresUpdate = true; - } - if (!requiresUpdate) { - try { - PackageInfo packageInfo = context.getPackageManager().getPackageInfo(context.getApplicationContext().getPackageName(), PackageManager.GET_PERMISSIONS); - if (packageInfo.lastUpdateTime > categoriesFile.lastModified()) { - requiresUpdate = true; - } - } catch (Exception ex) { - ex.printStackTrace(); - } - } - - if (!requiresUpdate) { - return; - } - - OutputStream os = getContext().openFileOutput(FILE_NAME_NOTIFICATION_CATEGORIES, 0); - PushActionCategory[] categories = provider.getPushActionCategories(); - javax.xml.parsers.DocumentBuilderFactory docFactory = javax.xml.parsers.DocumentBuilderFactory.newInstance(); - javax.xml.parsers.DocumentBuilder docBuilder; - try { - docBuilder = docFactory.newDocumentBuilder(); - } catch (ParserConfigurationException ex) { - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - throw new IOException("Faield to create document builder for creating notification categories XML document", ex); - } - - // root elements - org.w3c.dom.Document doc = docBuilder.newDocument(); - org.w3c.dom.Element root = (org.w3c.dom.Element)doc.createElement("categories"); - doc.appendChild(root); - for (PushActionCategory category : categories) { - org.w3c.dom.Element categoryEl = (org.w3c.dom.Element)doc.createElement("category"); - org.w3c.dom.Attr idAttr = doc.createAttribute("id"); - idAttr.setValue(category.getId()); - categoryEl.setAttributeNode(idAttr); - - for (PushAction action : category.getActions()) { - org.w3c.dom.Element actionEl = (org.w3c.dom.Element)doc.createElement("action"); - org.w3c.dom.Attr actionIdAttr = doc.createAttribute("id"); - actionIdAttr.setValue(action.getId()); - actionEl.setAttributeNode(actionIdAttr); - - - org.w3c.dom.Attr actionTitleAttr = doc.createAttribute("title"); - if (action.getTitle() != null) { - actionTitleAttr.setValue(action.getTitle()); - } else { - actionTitleAttr.setValue(action.getId()); - } - actionEl.setAttributeNode(actionTitleAttr); - - if (action.getIcon() != null) { - org.w3c.dom.Attr actionIconAttr = doc.createAttribute("icon"); - String iconVal = action.getIcon(); - try { - // We'll store the resource IDs for the icon - // rather than the icon name because that is what - // the push notifications require. - iconVal = ""+context.getResources().getIdentifier(iconVal, "drawable", context.getPackageName()); - actionIconAttr.setValue(iconVal); - actionEl.setAttributeNode(actionIconAttr); - } catch (Exception ex) { - ex.printStackTrace(); - - } - - } - - if (action.getTextInputPlaceholder() != null) { - org.w3c.dom.Attr textInputPlaceholderAttr = doc.createAttribute("textInputPlaceholder"); - textInputPlaceholderAttr.setValue(action.getTextInputPlaceholder()); - actionEl.setAttributeNode(textInputPlaceholderAttr); - } - if (action.getTextInputButtonText() != null) { - org.w3c.dom.Attr textInputButtonTextAttr = doc.createAttribute("textInputButtonText"); - textInputButtonTextAttr.setValue(action.getTextInputButtonText()); - actionEl.setAttributeNode(textInputButtonTextAttr); - } - categoryEl.appendChild(actionEl); - } - root.appendChild(categoryEl); - - } - try { - javax.xml.transform.TransformerFactory transformerFactory = javax.xml.transform.TransformerFactory.newInstance(); - javax.xml.transform.Transformer transformer = transformerFactory.newTransformer(); - javax.xml.transform.dom.DOMSource source = new javax.xml.transform.dom.DOMSource(doc); - javax.xml.transform.stream.StreamResult result = new javax.xml.transform.stream.StreamResult(os); - transformer.transform(source, result); - - } catch (Exception ex) { - throw new IOException("Failed to save notification categories as XML.", ex); - } - - } - - /** - * Retrieves the app's available push action categories from the XML file in which they - * should have been installed on the first load. - * @param context - * @return - * @throws IOException - */ - private static PushActionCategory[] getInstalledPushActionCategories(Context context) throws IOException { - // NOTE: This method may be called from the PushReceiver when the app isn't running so we can't access - // the main activity context, display properties, or any CN1 stuff. Just native android - - File categoriesFile = new File(context.getFilesDir().getAbsolutePath() + "/" + FILE_NAME_NOTIFICATION_CATEGORIES); - if (!categoriesFile.exists()) { - return new PushActionCategory[0]; - } - javax.xml.parsers.DocumentBuilderFactory docFactory = javax.xml.parsers.DocumentBuilderFactory.newInstance(); - javax.xml.parsers.DocumentBuilder docBuilder; - try { - docBuilder = docFactory.newDocumentBuilder(); - } catch (ParserConfigurationException ex) { - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - throw new IOException("Faield to create document builder for creating notification categories XML document", ex); - } - org.w3c.dom.Document doc; - try { - doc = docBuilder.parse(context.openFileInput(FILE_NAME_NOTIFICATION_CATEGORIES)); - } catch (SAXException ex) { - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - throw new IOException("Failed to parse instaled push action categories", ex); - } - org.w3c.dom.Element root = doc.getDocumentElement(); - java.util.List out = new ArrayList(); - org.w3c.dom.NodeList l = root.getElementsByTagName("category"); - int len = l.getLength(); - for (int i=0; i actions = new ArrayList(); - org.w3c.dom.NodeList al = el.getElementsByTagName("action"); - int alen = al.getLength(); - for (int j=0; j= 23) { - return PendingIntent.getActivity(ctx, value, intent, FLAG_IMMUTABLE); - } else { - return PendingIntent.getActivity(ctx, value, intent, PendingIntent.FLAG_CANCEL_CURRENT); - } - } - - public static PendingIntent createMutablePendingIntent(Context ctx, int value, Intent intent) { - if (android.os.Build.VERSION.SDK_INT >= 23) { - return PendingIntent.getActivity(ctx, value, intent, FLAG_MUTABLE); - } else { - return PendingIntent.getActivity(ctx, value, intent, PendingIntent.FLAG_CANCEL_CURRENT); - } - } - - public static PendingIntent getPendingIntent(Context ctx, int value, Intent intent) { - if (android.os.Build.VERSION.SDK_INT >= 23) { - return PendingIntent.getService(ctx, value, intent, FLAG_IMMUTABLE); - } else { - return PendingIntent.getService(ctx, value, intent, PendingIntent.FLAG_CANCEL_CURRENT); - } - } - - public static PendingIntent getBroadcastPendingIntent(Context ctx, int value, Intent intent) { - if (android.os.Build.VERSION.SDK_INT >= 23) { - // PendingIntent.FLAG_IMMUTABLE - return PendingIntent.getBroadcast(ctx, value, intent, 67108864); - } else { - return PendingIntent.getBroadcast(ctx, value, intent, PendingIntent.FLAG_CANCEL_CURRENT); - } - } - - /** - * Adds actions to a push notification. This is called by the Push broadcast receiver probably before - * Codename One is initialized - * @param provider Reference to the app's main class which implements PushActionsProvider - * @param categoryId The category ID of the push notification. - * @param builder The builder for the push notification. - * @param targetIntent The target intent... this should go to the app's main Activity. - * @param context The current context (inside the Broadcast receiver). - * @throws IOException - */ - public static void addActionsToNotification(PushActionsProvider provider, String categoryId, NotificationCompat.Builder builder, Intent targetIntent, Context context) throws IOException { - // NOTE: THis will likely run when the main activity isn't running so we won't have - // access to any display properties... just native Android APIs will be accessible. - - PushActionCategory category = null; - PushActionCategory[] categories; - if (provider != null) { - categories = provider.getPushActionCategories(); - } else { - categories = getInstalledPushActionCategories(context); - } - for (PushActionCategory candidateCategory : categories) { - if (categoryId.equals(candidateCategory.getId())) { - category = candidateCategory; - break; - } - } - if (category == null) { - return; - } - - int requestCode = 1; - for (PushAction action : category.getActions()) { - Intent newIntent = (Intent)targetIntent.clone(); - newIntent.putExtra("pushActionId", action.getId()); - PendingIntent contentIntent = createMutablePendingIntent(context, requestCode++, newIntent); - try { - int iconId; - try { - iconId = Integer.parseInt(action.getIcon()); - } catch (NumberFormatException ex) { - iconId = 0; - } - if (ActionWrapper.BuilderWrapper.isSupported()) { - // We need to take this abstracted "wrapper" approach because the Action.Builder class, and RemoteInput class - // aren't available until API 22. - // These classes use reflection to provide support for these classes safely. - ActionWrapper.BuilderWrapper actionBuilder = new ActionWrapper.BuilderWrapper(iconId, action.getTitle(), contentIntent); - if (action.getTextInputPlaceholder() != null && RemoteInputWrapper.isSupported()) { - RemoteInputWrapper.BuilderWrapper remoteInputBuilder = new RemoteInputWrapper.BuilderWrapper(action.getId()+"$Result"); - remoteInputBuilder.setLabel(action.getTextInputPlaceholder()); - - RemoteInputWrapper remoteInput = remoteInputBuilder.build(); - actionBuilder.addRemoteInput(remoteInput); - } - ActionWrapper actionWrapper = actionBuilder.build(); - new NotificationCompatWrapper.BuilderWrapper(builder).addAction(actionWrapper); - } else { - builder.addAction(iconId, action.getTitle(), contentIntent); - } - } catch (Exception ex) { - ex.printStackTrace(); - } - } - - } - - public static void firePendingPushes(final PushCallback c, final Context a) { - try { - if(c != null) { - InputStream i = a.openFileInput("CN1$AndroidPendingNotifications"); - if(i == null) { - return; - } - DataInputStream is = new DataInputStream(i); - int count = is.readByte(); - for(int iter = 0 ; iter < count ; iter++) { - boolean hasType = is.readBoolean(); - String actualType = null; - if(hasType) { - actualType = is.readUTF(); - } - final String t; - final String b; - final String category; - final String image; - if ("99".equals(actualType)) { - // This was a rich push - Map vals = splitQuery(is.readUTF()); - t = vals.get("type"); - b = vals.get("body"); - category = vals.get("category"); - image = vals.get("image"); - } else { - t = actualType; - b = is.readUTF(); - category = null; - image = null; - } - long s = is.readLong(); - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - Display.getInstance().setProperty("pendingPush", "true"); - Display.getInstance().setProperty("pushType", t); - initPushContent(b, image, t, category, a); - if(t != null && ("3".equals(t) || "6".equals(t))) { - String[] a = b.split(";"); - c.push(a[0]); - c.push(a[1]); - } else if (t != null && ("101".equals(t))) { - c.push(b.substring(b.indexOf(" ")+1)); - } else { - c.push(b); - } - Display.getInstance().setProperty("pendingPush", null); - } - }); - } - a.deleteFile("CN1$AndroidPendingNotifications"); - } - } catch(IOException err) { - } - } - - public static String[] getPendingPush(String type, Context a) { - InputStream i = null; - try { - i = a.openFileInput("CN1$AndroidPendingNotifications"); - if (i == null) { - return null; - } - DataInputStream is = new DataInputStream(i); - int count = is.readByte(); - Vector v = new Vector(); - for (int iter = 0; iter < count; iter++) { - boolean hasType = is.readBoolean(); - String actualType = null; - if (hasType) { - actualType = is.readUTF(); - } - - final String t; - final String b; - if ("99".equals(actualType)) { - // This was a rich push - Map vals = splitQuery(is.readUTF()); - t = vals.get("type"); - b = vals.get("body"); - //category = vals.get("category"); - //image = vals.get("image"); - } else { - t = actualType; - b = is.readUTF(); - //category = null; - //image = null; - } - long s = is.readLong(); - if(t != null && ("3".equals(t) || "6".equals(t))) { - String[] m = b.split(";"); - v.add(m[0]); - } else if(t != null && "4".equals(t)){ - String[] m = b.split(";"); - v.add(m[1]); - } else if(t != null && "2".equals(t)){ - continue; - }else if (t != null && "101".equals(t)) { - v.add(b.substring(b.indexOf(" ")+1)); - }else{ - v.add(b); - } - } - String [] retVal = new String[v.size()]; - for (int j = 0; j < retVal.length; j++) { - retVal[j] = (String)v.get(j); - } - return retVal; - - } catch (Exception ex) { - ex.printStackTrace(); - } finally { - try { - if(i != null){ - i.close(); - } - } catch (IOException ex) { - } - } - return null; - } - - private static AndroidImplementation instance; - private static final String INTENT_PROPERTY_PREFIX = "android.intent."; - private static final String INTENT_EXTRA_PROPERTY_PREFIX = "android.intent.extra."; - private static final Set intentPropertyKeys = new HashSet(); - private static final Object intentPropertyLock = new Object(); - private static Intent lastPublishedIntent; - - public static AndroidImplementation getInstance() { - return instance; - } - - public static void clearAppArg() { - if (instance != null) { - instance.setAppArg(null); - clearIntentProperties(); - } - } - - /// Delivers a link that arrived at an already-running activity, so the - /// router sees it on Android as it already does on iOS. - /// - /// The two ports were asymmetric here, and silently so. iOS routes every - /// deep link through `Display.setProperty("AppArg", url)`, which fires - /// [com.codename1.router.Navigation#dispatchExternalUrl]. Android's - /// `onNewIntent` only stored the intent, and [#getAppArg] then derived - /// the value lazily through the implementation's own setter -- so - /// `setProperty` never ran and the router never fired. Anything built on - /// `@Route` therefore worked on iOS and did nothing on Android, which - /// reads as a feature that "just doesn't convert" on the platform rather - /// than as a bug. - /// - /// Deliberately narrow. Only `ACTION_VIEW` with an http or https scheme - /// goes through here; `EXTRA_TEXT` shares, `content://` attachments and - /// `EXTRA_STREAM` payloads keep their existing lazy path. Dispatching for - /// every intent would double-fire against the `setAppArg` inside - /// [#getAppArg] and would change behaviour for every share-target - /// application in the field. - /// - /// #### Parameters - /// - /// - `intent`: the intent delivered to the running activity - static void dispatchNewIntentUrl(Intent intent) { - if (intent == null || instance == null || !Display.isInitialized()) { - return; - } - try { - if (!Intent.ACTION_VIEW.equals(intent.getAction())) { - return; - } - android.net.Uri data = intent.getData(); - if (data == null) { - return; - } - String scheme = data.getScheme(); - if (!"http".equals(scheme) && !"https".equals(scheme)) { - return; - } - // Cleared first so the value below is what getAppArg() reports, - // rather than whatever the previous intent left cached. - instance.setAppArg(null); - clearIntentProperties(); - // The intent is stored UNMODIFIED, and the url is marked as delivered by - // remembering the intent's identity instead of by erasing its data. - // - // Two earlier shapes were both wrong. Clearing the data on the intent - // passed in broke the ordinary way to extend onNewIntent() -- - // super.onNewIntent(intent) followed by the subclass reading - // intent.getData(), which had just been nulled underneath it. Storing a - // data-less COPY fixed that one and broke two more readers: the - // documented `android.intent.data` property is published from whatever - // the activity has stored, and native integrations read - // getActivity().getIntent().getData() after onNewIntent(). Both saw a - // warm deep link as no deep link at all while cold links still carried - // it -- an asymmetry an application has no way to work around. - // - // What actually has to be suppressed is narrower than the data: only - // getAppArg()'s rebuilding of the url from the stored intent, because - // CodenameOneActivity.onStop() clears the app arg and the next read - // after a resume would otherwise report the same deep link a second - // time and open one tapped invite twice. - getActivity().setIntent(intent); - markAppArgDelivered(intent); - // Published here rather than left to getAppArg(), since the properties - // for the previous intent were just cleared and the reader that used to - // repopulate them lazily is exactly the one now suppressed. - publishIntentProperties(getActivity(), intent); - Display.getInstance().setProperty("AppArg", data.toString()); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - } - - /// Identity of the intent whose url [#dispatchNewIntentUrl] already delivered as - /// the app arg. Weak because it needs to outlive nothing: the activity holds the - /// intent, and once it stores a different one this reference is free to go. - private static java.lang.ref.WeakReference deliveredAppArgIntent; - - private static void markAppArgDelivered(Intent intent) { - synchronized (intentPropertyLock) { - deliveredAppArgIntent = new java.lang.ref.WeakReference(intent); - } - } - - private static boolean isAppArgDelivered(Intent intent) { - synchronized (intentPropertyLock) { - return deliveredAppArgIntent != null && deliveredAppArgIntent.get() == intent; - } - } - - private static void clearIntentProperties() { - synchronized (intentPropertyLock) { - if (Display.isInitialized()) { - for (String key : new ArrayList(intentPropertyKeys)) { - Display.getInstance().setProperty(key, null); - } - } - intentPropertyKeys.clear(); - lastPublishedIntent = null; - } - } - - private static void publishIntentProperties(Activity activity, Intent intent) { - if (intent == null) { - return; - } - - synchronized (intentPropertyLock) { - if (intent == lastPublishedIntent) { - return; - } - - Map nextProperties = new HashMap(); - nextProperties.put(INTENT_PROPERTY_PREFIX + "action", intent.getAction()); - nextProperties.put(INTENT_PROPERTY_PREFIX + "data", intent.getDataString()); - nextProperties.put(INTENT_PROPERTY_PREFIX + "type", intent.getType()); - - // Only getCallingPackage() is a verified caller identity. Referrer values are caller-controlled. - String callerPackage = activity.getCallingPackage(); - nextProperties.put(INTENT_PROPERTY_PREFIX + "caller", callerPackage); - nextProperties.put(INTENT_PROPERTY_PREFIX + "caller.verified", callerPackage != null ? "true" : "false"); - - Bundle extras = intent.getExtras(); - if (extras != null) { - for (String key : extras.keySet()) { - Object value = extras.get(key); - String propertyKey = key.startsWith(INTENT_EXTRA_PROPERTY_PREFIX) ? key : INTENT_EXTRA_PROPERTY_PREFIX + key; - nextProperties.put(propertyKey, value == null ? null : String.valueOf(value)); - } - } - - if (Display.isInitialized()) { - ArrayList keysToRemove = new ArrayList(); - for (String key : intentPropertyKeys) { - if (!nextProperties.containsKey(key)) { - keysToRemove.add(key); - } - } - for (String key : keysToRemove) { - Display.getInstance().setProperty(key, null); - intentPropertyKeys.remove(key); - } - for (Map.Entry entry : nextProperties.entrySet()) { - Display.getInstance().setProperty(entry.getKey(), entry.getValue()); - intentPropertyKeys.add(entry.getKey()); - } - } else { - intentPropertyKeys.clear(); - intentPropertyKeys.addAll(nextProperties.keySet()); - } - - lastPublishedIntent = intent; - } - } - - public static Context getContext() { - Context out = getActivity(); - if (out != null) { - return out; - } - return context; - } - - public void setContext(Context c) { - context = c; - } - - @Override - public void init(Object m) { - // NOTE: Do not explicitly set the PlayServices instance to anything other than - // an instance of the base PlayServices class. The Build Server will automatically - // swap this for the appropriate subclass depending on the playServicesVersion of - // the build. - PlayServices.setInstance(new PlayServices()); // <---- DO NOT CHANGE - Build server will replace with appropriate subclass instance - if (m instanceof CodenameOneActivity) { - setContext(null); - setActivity((CodenameOneActivity) m); - } else { - setActivity(null); - setContext((Context)m); - } - // The nearby bridge is cached for the life of the process while - // Android recreates the activity freely -- a configuration change, - // or "Don't keep activities". An association chooser opened by the - // old activity delivers its result to the NEW one, where the - // backend's result listener is not installed, so the association - // resource never settled and every later association answered BUSY. - // Told here because this is the one place that knows it changed. - if (nearbyBridge != null) { - nearbyBridge.onActivityChanged(); - } - - instance = this; - if(getActivity() != null && getActivity().hasUI()){ - if (!hasActionBar()) { - try { - getActivity().requestWindowFeature(Window.FEATURE_NO_TITLE); - } catch (Exception e) { - com.codename1.io.Log.p("requestWindowFeature FEATURE_NO_TITLE threw exception: " + e.toString()); - } - } else { - getActivity().invalidateOptionsMenu(); - try { - getActivity().requestWindowFeature(Window.FEATURE_ACTION_BAR); - getActivity().requestWindowFeature(Window.FEATURE_PROGRESS); - - if(android.os.Build.VERSION.SDK_INT >= 21){ - //WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS - getActivity().getWindow().addFlags(-2147483648); - } - } catch (Exception e) { - //Log.d("Codename One", "No idea why this throws a Runtime Error", e); - } - NotifyActionBar notify = new NotifyActionBar(getActivity(), false); - notify.run(); - } - - if(statusBarHidden) { - getActivity().getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE - | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN); - getActivity().getWindow().setStatusBarColor(android.graphics.Color.TRANSPARENT); - } - - if(Display.getInstance().getProperty("StatusbarHidden", "").equals("true")){ - getActivity().getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN); - } - - if(Display.getInstance().getProperty("KeepScreenOn", "").equals("true")){ - getActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); - } - - if(Display.getInstance().getProperty("DisableScreenshots", "").equals("true")){ - getActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE); - } - - if (m instanceof CodenameOneActivity) { - ((CodenameOneActivity) m).setDefaultIntentResultListener(this); - ((CodenameOneActivity) m).setIntentResultListener(this); - } - - /** - * translate our default font height depending on the screen density. - * this is required for new high resolution devices. otherwise - * everything looks awfully small. - * - * we use our default font height value of 16 and go from there. i - * thought about using new Paint().getTextSize() for this value but if - * some new version of android suddenly returns values already tranlated - * to the screen then we might end up with too large fonts. the - * documentation is not very precise on that. - */ - final int defaultFontPixelHeight = 16; - this.defaultFontHeight = this.translatePixelForDPI(defaultFontPixelHeight); - - - this.defaultFont = (CodenameOneTextPaint) ((NativeFont) this.createFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_MEDIUM)).font; - Display.getInstance().setTransitionYield(-1); - - initSurface(); - /** - * devices are extremely sensitive so dragging should start a little - * later than suggested by default implementation. - */ - this.setDragStartPercentage(1); - VirtualKeyboardInterface vkb = new AndroidKeyboard(this); - Display.getInstance().registerVirtualKeyboard(vkb); - Display.getInstance().setDefaultVirtualKeyboard(vkb); - - InPlaceEditView.endEdit(); - - getActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN); - - if (nativePeers.size() > 0) { - for (int i = 0; i < nativePeers.size(); i++) { - ((AndroidImplementation.AndroidPeer) nativePeers.elementAt(i)).init(); - } - } - } else { - /** - * translate our default font height depending on the screen density. - * this is required for new high resolution devices. otherwise - * everything looks awfully small. - * - * we use our default font height value of 16 and go from there. i - * thought about using new Paint().getTextSize() for this value but if - * some new version of android suddenly returns values already tranlated - * to the screen then we might end up with too large fonts. the - * documentation is not very precise on that. - */ - final int defaultFontPixelHeight = 16; - this.defaultFontHeight = this.translatePixelForDPI(defaultFontPixelHeight); - - - this.defaultFont = (CodenameOneTextPaint) ((NativeFont) this.createFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_MEDIUM)).font; - } - HttpURLConnection.setFollowRedirects(false); - CookieHandler.setDefault(null); - VideoCaptureConstraints.init(new AndroidVideoCaptureConstraintsCompiler()); - } - - - - @Override - public boolean isInitialized(){ -// Removing the check for null view to prevent strange things from happening when -// calling from a Service context. -// if(getActivity() != null && myView == null){ -// //if the view is null deinitialize the Display -// if(super.isInitialized()){ -// syncDeinitialize(); -// } -// return false; -// } - return super.isInitialized(); - } - - /** - * Reinitializes CN1. - * @param i Context to initialize it with. - * - * @see #startContext(Context) - */ - private static void reinit(Object i) { - if (instance != null && ((i instanceof CodenameOneActivity) || instance.myView == null)) { - instance.init(i); - } - Display.init(i); - - // This is a hack to fix an issue that caused the screen to appear blank when - // the app is loaded from memory after being unloaded. - - // This issue only seems to occur when the Activity had been unloaded - // so to test this you'll need to check the "Don't keep activities" checkbox under/ - // Developer options. - // Developer options. - Display.getInstance().callSerially(new Runnable() { - public void run() { - Display.getInstance().invokeAndBlock(new Runnable(){ public void run(){ - Util.sleep(50); - }}); - if (!Display.isInitialized() || Display.getInstance().isMinimized()) { - return; - } - Form cur = Display.getInstance().getCurrent(); - if (cur != null) { - cur.forceRevalidate(); - } - } - - }); - } - - private static class InvalidateOptionsMenuImpl implements Runnable { - private Activity activity; - - public InvalidateOptionsMenuImpl(Activity activity) { - this.activity = activity; - } - - @Override - public void run() { - activity.invalidateOptionsMenu(); - } - } - - @Override - public Boolean isDarkMode() { - try { - int nightModeFlags = getActivity().getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; - switch (nightModeFlags) { - case Configuration.UI_MODE_NIGHT_YES: - return true; - case Configuration.UI_MODE_NIGHT_NO: - return false; - default: - return null; - } - } catch(Throwable t) { - return null; - } - } - - @Override - public boolean isLargerTextEnabled() { - return getLargerTextScale() > 1.0f; - } - - @Override - public float getLargerTextScale() { - try { - Configuration configuration; - if (getActivity() != null) { - configuration = getActivity().getResources().getConfiguration(); - } else { - configuration = getContext().getResources().getConfiguration(); - } - return configuration.fontScale; - } catch (Throwable t) { - return 1.0f; - } - } - - - private boolean hasActionBar() { - return android.os.Build.VERSION.SDK_INT >= 11; - } - - public int translatePixelForDPI(int pixel) { - return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, pixel, - getContext().getResources().getDisplayMetrics()); - } - - /** - * Returns the platform EDT thread priority - */ - public int getEDTThreadPriority(){ - return Thread.NORM_PRIORITY; - } - - /// Android reports this directly as DisplayMetrics.density, so there is no - /// need to make callers derive it from the density bucket -- the bucket is a - /// coarse DPI band and rounds to a different number than the scale the - /// platform itself lays out with. - /// - /// Read the same way getDeviceDensity does, preferring the activity's own - /// display, because a multi-display device can have a different scale per - /// display and the resources copy is the default one. - @Override - public float getDevicePixelRatio() { - DisplayMetrics metrics = new DisplayMetrics(); - if (getActivity() != null) { - getActivity().getWindowManager().getDefaultDisplay().getMetrics(metrics); - } else if (getContext() != null) { - metrics = getContext().getResources().getDisplayMetrics(); - } else { - return super.getDevicePixelRatio(); - } - // 0 means "not reported", which is what the portable contract expects. - return metrics.density > 0 ? metrics.density : super.getDevicePixelRatio(); - } - - @Override - public int getDeviceDensity() { - DisplayMetrics metrics = new DisplayMetrics(); - if (getActivity() != null) { - getActivity().getWindowManager().getDefaultDisplay().getMetrics(metrics); - } else { - metrics = getContext().getResources().getDisplayMetrics(); - } - - int dpi = metrics.densityDpi; - if (dpi < DisplayMetrics.DENSITY_MEDIUM) { - return Display.DENSITY_LOW; - } - if (dpi < 213) { - return Display.DENSITY_MEDIUM; - } - // 213 == TV - if (dpi <= DisplayMetrics.DENSITY_HIGH) { - return Display.DENSITY_HIGH; - } - if (dpi < 400) { - return Display.DENSITY_VERY_HIGH; - } - if (dpi < 560) { - return Display.DENSITY_HD; - } - if (dpi <= 640) { - return Display.DENSITY_2HD; - } - return Display.DENSITY_4K; - } - - public static boolean isImmersive() { - if (getActivity() == null) { - return false; - } - return isImmersive(getActivity().getWindow()); - } - public static boolean isImmersive(Window window) { - if (Build.VERSION.SDK_INT >= 35) { - // Android 15+ is always immersive (overlay mode by default) - return true; - } - // On Android 34 and below, we can't detect decorFitsSystemWindows - // reliably at runtime. So the app must make the decision explicitly. - return false; - } - public static Rect getSystemBarInsets(final View rootView) { - final Rect result = new Rect(0, 0, 0, 0); - try { - Object insets = View.class - .getMethod("getRootWindowInsets") - .invoke(rootView); - if (insets == null) return result; - // Get android.view.WindowInsets$Type.systemBars() - Class typeClass = Class.forName("android.view.WindowInsets$Type"); - int systemBarsMask = ((Integer) typeClass - .getMethod("systemBars") - .invoke(null)).intValue(); - // Call insets.getInsets(int) - Object insetsObject = insets.getClass() - .getMethod("getInsets", new Class[]{int.class}) - .invoke(insets, new Object[]{systemBarsMask}); - if (insetsObject == null) return result; - Class insetsClass = insetsObject.getClass(); - int left = ((Integer) insetsClass.getField("left").get(insetsObject)).intValue(); - int top = ((Integer) insetsClass.getField("top").get(insetsObject)).intValue(); - int right = ((Integer) insetsClass.getField("right").get(insetsObject)).intValue(); - int bottom = ((Integer) insetsClass.getField("bottom").get(insetsObject)).intValue(); - // Include mandatory gesture insets (e.g. gesture navigation handle area). - // Some devices expose a larger interaction-protected bottom region here - // than in plain system bar insets. - try { - int mandatoryGesturesMask = ((Integer) typeClass - .getMethod("mandatorySystemGestures") - .invoke(null)).intValue(); - Object mandatoryInsetsObject = insets.getClass() - .getMethod("getInsets", new Class[]{int.class}) - .invoke(insets, new Object[]{mandatoryGesturesMask}); - if (mandatoryInsetsObject != null) { - Class mandatoryInsetsClass = mandatoryInsetsObject.getClass(); - left = Math.max(left, ((Integer) mandatoryInsetsClass.getField("left").get(mandatoryInsetsObject)).intValue()); - top = Math.max(top, ((Integer) mandatoryInsetsClass.getField("top").get(mandatoryInsetsObject)).intValue()); - right = Math.max(right, ((Integer) mandatoryInsetsClass.getField("right").get(mandatoryInsetsObject)).intValue()); - bottom = Math.max(bottom, ((Integer) mandatoryInsetsClass.getField("bottom").get(mandatoryInsetsObject)).intValue()); - } - } catch (Throwable t) { - // Ignore if mandatory gesture insets are unavailable. - } - result.set(left, top, right, bottom); - } catch (Throwable t) { - t.printStackTrace(); // Optional: log this or suppress if expected - } - return result; - } - - - public Rectangle getDisplaySafeArea(Rectangle rect) { - if (rect == null) { - rect = new Rectangle(); - } - if (getProperty("android.useSafeAreaInsets", "true").equals("false")) { - return super.getDisplaySafeArea(rect); - } - if (this.myView != null) { - rect.setBounds( - this.myView.getSafeAreaInsets().left, - this.myView.getSafeAreaInsets().top, - getDisplayWidth() - this.myView.getSafeAreaInsets().right - this.myView.getSafeAreaInsets().left, - getDisplayHeight() - this.myView.getSafeAreaInsets().top - this.myView.getSafeAreaInsets().bottom - ); - return rect; - } - - return super.getDisplaySafeArea(rect); - } - - /** - * A status flag to indicate that CN1 is in the process of deinitializing. - */ - private static boolean deinitializing; - private static boolean deinitializingEdt; - - public static void syncDeinitialize() { - if (deinitializingEdt){ - return; - } - deinitializingEdt = true; // This will get unset in {@link #deinitialize()} - deinitializing = true; - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - Display.deinitialize(); - deinitializingEdt = false; - } - }); - } - - public void deinitialize() { - //activity.getWindowManager().removeView(relativeLayout); - super.deinitialize(); - if (getActivity() != null) { - - Runnable r = new Runnable() { - public void run() { - synchronized (AndroidImplementation.this) { - if (!deinitializing) { - return; - } - deinitializing = false; - } - if (nativePeers.size() > 0) { - for (int i = 0; i < nativePeers.size(); i++) { - ((AndroidImplementation.AndroidPeer) nativePeers.elementAt(i)).deinit(); - } - } - if (accessibilityProvider != null) { - accessibilityProvider.dispose(); - accessibilityProvider = null; - } - if (relativeLayout != null) { - relativeLayout.removeAllViews(); - } - relativeLayout = null; - myView = null; - } - }; - - if (Looper.getMainLooper().getThread() == Thread.currentThread()) { - deinitializing = true; - r.run(); - } else { - deinitializing = true; - getActivity().runOnUiThread(r); - } - } else { - deinitializing = false; - } - } - - /** - * init view. a lot of back and forth between this thread and the UI thread. - */ - private void initSurface() { - if (getActivity() != null && myView == null) { - relativeLayout= new RelativeLayout(getActivity()); - relativeLayout.setLayoutParams(new RelativeLayout.LayoutParams( - RelativeLayout.LayoutParams.FILL_PARENT, - RelativeLayout.LayoutParams.FILL_PARENT)); - relativeLayout.setFocusable(false); - - getActivity().getWindow().setBackgroundDrawable(null); - if(asyncView) { - if(android.os.Build.VERSION.SDK_INT < 14){ - myView = new AndroidSurfaceView(getActivity(), AndroidImplementation.this); - } else { - int hardwareAcceleration = 16777216; - getActivity().getWindow().setFlags(hardwareAcceleration, hardwareAcceleration); - myView = new AndroidAsyncView(getActivity(), AndroidImplementation.this); - } - } else { - int hardwareAcceleration = 16777216; - getActivity().getWindow().setFlags(hardwareAcceleration, hardwareAcceleration); - superPeerMode = true; - myView = new AndroidAsyncView(getActivity(), AndroidImplementation.this); - } - myView.getAndroidView().setVisibility(View.VISIBLE); - // Makes the surface an Android drop target, so a drag from another application -- - // or from elsewhere in this one -- reaches the components that asked for it. - AndroidNativeDragAndDrop.install(this, myView.getAndroidView()); - - if (hideOverlayWindowsRequested) { - setHideOverlayWindows(true); - } - - if (Build.VERSION.SDK_INT >= 16) { - final View semanticHost = myView.getAndroidView(); - accessibilityProvider = new AndroidAccessibilityProvider(semanticHost, this); - semanticHost.setAccessibilityDelegate(new View.AccessibilityDelegate() { - @Override - public android.view.accessibility.AccessibilityNodeProvider getAccessibilityNodeProvider(View host) { - return accessibilityProvider; - } - }); - } - - relativeLayout.addView(myView.getAndroidView()); - myView.getAndroidView().setVisibility(View.VISIBLE); - - int id = getActivity().getResources().getIdentifier("main", "layout", getActivity().getApplicationInfo().packageName); - RelativeLayout root = (RelativeLayout) LayoutInflater.from(getActivity()).inflate(id, null); - if(viewAbove != null) { - RelativeLayout.LayoutParams lp = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT); - lp.addRule(RelativeLayout.ALIGN_PARENT_TOP); - lp.addRule(RelativeLayout.CENTER_HORIZONTAL); - - RelativeLayout.LayoutParams lp2 = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT); - lp2.setMargins(0, 0, aboveSpacing, 0); - relativeLayout.setLayoutParams(lp2); - root.addView(viewAbove, lp); - } - root.addView(relativeLayout); - if(viewBelow != null) { - RelativeLayout.LayoutParams lp = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT); - lp.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM); - lp.addRule(RelativeLayout.CENTER_HORIZONTAL); - - RelativeLayout.LayoutParams lp2 = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT); - lp2.setMargins(0, 0, 0, belowSpacing); - relativeLayout.setLayoutParams(lp2); - root.addView(viewBelow, lp); - } - getActivity().setContentView(root); - if (!myView.getAndroidView().hasFocus()) { - myView.getAndroidView().requestFocus(); - } - } - } - - @Override - public void confirmControlView() { - if(myView == null){ - return; - } - myView.getAndroidView().setVisibility(View.VISIBLE); - //ugly workaround for a bug where on some android versions the async view - //came back black from the background. - if(myView instanceof AndroidAsyncView){ - final AndroidAsyncView finalView = (AndroidAsyncView)myView; - new Thread(new Runnable() { - @Override - public void run() { - Util.sleep(1000); - finalView.setPaintViewOnBuffer(false); - } - }).start(); - } - } - - public void hideNotifyPublic() { - super.hideNotify(); - saveTextEditingState(); - } - - public void showNotifyPublic() { - super.showNotify(); - } - - @Override - public boolean isMinimized() { - return getActivity() == null || ((CodenameOneActivity)getActivity()).isBackground(); - } - - @Override - public boolean minimizeApplication() { - Activity activity = getActivity(); - if (activity != null) { - // Move the app task to background instead of explicitly launching HOME. - // Some OEM launchers are no longer exported and can throw SecurityException - // when invoked via an ACTION_MAIN/CATEGORY_HOME intent. - if (activity.moveTaskToBack(true)) { - return true; - } - } - - // Fallback for edge-cases where there is no active activity/task. - Intent startMain = new Intent(Intent.ACTION_MAIN); - startMain.addCategory(Intent.CATEGORY_HOME); - startMain.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - startMain.putExtra("WaitForResult", Boolean.FALSE); - try { - getContext().startActivity(startMain); - return true; - } catch (SecurityException ex) { - Log.e("Codename One", "Unable to minimize application", ex); - return false; - } - } - - @Override - public void restoreMinimizedApplication() { - if (getActivity() != null) { - Intent i = new Intent(getActivity(), getActivity().getClass()); - i.setAction(Intent.ACTION_MAIN); - i.addCategory(Intent.CATEGORY_LAUNCHER); - getContext().startActivity(i); - } - } - - @Override - public boolean isNativeInputImmediate() { - return true; - } - - public void editString(final Component cmp, int maxSize, final int constraint, String text, int keyCode) { - InPlaceEditView.edit(this, cmp, constraint); - } - - protected boolean editInProgress() { - return InPlaceEditView.isEditing(); - } - - @Override - public boolean isAsyncEditMode() { - return asyncEditMode; - } - - void setAsyncEditMode(boolean async) { - asyncEditMode = async; - } - - void callHideTextEditor() { - super.hideTextEditor(); - } - - @Override - public void hideTextEditor() { - InPlaceEditView.hideActiveTextEditor(); - } - - @Override - public boolean isNativeEditorVisible(Component c) { - return super.isNativeEditorVisible(c) && !InPlaceEditView.isActiveTextEditorHidden(); - } - - public static void stopEditing() { - stopEditing(false); - } - - public static void stopEditing(final boolean forceVKBClose){ - if (getActivity() == null) { - return; - } - final boolean[] flag = new boolean[]{false}; - - // InPlaceEditView.endEdit must be called from the UI thread. - // We must wait for this call to be over, otherwise Codename One's painting - // of the next form will be garbled. - getActivity().runOnUiThread(new Runnable() { - @Override - public void run() { - // Must be called from the UI thread - InPlaceEditView.stopEdit(forceVKBClose); - - synchronized (flag) { - flag[0] = true; - flag.notify(); - } - } - }); - - if (!flag[0]) { - // Wait (if necessary) for the asynchronous runOnUiThread to do its work - synchronized (flag) { - - try { - flag.wait(); - } catch (InterruptedException e) { - } - } - } - } - - @Override - public void saveTextEditingState() { - stopEditing(true); - } - - @Override - public void stopTextEditing() { - saveTextEditingState(); - } - - @Override - public void stopTextEditing(final Runnable onFinish) { - final Form f = Display.getInstance().getCurrent(); - f.addSizeChangedListener(new ActionListener() { - @Override - public void actionPerformed(ActionEvent evt) { - f.removeSizeChangedListener(this); - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - onFinish.run(); - } - }); - } - }); - stopEditing(true); - } - - - protected void setLastSizeChangedWH(int w, int h) { - // not used? - //this.lastSizeChangeW = w; - //this.lastSizeChangeH = h; - } - - /*@Override - public boolean handleEDTException(final Throwable err) { - - final boolean[] messageComplete = new boolean[]{false}; - - Log.e("Codename One", "Err on EDT", err); - - activity.runOnUiThread(new Runnable() { - @Override - public void run() { - UIManager m = UIManager.getInstance(); - final FrameLayout frameLayout = new FrameLayout( - activity); - final TextView textView = new TextView( - activity); - textView.setGravity(Gravity.CENTER); - frameLayout.addView(textView, new FrameLayout.LayoutParams( - FrameLayout.LayoutParams.FILL_PARENT, - FrameLayout.LayoutParams.WRAP_CONTENT)); - textView.setText("An internal application error occurred: " + err.toString()); - AlertDialog.Builder bob = new AlertDialog.Builder( - activity); - bob.setView(frameLayout); - bob.setTitle(""); - bob.setPositiveButton(m.localize("ok", "OK"), - new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface d, int which) { - d.dismiss(); - synchronized (messageComplete) { - messageComplete[0] = true; - messageComplete.notify(); - } - } - }); - AlertDialog editDialog = bob.create(); - editDialog.show(); - } - }); - - synchronized (messageComplete) { - if (messageComplete[0]) { - return true; - } - try { - messageComplete.wait(); - } catch (Exception ignored) { - ; - } - } - return true; - }*/ - - @Override - public InputStream getResourceAsStream(Class cls, String resource) { - try { - if (resource.startsWith("/")) { - resource = resource.substring(1); - } - return getContext().getAssets().open(resource); - } catch (IOException ex) { - Log.i("Codename One", "Resource not found: " + resource); - return null; - } - } - - @Override - protected void pointerPressed(final int x, final int y) { - super.pointerPressed(x, y); - } - - @Override - protected void pointerPressed(final int[] x, final int[] y) { - super.pointerPressed(x, y); - } - - @Override - protected void pointerReleased(final int x, final int y) { - super.pointerReleased(x, y); - } - - @Override - protected void pointerReleased(final int[] x, final int[] y) { - super.pointerReleased(x, y); - } - - @Override - protected void pointerDragged(int x, int y) { - super.pointerDragged(x, y); - } - - @Override - protected void pointerDragged(int[] x, int[] y) { - super.pointerDragged(x, y); - } - - @Override - protected void pointerHover(int x, int y) { - super.pointerHover(x, y); - } - - @Override - protected void pointerHover(int[] x, int[] y) { - super.pointerHover(x, y); - } - - @Override - protected void pointerHoverPressed(int x, int y) { - super.pointerHoverPressed(x, y); - } - - @Override - protected void pointerHoverPressed(int[] x, int[] y) { - super.pointerHoverPressed(x, y); - } - - @Override - protected void pointerHoverReleased(int x, int y) { - super.pointerHoverReleased(x, y); - } - - @Override - protected void pointerHoverReleased(int[] x, int[] y) { - super.pointerHoverReleased(x, y); - } - - @Override - protected int getDragAutoActivationThreshold() { - return 1000000; - } - - @Override - public void flushGraphics() { - if (myView != null) { - myView.flushGraphics(); - } - - } - - @Override - public void flushGraphics(int x, int y, int width, int height) { - this.tmprect.set(x, y, x + width, y + height); - if (myView != null) { - myView.flushGraphics(this.tmprect); - } - } - - @Override - public int charWidth(Object nativeFont, char ch) { - this.tmpchar[0] = ch; - float w = (nativeFont == null ? this.defaultFont - : (Paint) ((NativeFont) nativeFont).font).measureText(this.tmpchar, 0, 1); - if (w - (int) w > 0) { - return (int) (w + 1); - } - return (int) w; - } - - @Override - public int charsWidth(Object nativeFont, char[] ch, int offset, int length) { - float w = (nativeFont == null ? this.defaultFont - : (Paint) ((NativeFont) nativeFont).font).measureText(ch, offset, length); - if (w - (int) w > 0) { - return (int) (w + 1); - } - return (int) w; - } - - @Override - public int stringWidth(Object nativeFont, String str) { - float w = (nativeFont == null ? this.defaultFont - : (Paint) ((NativeFont) nativeFont).font).measureText(str); - if (w - (int) w > 0) { - return (int) (w + 1); - } - return (int) w; - } - - @Override - public void setNativeFont(Object graphics, Object font) { - if (font == null) { - font = this.defaultFont; - } - if (font instanceof NativeFont) { - ((AndroidGraphics) graphics).setFont((CodenameOneTextPaint) ((NativeFont) font).font); - } else { - ((AndroidGraphics) graphics).setFont((CodenameOneTextPaint) font); - } - } - - @Override - public int getHeight(Object nativeFont) { - CodenameOneTextPaint font = (nativeFont == null ? this.defaultFont - : (CodenameOneTextPaint) ((NativeFont) nativeFont).font); - if(font.fontHeight < 0) { - Paint.FontMetrics fm = font.getFontMetrics(); - font.fontHeight = (int)Math.ceil(fm.bottom - fm.top); - } - return font.fontHeight; - } - - @Override - public int getFontAscent(Object nativeFont) { - Paint font = (nativeFont == null ? this.defaultFont - : (Paint) ((NativeFont) nativeFont).font); - return -Math.round(font.getFontMetrics().ascent); - } - - @Override - public int getFontDescent(Object nativeFont) { - Paint font = (nativeFont == null ? this.defaultFont - : (Paint) ((NativeFont) nativeFont).font); - return Math.abs(Math.round(font.getFontMetrics().descent)); - } - - @Override - public boolean isBaselineTextSupported() { - return true; - } - - - - - - - public int getFace(Object nativeFont) { - if (nativeFont == null) { - return Font.FACE_SYSTEM; - } - return ((NativeFont) nativeFont).face; - } - - public int getStyle(Object nativeFont) { - if (nativeFont == null) { - return Font.STYLE_PLAIN; - } - return ((NativeFont) nativeFont).style; - } - - @Override - public int getSize(Object nativeFont) { - if (nativeFont == null) { - return Font.SIZE_MEDIUM; - } - return ((NativeFont) nativeFont).size; - } - - @Override - public boolean isTrueTypeSupported() { - return true; - } - - @Override - public boolean isNativeFontSchemeSupported() { - return true; - } - - private Typeface fontToRoboto(String fontName) { - if("native:MainThin".equals(fontName)) { - return Typeface.create("sans-serif-thin", Typeface.NORMAL); - } - if("native:MainLight".equals(fontName)) { - return Typeface.create("sans-serif-light", Typeface.NORMAL); - } - if("native:MainRegular".equals(fontName)) { - return Typeface.create("sans-serif", Typeface.NORMAL); - } - - if("native:MainBold".equals(fontName)) { - return Typeface.create("sans-serif-condensed", Typeface.BOLD); - } - - if("native:MainBlack".equals(fontName)) { - return Typeface.create("sans-serif-black", Typeface.BOLD); - } - - if("native:ItalicThin".equals(fontName)) { - return Typeface.create("sans-serif-thin", Typeface.ITALIC); - } - - if("native:ItalicLight".equals(fontName)) { - return Typeface.create("sans-serif-thin", Typeface.ITALIC); - } - - if("native:ItalicRegular".equals(fontName)) { - return Typeface.create("sans-serif", Typeface.ITALIC); - } - - if("native:ItalicBold".equals(fontName)) { - return Typeface.create("sans-serif-condensed", Typeface.BOLD_ITALIC); - } - - if("native:ItalicBlack".equals(fontName)) { - return Typeface.create("sans-serif-black", Typeface.BOLD_ITALIC); - } - - throw new IllegalArgumentException("Unsupported native font type: " + fontName); - } - - @Override - public Object loadTrueTypeFont(String fontName, String fileName) { - if(fontName.startsWith("native:")) { - Typeface t = fontToRoboto(fontName); - int fontStyle = com.codename1.ui.Font.STYLE_PLAIN; - if(t.isBold()) { - fontStyle |= com.codename1.ui.Font.STYLE_BOLD; - } - if(t.isItalic()) { - fontStyle |= com.codename1.ui.Font.STYLE_ITALIC; - } - CodenameOneTextPaint newPaint = new CodenameOneTextPaint(t); - newPaint.setAntiAlias(true); - newPaint.setSubpixelText(true); - return new NativeFont(com.codename1.ui.Font.FACE_SYSTEM, fontStyle, - com.codename1.ui.Font.SIZE_MEDIUM, newPaint, fileName, 0, 0); - } - Typeface t = Typeface.createFromAsset(getContext().getAssets(), fileName); - if(t == null) { - throw new RuntimeException("Font not found: " + fileName); - } - CodenameOneTextPaint newPaint = new CodenameOneTextPaint(t); - newPaint.setAntiAlias(true); - newPaint.setSubpixelText(true); - return new NativeFont(com.codename1.ui.Font.FACE_SYSTEM, - com.codename1.ui.Font.STYLE_PLAIN, com.codename1.ui.Font.SIZE_MEDIUM, newPaint, fileName, 0, 0); - } - - public static class NativeFont { - int face; - int style; - int size; - public Object font; - String fileName; - float height; - int weight; - - public NativeFont(int face, int style, int size, Object font, String fileName, float height, int weight) { - this(face, style, size, font); - this.fileName = fileName; - this.height = height; - this.weight = weight; - } - - public NativeFont(int face, int style, int size, Object font) { - this.face = face; - this.style = style; - this.size = size; - this.font = font; - } - - public boolean equals(Object o) { - if(o == null) { - return false; - } - NativeFont n = ((NativeFont)o); - if(fileName != null) { - return n.fileName != null && fileName.equals(n.fileName) && n.height == height && n.weight == weight; - } - return n.face == face && n.style == style && n.size == size && font.equals(n.font); - } - - public int hashCode() { - return face | style | size; - } - } - - /// Returns a copy of the given native font with its paint's letter spacing set - /// to the supplied value (Android letter spacing is in EM units, independent of - /// font size). Used by Style.letterSpacing so a per-UIID spacing -- matching the - /// Material text-appearance for each component -- is baked into the SAME paint - /// that does both measureText (layout) and drawText (render), keeping advances - /// consistent. Other ports get the default no-op. - @Override - public Object deriveTrueTypeFontWithLetterSpacing(Object font, float letterSpacing) { - NativeFont fnt = (NativeFont) font; - CodenameOneTextPaint copy = new CodenameOneTextPaint((CodenameOneTextPaint) fnt.font); - copy.setLetterSpacing(letterSpacing); - return new NativeFont(fnt.face, fnt.style, fnt.size, copy, fnt.fileName, fnt.height, fnt.weight); - } - - @Override - public Object deriveTrueTypeFont(Object font, float size, int weight) { - NativeFont fnt = (NativeFont)font; - CodenameOneTextPaint paint = (CodenameOneTextPaint)fnt.font; - paint.setAntiAlias(true); - Typeface type = paint.getTypeface(); - int fontstyle = Typeface.NORMAL; - if ((weight & Font.STYLE_BOLD) != 0 || type.isBold()) { - fontstyle |= Typeface.BOLD; - } - if ((weight & Font.STYLE_ITALIC) != 0 || type.isItalic()) { - fontstyle |= Typeface.ITALIC; - } - type = Typeface.create(type, fontstyle); - CodenameOneTextPaint newPaint = new CodenameOneTextPaint(type); - newPaint.setTextSize(size); - newPaint.setAntiAlias(true); - // preserve any letter spacing already configured on the source paint - newPaint.setLetterSpacing(paint.getLetterSpacing()); - NativeFont n = new NativeFont(com.codename1.ui.Font.FACE_SYSTEM, weight, com.codename1.ui.Font.SIZE_MEDIUM, newPaint, fnt.fileName, size, weight); - return n; - } - - @Override - public Object createFont(int face, int style, int size) { - Typeface typeface = null; - switch (face) { - case Font.FACE_MONOSPACE: - typeface = Typeface.MONOSPACE; - break; - default: - typeface = Typeface.DEFAULT; - break; - } - - int fontstyle = Typeface.NORMAL; - if ((style & Font.STYLE_BOLD) != 0) { - fontstyle |= Typeface.BOLD; - } - if ((style & Font.STYLE_ITALIC) != 0) { - fontstyle |= Typeface.ITALIC; - } - - - int height = this.defaultFontHeight; - int diff = height / 3; - - switch (size) { - case Font.SIZE_SMALL: - height -= diff; - break; - case Font.SIZE_LARGE: - height += diff; - break; - } - - Paint font = new CodenameOneTextPaint(Typeface.create(typeface, fontstyle)); - font.setAntiAlias(true); - font.setUnderlineText((style & Font.STYLE_UNDERLINED) != 0); - font.setTextSize(height); - return new NativeFont(face, style, size, font); - - } - - /** - * Loads a native font based on a lookup for a font name and attributes. - * Font lookup values can be separated by commas and thus allow fallback if - * the primary font isn't supported by the platform. - * - * @param lookup string describing the font - * @return the native font object - */ - public Object loadNativeFont(String lookup) { - try { - lookup = lookup.split(";")[0]; - int typeface = Typeface.NORMAL; - String familyName = lookup.substring(0, lookup.indexOf("-")); - String style = lookup.substring(lookup.indexOf("-") + 1, lookup.lastIndexOf("-")); - String size = lookup.substring(lookup.lastIndexOf("-") + 1, lookup.length()); - - if (style.equals("bolditalic")) { - typeface = Typeface.BOLD_ITALIC; - } else if (style.equals("italic")) { - typeface = Typeface.ITALIC; - } else if (style.equals("bold")) { - typeface = Typeface.BOLD; - } - Paint font = new CodenameOneTextPaint(Typeface.create(familyName, typeface)); - font.setAntiAlias(true); - font.setTextSize(Integer.parseInt(size)); - return new NativeFont(0, 0, 0, font); - } catch (Exception err) { - return null; - } - } - - /** - * Indicates whether loading a font by a string is supported by the platform - * - * @return true if the platform supports font lookup - */ - @Override - public boolean isLookupFontSupported() { - return true; - } - - @Override - public boolean isAntiAliasedTextSupported() { - return true; - } - - @Override - public void setAntiAliasedText(Object graphics, boolean a) { - android.graphics.Paint p = ((AndroidGraphics) graphics).getFont(); - if(p != null) { - p.setAntiAlias(a); - } - } - - @Override - public Object getDefaultFont() { - CodenameOneTextPaint paint = new CodenameOneTextPaint(this.defaultFont); - return new NativeFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_MEDIUM, paint); - } - - - private AndroidGraphics nullGraphics; - - private AndroidGraphics getNullGraphics() { - if (nullGraphics == null) { - Bitmap bitmap = Bitmap.createBitmap(getDisplayWidth()==0?100:getDisplayWidth(), getDisplayHeight()==0?100:getDisplayHeight(), - Bitmap.Config.ARGB_8888); - nullGraphics = (AndroidGraphics) this.getNativeGraphics(bitmap); - } - return nullGraphics; - } - - - @Override - public Object getNativeGraphics() { - if(myView != null){ - nullGraphics = null; - return myView.getGraphics(); - }else{ - return getNullGraphics(); - } - } - - @Override - public Object getNativeGraphics(Object image) { - AndroidGraphics g = new AndroidGraphics(this, new Canvas((Bitmap) image), true); - g.underlyingBitmap = (Bitmap) image; - g.setClip(0, 0, ((Bitmap)image).getWidth(), ((Bitmap)image).getHeight()); - return g; - } - - @Override - public void getRGB(Object nativeImage, int[] arr, int offset, int x, int y, - int width, int height) { - ((Bitmap) nativeImage).getPixels(arr, offset, width, x, y, width, - height); - } - - private int sampleSizeOverride = -1; - - @Override - public Object createImage(String path) throws IOException { - int IMAGE_MAX_SIZE = getDisplayHeight(); - if (exists(path)) { - Bitmap b = null; - try { - //Decode image size - BitmapFactory.Options o = new BitmapFactory.Options(); - o.inJustDecodeBounds = true; - o.inPreferredConfig = Bitmap.Config.ARGB_8888; - - InputStream fis = createFileInputStream(path); - BitmapFactory.decodeStream(fis, null, o); - fis.close(); - - int scale = 1; - if (o.outHeight > IMAGE_MAX_SIZE || o.outWidth > IMAGE_MAX_SIZE) { - scale = (int) Math.pow(2, (int) Math.round(Math.log(IMAGE_MAX_SIZE / (double) Math.max(o.outHeight, o.outWidth)) / Math.log(0.5))); - } - - //Decode with inSampleSize - BitmapFactory.Options o2 = new BitmapFactory.Options(); - o2.inPreferredConfig = Bitmap.Config.ARGB_8888; - - if(sampleSizeOverride != -1) { - o2.inSampleSize = sampleSizeOverride; - } else { - String sampleSize = Display.getInstance().getProperty("android.sampleSize", null); - if(sampleSize != null) { - o2.inSampleSize = Integer.parseInt(sampleSize); - } else { - o2.inSampleSize = scale; - } - } - o2.inPurgeable = true; - o2.inInputShareable = true; - fis = createFileInputStream(path); - b = BitmapFactory.decodeStream(fis, null, o2); - fis.close(); - - //fix rotation - ExifInterface exif = new ExifInterface(removeFilePrefix(path)); - int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); - - int angle = 0; - switch (orientation) { - case ExifInterface.ORIENTATION_ROTATE_90: - angle = 90; - break; - case ExifInterface.ORIENTATION_ROTATE_180: - angle = 180; - break; - case ExifInterface.ORIENTATION_ROTATE_270: - angle = 270; - break; - } - - if (sampleSizeOverride < 0 && angle != 0) { - Matrix mat = new Matrix(); - mat.postRotate(angle); - Bitmap correctBmp = Bitmap.createBitmap(b, 0, 0, b.getWidth(), b.getHeight(), mat, true); - b.recycle(); - b = correctBmp; - } - } catch (IOException e) { - } - return b; - } else { - InputStream in = this.getResourceAsStream(getClass(), path); - if (in == null) { - throw new IOException("Resource not found. " + path); - } - try { - return this.createImage(in); - } finally { - if (in != null) { - try { - in.close(); - } catch (Exception ignored) { - ; - } - } - } - } - } - - @Override - public boolean areMutableImagesFast() { - if (myView == null) return false; - return !myView.alwaysRepaintAll(); - } - - @Override - public void repaint(Animation cmp) { - if(myView != null && myView.alwaysRepaintAll()) { - if(cmp instanceof Component) { - Component c = (Component)cmp; - c.setDirtyRegion(null); - if(c.getParent() != null) { - cmp = c.getComponentForm(); - } else { - Form f = getCurrentForm(); - if(f != null) { - cmp = f; - } - } - } else { - // make sure the form is repainted for standalone anims e.g. in the case - // of replace animation - Form f = getCurrentForm(); - if(f != null) { - super.repaint(f); - } - } - } - super.repaint(cmp); - } - - @Override - public Object createImage(InputStream i) throws IOException { - BitmapFactory.Options opts = new BitmapFactory.Options(); - opts.inPreferredConfig = Bitmap.Config.ARGB_8888; - return BitmapFactory.decodeStream(i, null, opts); - } - - @Override - public void releaseImage(Object image) { - Bitmap i = (Bitmap) image; - i.recycle(); - } - - @Override - public Object createImage(byte[] bytes, int offset, int len) { - BitmapFactory.Options opts = new BitmapFactory.Options(); - opts.inPreferredConfig = Bitmap.Config.ARGB_8888; - return BitmapFactory.decodeByteArray(bytes, offset, len, opts); - } - - @Override - public Object createImage(int[] rgb, int width, int height) { - return Bitmap.createBitmap(rgb, width, height, Bitmap.Config.ARGB_8888); - } - - @Override - public boolean isAlphaMutableImageSupported() { - return true; - } - - @Override - public Object scale(Object nativeImage, int width, int height) { - return Bitmap.createScaledBitmap((Bitmap) nativeImage, width, height, - false); - } - - // @Override -// public Object rotate(Object image, int degrees) { -// Matrix matrix = new Matrix(); -// matrix.postRotate(degrees); -// return Bitmap.createBitmap((Bitmap) image, 0, 0, ((Bitmap) image).getWidth(), ((Bitmap) image).getHeight(), matrix, true); -// } - @Override - public boolean isRotationDrawingSupported() { - return false; - } - - @Override - protected boolean cacheLinearGradients() { - return false; - } - - @Override - public boolean isNativeInputSupported() { - return true; - } - - /** - * Returns true if the underlying OS supports opening the native navigation - * application - * @return true if the underlying OS supports launch of native navigation app - */ - public boolean isOpenNativeNavigationAppSupported(){ - return true; - } - - /** - * Opens the native navigation app in the given coordinate. - * @param latitude - * @param longitude - */ - public void openNativeNavigationApp(double latitude, double longitude){ - execute("google.navigation:ll=" + latitude+ "," + longitude); - } - - - @Override - public void openNativeNavigationApp(String location) { - execute("google.navigation:q=" + Util.encodeUrl(location)); - } - - @Override - public Object createMutableImage(int width, int height, int fillColor) { - Bitmap bitmap = Bitmap.createBitmap(width, height, - Bitmap.Config.ARGB_8888); - AndroidGraphics graphics = (AndroidGraphics) this.getNativeGraphics(bitmap); - graphics.fillBitmap(fillColor); - return bitmap; - } - - @Override - public int getImageHeight(Object i) { - return ((Bitmap) i).getHeight(); - } - - @Override - public int getImageWidth(Object i) { - return ((Bitmap) i).getWidth(); - } - - @Override - public void drawImage(Object graphics, Object img, int x, int y) { - ((AndroidGraphics) graphics).drawImage(img, x, y); - } - - @Override - public void tileImage(Object graphics, Object img, int x, int y, int w, int h) { - ((AndroidGraphics) graphics).tileImage(img, x, y, w, h); - } - - public boolean isScaledImageDrawingSupported() { - return true; - } - - public void drawImage(Object graphics, Object img, int x, int y, int w, int h) { - ((AndroidGraphics) graphics).drawImage(img, x, y, w, h); - } - - @Override - public void drawLine(Object graphics, int x1, int y1, int x2, int y2) { - ((AndroidGraphics) graphics).drawLine(x1, y1, x2, y2); - } - - @Override - public boolean isAntiAliasingSupported() { - return true; - } - - @Override - public void setAntiAliased(Object graphics, boolean a) { - ((AndroidGraphics) graphics).getPaint().setAntiAlias(a); - } - - @Override - public void drawPolygon(Object graphics, int[] xPoints, int[] yPoints, int nPoints) { - ((AndroidGraphics) graphics).drawPolygon(xPoints, yPoints, nPoints); - } - - @Override - public void fillPolygon(Object graphics, int[] xPoints, int[] yPoints, int nPoints) { - ((AndroidGraphics) graphics).fillPolygon(xPoints, yPoints, nPoints); - } - - @Override - public void drawRGB(Object graphics, int[] rgbData, int offset, int x, - int y, int w, int h, boolean processAlpha) { - ((AndroidGraphics) graphics).drawRGB(rgbData, offset, x, y, w, h, processAlpha); - } - - @Override - public void drawRect(Object graphics, int x, int y, int width, int height) { - ((AndroidGraphics) graphics).drawRect(x, y, width, height); - } - - @Override - public void drawRoundRect(Object graphics, int x, int y, int width, - int height, int arcWidth, int arcHeight) { - ((AndroidGraphics) graphics).drawRoundRect(x, y, width, height, arcWidth, arcHeight); - } - - @Override - public void drawString(Object graphics, String str, int x, int y) { - ((AndroidGraphics) graphics).drawString(str, x, y); - } - - @Override - public void drawArc(Object graphics, int x, int y, int width, int height, - int startAngle, int arcAngle) { - ((AndroidGraphics) graphics).drawArc(x, y, width, height, startAngle, arcAngle); - } - - @Override - public void fillArc(Object graphics, int x, int y, int width, int height, - int startAngle, int arcAngle) { - ((AndroidGraphics) graphics).fillArc(x, y, width, height, startAngle, arcAngle); - } - - @Override - public void fillRect(Object graphics, int x, int y, int width, int height) { - ((AndroidGraphics) graphics).fillRect(x, y, width, height); - } - - @Override - public void fillRect(Object graphics, int x, int y, int w, int h, byte alpha) { - ((AndroidGraphics) graphics).fillRect(x, y, w, h, alpha); - } - - @Override - public void paintComponentBackground(Object graphics, int x, int y, int width, int height, Style s) { - if((!asyncView) || compatPaintMode ) { - super.paintComponentBackground(graphics, x, y, width, height, s); - return; - } - ((AndroidGraphics) graphics).paintComponentBackground(x, y, width, height, s); - } - - @Override - public void fillLinearGradient(Object graphics, int startColor, int endColor, int x, int y, int width, int height, boolean horizontal) { - if(!asyncView) { - super.fillLinearGradient(graphics, startColor, endColor, x, y, width, height, horizontal); - return; - } - ((AndroidGraphics)graphics).fillLinearGradient(startColor, endColor, x, y, width, height, horizontal); - } - - @Override - public void fillRectRadialGradient(Object graphics, int startColor, int endColor, int x, int y, int width, int height, float relativeX, float relativeY, float relativeSize) { - if(!asyncView) { - super.fillRectRadialGradient(graphics, startColor, endColor, x, y, width, height, relativeX, relativeY, relativeSize); - return; - } - ((AndroidGraphics)graphics).fillRectRadialGradient(startColor, endColor, x, y, width, height, relativeX, relativeY, relativeSize); - } - - @Override - public void fillRadialGradient(Object graphics, int startColor, int endColor, int x, int y, int width, int height) { - ((AndroidGraphics)graphics).fillRadialGradient(startColor, endColor, x, y, width, height); - } - - @Override - public void fillRadialGradient(Object graphics, int startColor, int endColor, int x, int y, int width, int height, int startAngle, int arcAngle) { - ((AndroidGraphics)graphics).fillRadialGradient(startColor, endColor, x, y, width, height, startAngle, arcAngle); - } - - @Override - public void fillGradient(Object graphics, com.codename1.ui.Gradient gradient, - int x, int y, int width, int height) { - // Always route Android multi-stop gradients through the native Shader - // path - the software rasterizer in the base impl would otherwise - // allocate a per-call ARGB buffer on the Bitmap-graphics path used by - // mutable images, which on Android emulator hardware GCs heavily for - // conic / large fills (the case that hung the instrumentation suite). - ((AndroidGraphics) graphics).fillGradient(gradient, x, y, width, height); - } - - @Override - public void drawLabelComponent(Object nativeGraphics, int cmpX, int cmpY, int cmpHeight, int cmpWidth, Style style, String text, Object icon, Object stateIcon, int preserveSpaceForState, int gap, boolean rtl, boolean isOppositeSide, int textPosition, int stringWidth, boolean isTickerRunning, int tickerShiftText, boolean endsWith3Points, int valign) { - if(AndroidAsyncView.legacyPaintLogic) { - super.drawLabelComponent(nativeGraphics, cmpX, cmpY, cmpHeight, cmpWidth, style, text, icon, stateIcon, preserveSpaceForState, gap, rtl, isOppositeSide, textPosition, stringWidth, isTickerRunning, tickerShiftText, endsWith3Points, valign); - return; - } - ((AndroidGraphics)nativeGraphics).drawLabelComponent(cmpX, cmpY, cmpHeight, cmpWidth, style, text, - (Bitmap)icon, (Bitmap)stateIcon, preserveSpaceForState, gap, rtl, isOppositeSide, textPosition, stringWidth, - isTickerRunning, tickerShiftText, endsWith3Points, valign); - } - - - @Override - public void fillRoundRect(Object graphics, int x, int y, int width, - int height, int arcWidth, int arcHeight) { - ((AndroidGraphics) graphics).fillRoundRect(x, y, width, height, arcWidth, arcHeight); - } - - @Override - public int getAlpha(Object graphics) { - return ((AndroidGraphics) graphics).getAlpha(); - } - - @Override - public void setAlpha(Object graphics, int alpha) { - ((AndroidGraphics) graphics).setAlpha(alpha); - } - - @Override - public boolean isAlphaGlobal() { - return true; - } - - @Override - public void setColor(Object graphics, int RGB) { - ((AndroidGraphics) graphics).setColor((getColor(graphics) & 0xff000000) | RGB); - } - - @Override - public int getBackKeyCode() { - return DROID_IMPL_KEY_BACK; - } - - @Override - public int getBackspaceKeyCode() { - return DROID_IMPL_KEY_BACKSPACE; - } - - @Override - public int getClearKeyCode() { - return DROID_IMPL_KEY_CLEAR; - } - - @Override - public int getClipHeight(Object graphics) { - return ((AndroidGraphics) graphics).getClipHeight(); - } - - @Override - public int getClipWidth(Object graphics) { - return ((AndroidGraphics) graphics).getClipWidth(); - } - - @Override - public int getClipX(Object graphics) { - return ((AndroidGraphics) graphics).getClipX(); - } - - @Override - public int getClipY(Object graphics) { - return ((AndroidGraphics) graphics).getClipY(); - } - - @Override - public void setClip(Object graphics, int x, int y, int width, int height) { - ((AndroidGraphics) graphics).setClip(x, y, width, height); - } - - @Override - public boolean isShapeClipSupported(Object graphics){ - return Build.VERSION.SDK_INT > Build.VERSION_CODES.HONEYCOMB; - } - - @Override - public void setClip(Object graphics, Shape shape) { - //Path p = cn1ShapeToAndroidPath(shape); - ((AndroidGraphics) graphics).setClip(shape); - } - - - @Override - public void clipRect(Object graphics, int x, int y, int width, int height) { - ((AndroidGraphics) graphics).clipRect(x, y, width, height); - } - - @Override - public int getColor(Object graphics) { - return ((AndroidGraphics) graphics).getColor(); - } - - @Override - public int getDisplayHeight() { - if (this.myView != null) { - int h = this.myView.getViewHeight(); - displayHeight = h; - return h; - } - return displayHeight; - } - - @Override - public int getDisplayWidth() { - if (this.myView != null) { - int w = this.myView.getViewWidth(); - displayWidth = w; - return w; - } - return displayWidth; - } - - @Override - public int getActualDisplayHeight() { - DisplayMetrics dm = getContext().getResources().getDisplayMetrics(); - return dm.heightPixels; - } - - @Override - public int getGameAction(int keyCode) { - switch (keyCode) { - case DROID_IMPL_KEY_DOWN: - return Display.GAME_DOWN; - case DROID_IMPL_KEY_UP: - return Display.GAME_UP; - case DROID_IMPL_KEY_LEFT: - return Display.GAME_LEFT; - case DROID_IMPL_KEY_RIGHT: - return Display.GAME_RIGHT; - case DROID_IMPL_KEY_FIRE: - return Display.GAME_FIRE; - default: - return 0; - } - } - - @Override - public int getKeyCode(int gameAction) { - switch (gameAction) { - case Display.GAME_DOWN: - return DROID_IMPL_KEY_DOWN; - case Display.GAME_UP: - return DROID_IMPL_KEY_UP; - case Display.GAME_LEFT: - return DROID_IMPL_KEY_LEFT; - case Display.GAME_RIGHT: - return DROID_IMPL_KEY_RIGHT; - case Display.GAME_FIRE: - return DROID_IMPL_KEY_FIRE; - default: - return 0; - } - } - - @Override - public int[] getSoftkeyCode(int index) { - if (index == 0) { - return leftSK; - } - return null; - } - - @Override - public int getSoftkeyCount() { - /** - * one menu button only. we may have to stuff some code here as soon as - * there are devices that no longer have only a single menu button. - */ - return 1; - } - - @Override - public void vibrate(int duration) { - if (!this.vibrateInitialized) { - try { - v = (Vibrator) getContext().getSystemService(Context.VIBRATOR_SERVICE); - } catch (Throwable e) { - Log.e("Codename One", "problem with virbrator(0)", e); - } finally { - this.vibrateInitialized = true; - } - } - if (v != null) { - try { - v.vibrate(duration); - } catch (Throwable e) { - Log.e("Codename One", "problem with virbrator(1)", e); - } - } - } - - @Override - public boolean isTouchDevice() { - return getContext().getPackageManager().hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN); - } - - @Override - public boolean hasPendingPaints() { - //if the view is not visible make sure the edt won't wait. - if (myView != null && myView.getAndroidView().getVisibility() != View.VISIBLE) { - return true; - } else { - return super.hasPendingPaints(); - } - } - - public void revalidate() { - if (myView != null) { - myView.getAndroidView().setVisibility(View.VISIBLE); - Form form = getCurrentForm(); - if (form != null) { - form.revalidate(); - } - flushGraphics(); - } - - } - - @Override - public int getKeyboardType() { - if (Display.getInstance().getDefaultVirtualKeyboard().isVirtualKeyboardShowing()) { - return Display.KEYBOARD_TYPE_VIRTUAL; - } - /** - * can we detect this? but even if we could i think it is best to have - * this fixed to qwerty. we pass unicode values to Codename One in any - * case. check AndroidView.onKeyUpDown() method. and read comment below. - */ - return Display.KEYBOARD_TYPE_QWERTY; - /** - * some info from the MIDP docs about keycodes: - * - * "Applications receive keystroke events in which the individual keys - * are named within a space of key codes. Every key for which events are - * reported to MIDP applications is assigned a key code. The key code - * values are unique for each hardware key unless two keys are obvious - * synonyms for each other. MIDP defines the following key codes: - * KEY_NUM0, KEY_NUM1, KEY_NUM2, KEY_NUM3, KEY_NUM4, KEY_NUM5, KEY_NUM6, - * KEY_NUM7, KEY_NUM8, KEY_NUM9, KEY_STAR, and KEY_POUND. (These key - * codes correspond to keys on a ITU-T standard telephone keypad.) Other - * keys may be present on the keyboard, and they will generally have key - * codes distinct from those list above. In order to guarantee - * portability, applications should use only the standard key codes. - * - * The standard key codes values are equal to the Unicode encoding for - * the character that represents the key. If the device includes any - * other keys that have an obvious correspondence to a Unicode - * character, their key code values should equal the Unicode encoding - * for that character. For keys that have no corresponding Unicode - * character, the implementation must use negative values. Zero is - * defined to be an invalid key code." - * - * Because the MIDP implementation is our reference and that - * implementation does not interpret the given keycodes we behave alike - * and pass on the unicode values. - */ - } - - /** - * Exits the application... - */ - public void exitApplication() { - android.os.Process.killProcess(android.os.Process.myPid()); - } - - /** - * finishAndRemoveTask() arrived in Lollipop, and there is nothing to remove without an - * activity -- a push or background service process owns no task of its own. - */ - @Override - public boolean isExitAndClearTaskSupported() { - return Build.VERSION.SDK_INT >= 21 && getActivity() != null; - } - - @Override - public void exitApplicationAndClearTask() { - final CodenameOneActivity a = getActivity(); - if (a == null || Build.VERSION.SDK_INT < 21) { - exitApplication(); - return; - } - Runnable finishAndKill = new Runnable() { - public void run() { - try { - a.finishAndRemoveTask(); - } catch (Throwable t) { - // A task we failed to remove is still a task we must exit, so log and fall - // through to the kill rather than leaving the application running. - com.codename1.io.Log.e(t); - } - // Killing here is what makes this behave like exitApplication(), which never - // returns to its caller either. It does not race the removal: finishAndRemoveTask() - // is a blocking binder call into the activity manager, so the task is already off - // the recents list when it returns. Measured on an API 36 emulator with a probe - // that ran this exact sequence 29 times -- the task was gone from - // "dumpsys activity recents" every time, while the control that only killed the - // process (what exitApplication() does) left it there every time. - android.os.Process.killProcess(android.os.Process.myPid()); - } - }; - if (Looper.getMainLooper().getThread() == Thread.currentThread()) { - finishAndKill.run(); - } else { - a.runOnUiThread(finishAndKill); - } - } - - @Override - public void notifyPushCompletion() { - if (pushWakeLock != null && pushWakeLock.isHeld()) { - try { - pushWakeLock.release(); - } catch (Exception ex) { - com.codename1.io.Log.e(ex); - } - } - } - - @Override - public void notifyCommandBehavior(int commandBehavior) { - if (commandBehavior == Display.COMMAND_BEHAVIOR_NATIVE) { - if (getActivity() instanceof CodenameOneActivity) { - ((CodenameOneActivity) getActivity()).enableNativeMenu(true); - } - } - } - - private static class NotifyActionBar implements Runnable { - private Activity activity; - private boolean show; - - public NotifyActionBar(Activity activity, int commandBehavior) { - this.activity = activity; - show = commandBehavior == Display.COMMAND_BEHAVIOR_NATIVE; - } - - public NotifyActionBar(Activity activity, boolean show) { - this.activity = activity; - this.show = show; - } - - @Override - public void run() { - activity.invalidateOptionsMenu(); - if (activity.getActionBar() == null) { - return; - } - if (show) { - activity.getActionBar().show(); - } else { - activity.getActionBar().hide(); - } - } - } - - @Override - public String getAppArg() { - if (super.getAppArg() != null) { - // This just maintains backward compatibility in case people are manually - // setting the AppArg in their properties. It reproduces the general - // behaviour the existed when AppArg was just another Display property. - return super.getAppArg(); - } - if (getActivity() == null) { - return null; - } - - android.content.Intent intent = getActivity().getIntent(); - if (intent != null) { - publishIntentProperties(getActivity(), intent); - String sharedText = intent.getStringExtra(Intent.EXTRA_TEXT); - intent.removeExtra(Intent.EXTRA_TEXT); - Uri u = intent.getData(); - String scheme = intent.getScheme(); - if (u != null && isAppArgDelivered(intent)) { - // dispatchNewIntentUrl() already handed this url over as the app arg - // on the warm path. The data stays on the intent for the readers that - // want it -- `android.intent.data` above, and native code asking the - // activity for its intent -- and only the second delivery is dropped. - u = null; - } - if (u == null && intent.getExtras() != null) { - if (intent.getExtras().keySet().contains("android.intent.extra.STREAM")) { - try { - u = (Uri)intent.getParcelableExtra("android.intent.extra.STREAM"); - scheme = u.getScheme(); - System.out.println("u="+u); - } catch (Exception ex) { - Log.d("Codename One", "Failed to load parcelable extra from intent: "+ex.getMessage()); - } - } - - } - if (u != null) { - //String scheme = intent.getScheme(); - intent.setData(null); - if ("content".equals(scheme)) { - try { - InputStream attachment = getActivity().getContentResolver().openInputStream(u); - if (attachment != null) { - String name = getContentName(getActivity().getContentResolver(), u); - if (name != null) { - String filePath = getAppHomePath() - + getFileSystemSeparator() + name; - if(filePath.startsWith("file:")) { - filePath = filePath.substring(5); - } - File f = new File(filePath); - OutputStream tmp = createFileOuputStream(f); - byte[] buffer = new byte[1024]; - int read = -1; - while ((read = attachment.read(buffer)) > -1) { - tmp.write(buffer, 0, read); - } - tmp.close(); - attachment.close(); - setAppArg(addFile(filePath)); - return addFile(filePath); - } - } - } catch (FileNotFoundException e) { - e.printStackTrace(); - return null; - } catch (IOException e) { - e.printStackTrace(); - return null; - } catch (Exception e) { - e.printStackTrace(); - return null; - } - } else { - - /* - // Why do we need this special case? u.toString() - // will include the full URL including query string. - // This special case causes urls like myscheme://part1/part2 - // to only return "/part2" which is obviously problematic and - // is inconsistent with iOS. Is this special case necessary - // in some versions of Android? - String encodedPath = u.getEncodedPath(); - if (encodedPath != null && encodedPath.length() > 0) { - String query = u.getQuery(); - if(query != null && query.length() > 0){ - encodedPath += "?" + query; - } - setAppArg(encodedPath); - return encodedPath; - } - */ - if (sharedText != null) { - setAppArg(sharedText); - return sharedText; - } else { - setAppArg(u.toString()); - return u.toString(); - } - - } - } else if (sharedText != null) { - setAppArg(sharedText); - return sharedText; - } - } - return null; - } - - // taken from https://stackoverflow.com/a/70380413/756809 - private boolean isRunningOnAndroidStudioEmulator() { - return Build.FINGERPRINT.startsWith("google/sdk_gphone") - && Build.FINGERPRINT.endsWith(":user/release-keys") - && "Google".equals(Build.MANUFACTURER) && Build.PRODUCT.startsWith("sdk_gphone") && "google".equals(Build.BRAND) - && Build.MODEL.startsWith("sdk_gphone"); - } - - // taken from https://stackoverflow.com/a/57960169/756809 - private boolean isEmulator() { - return isRunningOnAndroidStudioEmulator() || - ((Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) - || Build.FINGERPRINT.startsWith("generic") - || Build.FINGERPRINT.startsWith("unknown") - || Build.HARDWARE.contains("goldfish") - || Build.HARDWARE.contains("ranchu") - || Build.MODEL.contains("google_sdk") - || Build.MODEL.contains("Emulator") - || Build.MODEL.contains("Android SDK built for x86") - || Build.MODEL.contains("VirtualBox") - || Build.MANUFACTURER.contains("Genymotion") - || Build.PRODUCT.contains("sdk_google") - || Build.PRODUCT.contains("google_sdk") - || Build.PRODUCT.contains("sdk") - || Build.PRODUCT.contains("sdk_x86") - || Build.PRODUCT.contains("vbox86p") - || Build.PRODUCT.contains("emulator") - || Build.PRODUCT.contains("simulator")); - } - - - /** - * @inheritDoc - */ - @Override - public boolean canDial() { - return getContext().getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELEPHONY); - } - - /** - * @inheritDoc - */ - private static String cn1DistributionChannel; - private static boolean cn1DistributionChannelResolved; - /** Codename One channel id-value pair id in the APK Signing Block ('c','n','1','C'). */ - private static final int CN1_CHANNEL_PAIR_ID = 0x636E3143; - - /** - * The distribution channel (app store) stamped into this APK's Signing Block by - * the build server's channel packages, or null for a normal build. Read once and - * cached. Mirrors the daemon's {@code ApkChannelWriter}: locate the signing block - * before the central directory and return the Codename One channel pair's value. - */ - private String readDistributionChannel() { - if (cn1DistributionChannelResolved) { - return cn1DistributionChannel; - } - cn1DistributionChannelResolved = true; - try { - cn1DistributionChannel = cn1ReadChannelFromApk(getContext().getApplicationInfo().sourceDir); - } catch (Throwable t) { - cn1DistributionChannel = null; - } - return cn1DistributionChannel; - } - - private static String cn1ReadChannelFromApk(String path) throws java.io.IOException { - java.io.RandomAccessFile f = new java.io.RandomAccessFile(path, "r"); - try { - long len = f.length(); - long eocd = -1; - long maxBack = Math.min(len, 22 + 0xFFFF); - for (long i = len - 22; i >= len - maxBack && i >= 0; i--) { - if (cn1U32(f, i) == 0x06054b50L) { - eocd = i; - break; - } - } - if (eocd < 0) { - return null; - } - long cdOffset = cn1U32(f, eocd + 16); - if (cdOffset < 24 || cdOffset == 0xFFFFFFFFL) { - return null; - } - byte[] magic = "APK Sig Block 42".getBytes("US-ASCII"); - byte[] m = new byte[magic.length]; - f.seek(cdOffset - 16); - f.readFully(m); - for (int i = 0; i < magic.length; i++) { - if (m[i] != magic[i]) { - return null; - } - } - long sizeOfBlock = cn1U64(f, cdOffset - 24); - long blockStart = cdOffset - 8 - sizeOfBlock; - if (blockStart < 0) { - return null; - } - long p = blockStart + 8, to = cdOffset - 24; - while (p < to) { - long pairLen = cn1U64(f, p); - p += 8; - if (pairLen < 4 || p + pairLen > to + 8) { - break; - } - if ((int) cn1U32(f, p) == CN1_CHANNEL_PAIR_ID) { - byte[] v = new byte[(int) (pairLen - 4)]; - f.seek(p + 4); - f.readFully(v); - return new String(v, "UTF-8"); - } - p += pairLen; - } - return null; - } finally { - f.close(); - } - } - - private static long cn1U32(java.io.RandomAccessFile f, long at) throws java.io.IOException { - f.seek(at); - int b0 = f.read(), b1 = f.read(), b2 = f.read(), b3 = f.read(); - return (b0 & 0xFFL) | ((b1 & 0xFFL) << 8) | ((b2 & 0xFFL) << 16) | ((b3 & 0xFFL) << 24); - } - - private static long cn1U64(java.io.RandomAccessFile f, long at) throws java.io.IOException { - f.seek(at); - long v = 0; - for (int i = 0; i < 8; i++) { - v |= (f.read() & 0xFFL) << (8 * i); - } - return v; - } - - public String getProperty(String key, String defaultValue) { - if(key.equalsIgnoreCase("cn1_push_prefix")) { - /*if(!checkForPermission(Manifest.permission.READ_PHONE_STATE, "This is required to get notifications")){ - return ""; - }*/ - boolean has = hasAndroidMarket(); - if(has) { - return "gcm"; - } - return defaultValue; - } - if ("OS".equals(key)) { - return "Android"; - } - if ("DistributionChannel".equalsIgnoreCase(key) || "cn1.channel".equalsIgnoreCase(key)) { - // The app store this build was distributed through, stamped into the APK - // Signing Block by the Codename One build server's channel packages - // (android.distributionChannels). Empty for a normal Google Play build. - String ch = readDistributionChannel(); - return ch != null ? ch : defaultValue; - } - - // It's possible that this is triggering a Google Play data collection verification error - /*if ("androidId".equals(key)) { - return Settings.Secure.getString(getContext().getContentResolver(), Settings.Secure.ANDROID_ID); - }*/ - - /*if ("cellId".equals(key)) { - try { - if(!checkForPermission(Manifest.permission.READ_PHONE_STATE, "This is required to get the cellId")){ - return defaultValue; - } - String serviceName = Context.TELEPHONY_SERVICE; - TelephonyManager telephonyManager = (TelephonyManager) getContext().getSystemService(serviceName); - int cellId = ((GsmCellLocation) telephonyManager.getCellLocation()).getCid(); - return "" + cellId; - } catch (Throwable t) { - return defaultValue; - } - }*/ - if ("AppName".equals(key)) { - - final PackageManager pm = getContext().getPackageManager(); - ApplicationInfo ai; - try { - ai = pm.getApplicationInfo(getContext().getPackageName(), 0); - } catch (NameNotFoundException e) { - ai = null; - } - String applicationName = (String) (ai != null ? pm.getApplicationLabel(ai) : null); - if(applicationName == null){ - return defaultValue; - } - return applicationName; - } - if ("AppVersion".equals(key)) { - try { - PackageInfo i = getContext().getPackageManager().getPackageInfo(getContext().getApplicationInfo().packageName, 0); - return i.versionName; - } catch (NameNotFoundException ex) { - ex.printStackTrace(); - } - return defaultValue; - } - if ("Platform".equals(key)) { - String p = System.getProperty("platform"); - if(p == null) { - return defaultValue; - } - return p; - } - if ("User-Agent".equals(key)) { - String ua = getUserAgent(); - if(ua == null) { - return defaultValue; - } - return ua; - } - if("OSVer".equals(key)) { - return "" + android.os.Build.VERSION.RELEASE; - } - if("DeviceName".equals(key)) { - return "" + android.os.Build.MODEL; - } - if("DeviceHardwareModel".equals(key)) { - return "" + android.os.Build.MODEL; - } - if("DeviceManufacturer".equals(key)) { - return "" + android.os.Build.MANUFACTURER; - } - if("Emulator".equals(key)) { - return "" + isEmulator(); - } - /*try { - if ("IMEI".equals(key) || "UDID".equals(key)) { - if(!checkForPermission(Manifest.permission.READ_PHONE_STATE, "This is required to get the device ID")){ - return ""; - } - TelephonyManager tm = (TelephonyManager) getContext().getSystemService(Context.TELEPHONY_SERVICE); - String imei = null; - if (tm!=null && tm.getDeviceId() != null) { - // for phones or 3g tablets - imei = tm.getDeviceId(); - } else { - try { - imei = Secure.getString(getContext().getContentResolver(), Secure.ANDROID_ID); - } catch(Throwable t) { - com.codename1.io.Log.e(t); - } - } - return imei; - } - if ("MSISDN".equals(key)) { - if(!checkForPermission(Manifest.permission.READ_PHONE_STATE, "This is required to get the device ID")){ - return ""; - } - TelephonyManager tm = (TelephonyManager) getContext().getSystemService(Context.TELEPHONY_SERVICE); - return tm.getLine1Number(); - } - } catch(Throwable t) { - // will be caused by no permissions. - return defaultValue; - }*/ - - if (getActivity() != null) { - android.content.Intent intent = getActivity().getIntent(); - if(intent != null){ - Bundle extras = intent.getExtras(); - if (extras != null) { - String value = extras.getString(key); - if(value != null) { - return value; - } - } - } - } - - if(!key.startsWith("android.permission")) { - //these keys/values are from the Application Resources (strings values) - try { - int id = getContext().getResources().getIdentifier(key, "string", getContext().getApplicationInfo().packageName); - if (id != 0) { - String val = getContext().getResources().getString(id); - return val; - } - } catch (Exception e) { - } - } - return System.getProperty(key, super.getProperty(key, defaultValue)); - } - - private String getContentName(ContentResolver resolver, Uri uri) { - Cursor cursor = resolver.query(uri, null, null, null, null); - cursor.moveToFirst(); - int nameIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME); - if (nameIndex >= 0) { - String name = cursor.getString(nameIndex); - cursor.close(); - return name; - } - return null; - } - - private String getUserAgent() { - try { - String userAgent = System.getProperty("http.agent"); - if(userAgent != null){ - return userAgent; - } - } catch (Exception e) { - } - if (getActivity() == null) { - return "Android-CN1"; - } - try { - Constructor constructor = WebSettings.class.getDeclaredConstructor(Context.class, WebView.class); - constructor.setAccessible(true); - try { - WebSettings settings = constructor.newInstance(getActivity(), null); - return settings.getUserAgentString(); - } finally { - constructor.setAccessible(false); - } - } catch (Exception e) { - final StringBuffer ua = new StringBuffer(); - if (Thread.currentThread().getName().equalsIgnoreCase("main")) { - WebView m_webview = new WebView(getActivity()); - ua.append(m_webview.getSettings().getUserAgentString()); - m_webview.destroy(); - } else { - final boolean[] flag = new boolean[1]; - Thread thread = new Thread() { - public void run() { - Looper.prepare(); - WebView m_webview = new WebView(getActivity()); - ua.append(m_webview.getSettings().getUserAgentString()); - m_webview.destroy(); - Looper.loop(); - flag[0] = true; - synchronized (flag) { - flag.notify(); - } - } - }; - thread.setUncaughtExceptionHandler(AndroidImplementation.exceptionHandler); - thread.start(); - while (!flag[0]) { - synchronized (flag) { - try { - flag.wait(100); - } catch (InterruptedException ex) { - } - } - } - } - return ua.toString(); - } - } - - private String getMimeType(String url){ - String type = null; - String extension = MimeTypeMap.getFileExtensionFromUrl(url); - if (extension != null) { - MimeTypeMap mime = MimeTypeMap.getSingleton(); - - type = mime.getMimeTypeFromExtension(extension); - } - if (type == null) { - try { - Uri uri = Uri.parse(url); - ContentResolver cr = getContext().getContentResolver(); - type = cr.getType(uri); - } catch (Throwable t) { - t.printStackTrace(); - } - } - return type; - } - - public static void copy(File src, File dst) throws IOException { - InputStream in = new FileInputStream(src); - try { - OutputStream out = new FileOutputStream(dst); - try { - // Transfer bytes from in to out - byte[] buf = new byte[8096]; - int len; - while ((len = in.read(buf)) > 0) { - out.write(buf, 0, len); - } - } finally { - out.close(); - } - } finally { - in.close(); - } - } - - private static File makeTempCacheCopy(File file) throws IOException { - File cacheDir = new File(getContext().getCacheDir(), "intent_files"); - - // Create the storage directory if it does not exist - if (!cacheDir.exists()) { - if (!cacheDir.mkdirs()) { - Log.d(Display.getInstance().getProperty("AppName", "CodenameOne"), "failed to create directory"); - return null; - } - } - - File copy = new File(cacheDir, "tmp-"+System.currentTimeMillis()+file.getName()); - copy(file, copy); - return copy; - - } - - - - private Intent createIntentForURL(String url) { - Intent intent; - Uri uri; - try { - if (url.startsWith("intent")) { - intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME); - } else { - if(url.startsWith("/") || url.startsWith("file:")) { - if (PermissionsHelper.requiresExternalStoragePermissionForMediaAccess()) { - if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to open the file")){ - return null; - } - } - - } - intent = new Intent(); - intent.setAction(Intent.ACTION_VIEW); - if (url.startsWith("/")) { - File f = new File(url); - Uri furi = null; - try { - furi = FileProvider.getUriForFile(getContext(), getContext().getPackageName()+".provider", f); - } catch (Exception ex) { - f = makeTempCacheCopy(f); - furi = FileProvider.getUriForFile(getContext(), getContext().getPackageName()+".provider", f); - } - - - if (Build.VERSION.SDK_INT < 21) { - List resInfoList = getContext().getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); - for (ResolveInfo resolveInfo : resInfoList) { - String packageName = resolveInfo.activityInfo.packageName; - getContext().grantUriPermission(packageName, furi, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); - } - } - - uri = furi; - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK|Intent.FLAG_GRANT_READ_URI_PERMISSION); - }else{ - - if (url.startsWith("file:")) { - File f = new File(removeFilePrefix(url)); - System.out.println("File size: "+f.length()); - - Uri furi = null; - try { - furi = FileProvider.getUriForFile(getContext(), getContext().getPackageName()+".provider", f); - } catch (Exception ex) { - f = makeTempCacheCopy(f); - furi = FileProvider.getUriForFile(getContext(), getContext().getPackageName()+".provider", f); - } - - - if (Build.VERSION.SDK_INT < 21) { - List resInfoList = getContext().getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); - for (ResolveInfo resolveInfo : resInfoList) { - String packageName = resolveInfo.activityInfo.packageName; - getContext().grantUriPermission(packageName, furi, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); - } - } - uri = furi; - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK|Intent.FLAG_GRANT_READ_URI_PERMISSION); - - - } else { - uri = Uri.parse(url); - } - } - String mimeType = getMimeType(url); - if(mimeType != null){ - intent.setDataAndType(uri, mimeType); - }else{ - intent.setData(uri); - } - } - - return intent; - } catch(Exception err) { - com.codename1.io.Log.e(err); - return null; - } - } - - @Override - public Boolean canExecute(String url) { - try { - Intent it = createIntentForURL(url); - if(it == null) { - return false; - } - final PackageManager mgr = getContext().getPackageManager(); - List list = mgr.queryIntentActivities(it, PackageManager.MATCH_DEFAULT_ONLY); - return list.size() > 0; - } catch(Exception err) { - com.codename1.io.Log.e(err); - return false; - } - } - - - public void execute(String url, ActionListener response) { - if (response != null) { - callback = new EventDispatcher(); - callback.addListener(response); - } - - try { - Intent intent = createIntentForURL(url); - if(intent == null) { - return; - } - if(response != null && getActivity() != null){ - getActivity().startActivityForResult(intent, IntentResultListener.URI_SCHEME); - }else { - getContext().startActivity(intent); - } - return; - } catch (Exception ex) { - com.codename1.io.Log.e(ex); - } - - try { - if(editInProgress()) { - stopEditing(true); - } - getContext().startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url))); - } catch (Exception e) { - e.printStackTrace(); - } - } - - - /** - * @inheritDoc - */ - @Override - public void execute(String url) { - execute(url, null); - } - - /** - * @inheritDoc - */ - public void playBuiltinSound(String soundIdentifier) { - if (getActivity() != null && Display.SOUND_TYPE_BUTTON_PRESS.equals(soundIdentifier)) { - getActivity().runOnUiThread(new Runnable() { - public void run() { - if (myView != null) { - myView.getAndroidView().playSoundEffect(AudioManager.FX_KEY_CLICK); - } - } - }); - } - } - - /** - * @inheritDoc - */ - protected void playNativeBuiltinSound(Object data) { - } - - /** - * @inheritDoc - */ - public boolean isBuiltinSoundAvailable(String soundIdentifier) { - return false; - } - - /** - * @inheritDoc - */ - @Override - public boolean isNativeVideoPlayerControlsIncluded() { - return true; - } - - private static final int STATE_PAUSED = 0; - private static final int STATE_PLAYING = 1; - - private int mCurrentState; - - private MediaBrowserCompat mMediaBrowserCompat; - private android.support.v4.media.session.MediaControllerCompat mMediaControllerCompat; - - private android.support.v4.media.session.MediaControllerCompat.Callback mMediaControllerCompatCallback = new android.support.v4.media.session.MediaControllerCompat.Callback() { - - @Override - public void onPlaybackStateChanged(PlaybackStateCompat state) { - super.onPlaybackStateChanged(state); - if( state == null ) { - return; - } - - switch( state.getState() ) { - case PlaybackStateCompat.STATE_PLAYING: { - mCurrentState = STATE_PLAYING; - break; - } - case PlaybackStateCompat.STATE_PAUSED: { - mCurrentState = STATE_PAUSED; - break; - } - } - } - }; - - private MediaBrowserCompat.ConnectionCallback mMediaBrowserCompatConnectionCallback = new MediaBrowserCompat.ConnectionCallback() { - - @Override - public void onConnected() { - super.onConnected(); - try { - mMediaControllerCompat = new MediaControllerCompat(getActivity(), mMediaBrowserCompat.getSessionToken()); - mMediaControllerCompat.registerCallback(mMediaControllerCompatCallback); - MediaControllerCompat.setMediaController(getActivity(), mMediaControllerCompat); - MediaControllerCompat.getMediaController(getActivity()).getTransportControls().play(); - - } catch( RemoteException e ) { - e.printStackTrace(); - } - } - }; - - //BackgroundAudioService remoteControl; - - @Override - public void startRemoteControl() { - super.startRemoteControl(); - getActivity().runOnUiThread(new Runnable() { - public void run() { - mMediaBrowserCompat = new MediaBrowserCompat(getActivity(), new ComponentName(getActivity(), BackgroundAudioService.class), - mMediaBrowserCompatConnectionCallback, getActivity().getIntent().getExtras()); - - mMediaBrowserCompat.connect(); - AndroidNativeUtil.addLifecycleListener(new LifecycleListener() { - @Override - public void onCreate(Bundle savedInstanceState) { - - } - - @Override - public void onResume() { - - } - - @Override - public void onPause() { - - } - - @Override - public void onDestroy() { - if (mMediaBrowserCompat != null) { - if( MediaControllerCompat.getMediaController(getActivity()).getPlaybackState().getState() == PlaybackStateCompat.STATE_PLAYING ) { - MediaControllerCompat.getMediaController(getActivity()).getTransportControls().pause(); - } - - mMediaBrowserCompat.disconnect(); - mMediaBrowserCompat = null; - } - } - - @Override - public void onSaveInstanceState(Bundle b) { - - } - - @Override - public void onLowMemory() { - - } - }); - } - - }); - - } - - @Override - public void stopRemoteControl() { - super.stopRemoteControl(); - if (mMediaBrowserCompat != null) { - if( MediaControllerCompat.getMediaController(getActivity()).getPlaybackState().getState() == PlaybackStateCompat.STATE_PLAYING ) { - MediaControllerCompat.getMediaController(getActivity()).getTransportControls().pause(); - } - - mMediaBrowserCompat.disconnect(); - mMediaBrowserCompat = null; - } - } - - - @Override - public AsyncResource createBackgroundMediaAsync(final String uri) { - final AsyncResource out = new AsyncResource(); - new Thread(new Runnable() { - public void run() { - try { - out.complete(createBackgroundMedia(uri)); - } catch (IOException ex) { - out.error(ex); - } - } - }).start(); - - return out; - } - - private int nextMediaId; - private int backgroundMediaCount; - private ServiceConnection backgroundMediaServiceConnection; - @Override - public Media createBackgroundMedia(final String uri) throws IOException { - int mediaId = nextMediaId++; - backgroundMediaCount++; - - Intent serviceIntent = new Intent(getContext(), AudioService.class); - serviceIntent.putExtra("mediaLink", uri); - serviceIntent.putExtra("mediaId", mediaId); - if (background == null) { - ServiceConnection mConnection = new ServiceConnection() { - - public void onServiceDisconnected(ComponentName name) { - - background = null; - backgroundMediaServiceConnection = null; - } - - public void onServiceConnected(ComponentName name, IBinder service) { - AudioService.LocalBinder mLocalBinder = (AudioService.LocalBinder) service; - AudioService svc = (AudioService)mLocalBinder.getService(); - background = svc; - } - }; - backgroundMediaServiceConnection = mConnection; - boolean boundSuccess = getContext().bindService(serviceIntent, mConnection, getContext().BIND_AUTO_CREATE); - if (!boundSuccess) { - throw new RuntimeException("Failed to bind background media service for uri "+uri); - } - ContextCompat.startForegroundService(getContext(), serviceIntent); - while (background == null) { - Display.getInstance().invokeAndBlock(new Runnable() { - @Override - public void run() { - Util.sleep(200); - } - }); - } - } else { - ContextCompat.startForegroundService(getContext(), serviceIntent); - } - - while (background.getMedia(mediaId) == null) { - Display.getInstance().invokeAndBlock(new Runnable() { - public void run() { - Util.sleep(200); - } - - }); - } - Media ret = new MediaProxy(background.getMedia(mediaId)) { - - - @Override - public void cleanup() { - super.cleanup(); - if (--backgroundMediaCount <= 0) { - if (backgroundMediaServiceConnection != null) { - try { - getContext().unbindService(backgroundMediaServiceConnection); - } catch (IllegalArgumentException ex) { - // This is thrown sometimes if the service has already been unbound - } - } - } - } - }; - - return ret; - - } - - - /** - * @inheritDoc - */ - @Override - public Media createMedia(final String uri, boolean isVideo, final Runnable onCompletion) throws IOException { - if (getActivity() == null) { - return null; - } - if (uri.startsWith("file://")) { - return createMedia(removeFilePrefix(uri), isVideo, onCompletion); - } - File file = null; - if (uri.indexOf(':') < 0) { - // use a file object to play to try and workaround this issue: - // http://code.google.com/p/android/issues/detail?id=4124 - file = new File(uri); - } - - Uri parsedUri = null; - boolean isContentUri = false; - if (file == null) { - parsedUri = Uri.parse(uri); - isContentUri = parsedUri != null && "content".equalsIgnoreCase(parsedUri.getScheme()); - } - - // The document picker grants temporary permissions for content URIs. Requesting - // READ_EXTERNAL_STORAGE again would surface a redundant prompt on Android 13+, so we only - // ask for classic file paths that require the legacy permission. MediaStore URIs still - // require an explicit permission grant, so they remain subject to the legacy check even - // though they also use the content:// scheme. - boolean requiresLegacyPermission = !uri.startsWith(FileSystemStorage.getInstance().getAppHomePath()); - if (isContentUri && parsedUri != null) { - String authority = parsedUri.getAuthority(); - if (authority != null) { - authority = authority.toLowerCase(); - if (!"media".equals(authority) && !authority.startsWith("media.")) { - if (!"com.android.providers.media.documents".equals(authority)) { - requiresLegacyPermission = false; - } - } - } else { - requiresLegacyPermission = false; - } - } - - if(requiresLegacyPermission) { - if(!PermissionsHelper.checkForPermission(isVideo ? DevicePermission.PERMISSION_READ_VIDEO : DevicePermission.PERMISSION_READ_AUDIO, "This is required to play media")){ - return null; - } - } - - Media retVal; - - if (isVideo) { - final AndroidImplementation.Video[] video = new AndroidImplementation.Video[1]; - final boolean[] flag = new boolean[1]; - final File f = file; - final Uri videoUri = parsedUri; - getActivity().runOnUiThread(new Runnable() { - @Override - public void run() { - VideoView v = new VideoView(getActivity()); - v.setZOrderMediaOverlay(true); - if (f != null) { - v.setVideoURI(Uri.fromFile(f)); - } else { - v.setVideoURI(videoUri != null ? videoUri : Uri.parse(uri)); - } - video[0] = new AndroidImplementation.Video(v, getActivity(), onCompletion); - flag[0] = true; - synchronized (flag) { - flag.notify(); - } - } - }); - while (!flag[0]) { - synchronized (flag) { - try { - flag.wait(100); - } catch (InterruptedException ex) { - } - } - } - return video[0]; - } else { - MediaPlayer player; - if (file != null) { - FileInputStream is = new FileInputStream(file); - player = new MediaPlayer(); - player.setDataSource(is.getFD()); - player.prepare(); - } else { - player = MediaPlayer.create(getActivity(), parsedUri != null ? parsedUri : Uri.parse(uri)); - if (player == null && isContentUri) { - // Android 13+ introduces stricter access rules for content:// URIs returned - // from the system document picker. The picker grants our activity a - // persistable read permission, but some OEM builds still reject the URI when it - // is passed directly to MediaPlayer. Opening the descriptor ourselves keeps the - // same permission grant while avoiding the OEM bug. - ContentResolver resolver = getContext().getContentResolver(); - if (resolver != null && parsedUri != null) { - AssetFileDescriptor afd = null; - try { - afd = resolver.openAssetFileDescriptor(parsedUri, "r"); - if (afd != null) { - player = new MediaPlayer(); - player.setDataSource(afd.getFileDescriptor(), afd.getStartOffset(), afd.getLength()); - player.prepare(); - } - } finally { - if (afd != null) { - try { - afd.close(); - } catch (IOException ignore) { - } - } - } - } - } - } - if (player == null) { - throw new IOException("Unable to create media player for uri " + uri); - } - retVal = new Audio(getActivity(), player, null, onCompletion); - } - return retVal; - } - - @Override - public void addCompletionHandler(Media media, Runnable onCompletion) { - super.addCompletionHandler(media, onCompletion); - if (media instanceof Video) { - ((Video)media).addCompletionHandler(onCompletion); - } else if (media instanceof Audio) { - ((Audio)media).addCompletionHandler(onCompletion); - } else if (media instanceof MediaProxy) { - ((MediaProxy)media).addCompletionHandler(onCompletion); - } - } - - @Override - public void removeCompletionHandler(Media media, Runnable onCompletion) { - super.removeCompletionHandler(media, onCompletion); - if (media instanceof Video) { - ((Video)media).removeCompletionHandler(onCompletion); - } else if (media instanceof Audio) { - ((Audio)media).removeCompletionHandler(onCompletion); - } else if (media instanceof MediaProxy) { - ((MediaProxy)media).removeCompletionHandler(onCompletion); - } - } - - - - /** - * @inheritDoc - */ - @Override - public Media createMedia(InputStream stream, String mimeType, final Runnable onCompletion) throws IOException { - if (getActivity() == null) { - return null; - } - /*if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to play media")){ - return null; - }*/ - boolean isVideo = mimeType.contains("video"); - - if (!isVideo && stream instanceof FileInputStream) { - MediaPlayer player = new MediaPlayer(); - player.setDataSource(((FileInputStream) stream).getFD()); - player.prepare(); - return new Audio(getActivity(), player, stream, onCompletion); - } - String extension = MimeTypeMap.getFileExtensionFromUrl(mimeType); - final File temp = File.createTempFile("mtmp", extension == null ? "dat" : extension); - temp.deleteOnExit(); - OutputStream out = createFileOuputStream(temp); - - byte buf[] = new byte[256]; - int len = 0; - while ((len = stream.read(buf, 0, buf.length)) > -1) { - out.write(buf, 0, len); - } - out.close(); - stream.close(); - - final Runnable finish = new Runnable() { - - @Override - public void run() { - if(onCompletion != null){ - Display.getInstance().callSerially(onCompletion); - - // makes sure the file is only deleted after the onCompletion was invoked - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - temp.delete(); - } - }); - return; - } - temp.delete(); - } - }; - - if (isVideo) { - final AndroidImplementation.Video[] retVal = new AndroidImplementation.Video[1]; - final boolean[] flag = new boolean[1]; - - getActivity().runOnUiThread(new Runnable() { - @Override - public void run() { - VideoView v = new VideoView(getActivity()); - v.setZOrderMediaOverlay(true); - v.setVideoURI(Uri.fromFile(temp)); - retVal[0] = new AndroidImplementation.Video(v, getActivity(), finish); - flag[0] = true; - synchronized (flag) { - flag.notify(); - } - } - }); - while (!flag[0]) { - synchronized (flag) { - try { - flag.wait(100); - } catch (InterruptedException ex) { - } - } - } - - return retVal[0]; - } else { - return createMedia(createFileInputStream(temp), mimeType, finish); - } - - } - - @Override - public boolean isSoundPoolSupported() { - return getContext() != null; - } - - @Override - public com.codename1.media.SoundPoolPeer createSoundPool(int maxStreams) { - if (getContext() == null) { - return null; - } - return new com.codename1.media.GameSoundPool(this, maxStreams); - } - - @Override - public Media createMediaRecorder(MediaRecorderBuilder builder) throws IOException { - return createMediaRecorder(builder.getPath(), builder.getMimeType(), builder.getSamplingRate(), builder.getBitRate(), builder.getAudioChannels(), 0, builder.isRedirectToAudioBuffer()); - } - - @Override - public Media createMediaRecorder(final String path, final String mimeType) throws IOException { - MediaRecorderBuilder builder = new MediaRecorderBuilder() - .path(path) - .mimeType(mimeType); - return createMediaRecorder(builder); - } - - - - private Media createMediaRecorder(final String path, final String mimeType, final int sampleRate, final int bitRate, final int audioChannels, final int maxDuration, final boolean redirectToAudioBuffer) throws IOException { - if (getActivity() == null) { - return null; - } - if(!checkForPermission(Manifest.permission.RECORD_AUDIO, "This is required to record audio")){ - return null; - } - final Media[] record = new Media[1]; - final IOException[] error = new IOException[1]; - - final Object lock = new Object(); - synchronized (lock) { - getActivity().runOnUiThread(new Runnable() { - @Override - public void run() { - synchronized (lock) { - if (redirectToAudioBuffer) { - final int channelConfig =audioChannels == 1 ? android.media.AudioFormat.CHANNEL_IN_MONO - : audioChannels == 2 ? android.media.AudioFormat.CHANNEL_IN_STEREO - : android.media.AudioFormat.CHANNEL_IN_MONO; - final AudioRecord recorder = new AudioRecord( - MediaRecorder.AudioSource.MIC, - sampleRate, - channelConfig, - AudioFormat.ENCODING_PCM_16BIT, - AudioRecord.getMinBufferSize(sampleRate, channelConfig, AudioFormat.ENCODING_PCM_16BIT) - ); - - final com.codename1.media.AudioBuffer audioBuffer = com.codename1.media.MediaManager.getAudioBuffer(path, true, 64); - - record[0] = new AbstractMedia() { - private int lastTime; - private boolean isRecording; - @Override - protected void playImpl() { - if (isRecording) { - return; - } - isRecording = true; - recorder.startRecording(); - fireMediaStateChange(State.Playing); - new Thread(new Runnable() { - public void run() { - float[] audioData = new float[audioBuffer.getMaxSize()]; - short[] buffer = new short[AudioRecord.getMinBufferSize(recorder.getSampleRate(), recorder.getChannelCount(), AudioFormat.ENCODING_PCM_16BIT)]; - int read = -1; - int index = 0; - - while (isRecording && (read = recorder.read(buffer, 0, buffer.length)) >= 0) { - if (read > 0) { - for (int i=0; i= audioData.length) { - audioBuffer.copyFrom(sampleRate, audioChannels, audioData, 0, index); - index = 0; - } - } - if (index > 0) { - audioBuffer.copyFrom(sampleRate, audioChannels, audioData, 0, index); - index = 0; - } - } - } - - } - - }).start(); - } - - @Override - protected void pauseImpl() { - if (!isRecording) { - return; - } - isRecording = false; - recorder.stop(); - - - fireMediaStateChange(State.Paused); - } - - @Override - public void prepare() { - - } - - @Override - public void cleanup() { - pauseImpl(); - recorder.release(); - com.codename1.media.MediaManager.releaseAudioBuffer(path); - - } - - @Override - public int getTime() { - if (isRecording) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - AudioTimestamp ts = new AudioTimestamp(); - recorder.getTimestamp(ts, AudioTimestamp.TIMEBASE_MONOTONIC); - lastTime = (int) (ts.framePosition / ((float) sampleRate / 1000f)); - } - } - return lastTime; - } - - @Override - public void setTime(int time) { - - } - - @Override - public int getDuration() { - return getTime(); - } - - @Override - public void setVolume(int vol) { - - } - - @Override - public int getVolume() { - return 0; - } - - @Override - public boolean isPlaying() { - return recorder.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING; - } - - @Override - public Component getVideoComponent() { - return null; - } - - @Override - public boolean isVideo() { - return false; - } - - @Override - public boolean isFullScreen() { - return false; - } - - @Override - public void setFullScreen(boolean fullScreen) { - - } - - @Override - public void setNativePlayerMode(boolean nativePlayer) { - - } - - @Override - public boolean isNativePlayerMode() { - return false; - } - - @Override - public void setVariable(String key, Object value) { - - } - - @Override - public Object getVariable(String key) { - return null; - } - - }; - lock.notify(); - } else { - MediaRecorder recorder = new MediaRecorder(); - recorder.setAudioSource(MediaRecorder.AudioSource.MIC); - - if(mimeType.contains("amr")){ - recorder.setOutputFormat(MediaRecorder.OutputFormat.AMR_NB); - recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB); - }else{ - recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); - recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); - recorder.setAudioSamplingRate(sampleRate); - recorder.setAudioEncodingBitRate(bitRate); - } - if (audioChannels > 0) { - recorder.setAudioChannels(audioChannels); - } - if (maxDuration > 0) { - recorder.setMaxDuration(maxDuration); - } - recorder.setOutputFile(removeFilePrefix(path)); - try { - recorder.prepare(); - record[0] = new AndroidRecorder(recorder); - } catch (IllegalStateException ex) { - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - } catch (IOException ex) { - error[0] = ex; - } finally { - lock.notify(); - } - } - - - - } - } - }); - - try { - lock.wait(); - } catch (InterruptedException ex) { - ex.printStackTrace(); - } - - if (error[0] != null) { - throw error[0]; - } - - return record[0]; - } - } - - public String [] getAvailableRecordingMimeTypes(){ - // audio/aac and audio/mp4 result in the same thing - // AAC are wrapped in an mp4 container. - return new String[]{"audio/amr", "audio/aac", "audio/mp4"}; - } - - - /** - * @inheritDoc - */ - public Object createSoftWeakRef(Object o) { - return new SoftReference(o); - } - - /** - * @inheritDoc - */ - public Object extractHardRef(Object o) { - SoftReference w = (SoftReference) o; - if (w != null) { - return w.get(); - } - return null; - } - - /** - * @inheritDoc - */ - public PeerComponent createNativePeer(Object nativeComponent) { - if (!(nativeComponent instanceof View)) { - throw new IllegalArgumentException(nativeComponent.getClass().getName()); - } - return new AndroidImplementation.AndroidPeer((View) nativeComponent); - } - - private final java.util.Map glSurfaces = - new java.util.IdentityHashMap(); - - private final com.codename1.impl.gpu.GpuImplementation gpuImpl = - new com.codename1.impl.gpu.GpuImplementation() { - @Override - public PeerComponent createPeer(final com.codename1.gpu.RenderView view) { - final CodenameOneActivity a = getActivity(); - if (a == null) { - return null; - } - // The GLSurfaceView must be constructed on the UI thread; block until - // it exists so we can wrap and return its peer to the caller. - final AndroidGLSurface[] holder = new AndroidGLSurface[1]; - final java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1); - a.runOnUiThread(new Runnable() { - public void run() { - try { - holder[0] = new AndroidGLSurface(a, view); - } catch (Throwable t) { - t.printStackTrace(); - } finally { - latch.countDown(); - } - } - }); - try { - latch.await(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } - AndroidGLSurface surface = holder[0]; - if (surface == null) { - return null; - } - PeerComponent peer = createNativePeer(surface); - if (peer != null) { - glSurfaces.put(peer, surface); - } - return peer; - } - - @Override - public void setContinuous(PeerComponent peer, final boolean continuous) { - final AndroidGLSurface surface = glSurfaces.get(peer); - if (surface == null) { - return; - } - final CodenameOneActivity a = getActivity(); - if (a == null) { - return; - } - a.runOnUiThread(new Runnable() { - public void run() { - surface.setRenderMode(continuous - ? android.opengl.GLSurfaceView.RENDERMODE_CONTINUOUSLY - : android.opengl.GLSurfaceView.RENDERMODE_WHEN_DIRTY); - } - }); - } - - @Override - public void requestRender(PeerComponent peer) { - AndroidGLSurface surface = glSurfaces.get(peer); - if (surface != null) { - surface.requestRender(); - } - } - }; - - @Override - public com.codename1.impl.gpu.GpuImplementation getGpuImplementation() { - return gpuImpl; - } - - private void blockNativeFocusAll(boolean block) { - synchronized (this.nativePeers) { - final int size = this.nativePeers.size(); - for (int i = 0; i < size; i++) { - AndroidImplementation.AndroidPeer next = (AndroidImplementation.AndroidPeer) this.nativePeers.get(i); - next.blockNativeFocus(block); - } - } - } - - public void onFocusChange(View view, boolean bln) { - - if (bln) { - /** - * whenever the base view receives focus we automatically block - * possible native subviews from gaining focus. - */ - blockNativeFocusAll(true); - if (this.lastDirectionalKeyEventReceivedByWrapper != 0) { - /** - * because we also consume any key event in the OnKeyListener of - * the native wrappers, we have to simulate key events to make - * Codename One move the focus to the next component. - */ - if (myView == null) { - return; - } - if (!myView.getAndroidView().isInTouchMode()) { - switch (lastDirectionalKeyEventReceivedByWrapper) { - case AndroidImplementation.DROID_IMPL_KEY_LEFT: - case AndroidImplementation.DROID_IMPL_KEY_RIGHT: - case AndroidImplementation.DROID_IMPL_KEY_UP: - case AndroidImplementation.DROID_IMPL_KEY_DOWN: - Display.getInstance().keyPressed(lastDirectionalKeyEventReceivedByWrapper); - Display.getInstance().keyReleased(lastDirectionalKeyEventReceivedByWrapper); - break; - default: - Log.d("Codename One", "unexpected keycode: " + lastDirectionalKeyEventReceivedByWrapper); - break; - } - } else { - Log.d("Codename One", "base view gained focus but no key event to process."); - } - lastDirectionalKeyEventReceivedByWrapper = 0; - } - } - - } - - @Override - public void edtIdle(boolean enter) { - super.edtIdle(enter); - if(enter) { - // check if we have peers waiting for resize... - if(myView instanceof AndroidAsyncView) { - ((AndroidAsyncView)myView).resizeViews(); - } - } - } - - static final Map activePeers = new HashMap(); - - - /** - * wrapper component that capsules a native view object in a Codename One - * component. this involves A LOT of back and forth between the Codename One - * EDT and the Android UI thread. - * - * - * To use it you would: - * - * 1) create your native Android view(s). Make sure to work on the Android - * UI thread when constructing and modifying them. 2) create a Codename One - * peer component by calling: - * - * com.codename1.ui.PeerComponent.create(myAndroidView); - * - * 3) currently the view's size is not automatically calculated from the - * native view. so you should set the preferred size of the Codename One - * component manually. - * - * - */ - class AndroidPeer extends PeerComponent { - - private View v; - private AndroidImplementation.AndroidRelativeLayout layoutWrapper = null; - private int currentVisible = View.INVISIBLE; - private boolean lightweightMode; - - public AndroidPeer(View vv) { - super(vv); - this.v = vv; - if(!superPeerMode) { - v.measure(MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), - MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); - } - } - - @Override - protected Image generatePeerImage() { - try { - Bitmap bmp = AndroidNativeUtil.renderViewOnBitmap(v, getWidth(), getHeight()); - if(bmp == null) { - return Image.createImage(5, 5); - } - Image image = new AndroidImplementation.NativeImage(bmp); - return image; - } catch(Throwable t) { - t.printStackTrace(); - return Image.createImage(5, 5); - } - } - - protected boolean shouldRenderPeerImage() { - return !superPeerMode && (lightweightMode || !isInitialized()); - } - - protected void setLightweightMode(boolean l) { - if(superPeerMode) { - if (l != lightweightMode) { - lightweightMode = l; - if (lightweightMode) { - Image img = generatePeerImage(); - if (img != null) { - peerImage = img; - } - } - - } - return; - } - doSetVisibility(!l); - if (lightweightMode == l) { - return; - } - lightweightMode = l; - } - - @Override - public void setVisible(boolean visible) { - super.setVisible(visible); - this.doSetVisibility(visible); - } - - void doSetVisibility(final boolean visible) { - if (getActivity() == null) { - return; - } - getActivity().runOnUiThread(new Runnable() { - public void run() { - currentVisible = visible ? View.VISIBLE : View.INVISIBLE; - v.setVisibility(currentVisible); - if (visible) { - v.bringToFront(); - } - } - }); - if(visible){ - layoutPeer(); - } - } - - private void doSetVisibilityInternal(final boolean visible) { - if (getActivity() == null) { - return; - } - getActivity().runOnUiThread(new Runnable() { - public void run() { - currentVisible = visible ? View.VISIBLE : View.INVISIBLE; - v.setVisibility(currentVisible); - if (visible) { - v.bringToFront(); - } - } - }); - } - - protected void deinitialize() { - if(!superPeerMode) { - Image i = generatePeerImage(); - setPeerImage(i); - super.deinitialize(); - synchronized (nativePeers) { - nativePeers.remove(this); - } - deinit(); - }else{ - Image img = generatePeerImage(); - if (img != null) { - peerImage = img; - } - - if(myView instanceof AndroidAsyncView){ - ((AndroidAsyncView)myView).removePeerView(v); - } - super.deinitialize(); - } - } - - public void deinit(){ - if (getActivity() == null) { - return; - } - if (peerImage == null) { - peerImage = generatePeerImage(); - } - final boolean [] removed = new boolean[1]; - getActivity().runOnUiThread(new Runnable() { - public void run() { - try { - if (layoutWrapper != null && AndroidImplementation.this.relativeLayout != null) { - AndroidImplementation.this.relativeLayout.removeView(layoutWrapper); - AndroidImplementation.this.relativeLayout.requestLayout(); - layoutWrapper = null; - } - } finally { - removed[0] = true; - } - } - }); - while (!removed[0]) { - Display.getInstance().invokeAndBlock(new Runnable() { - public void run() { - if (!removed[0]) { - try { - Thread.sleep(5); - } catch(InterruptedException er) {} - } - } - }); - } - } - - protected void initComponent() { - super.initComponent(); - if(!superPeerMode) { - synchronized (nativePeers) { - nativePeers.add(this); - } - init(); - setPeerImage(null); - } - } - - public void init(){ - if(superPeerMode || getActivity() == null) { - return; - } - runOnUiThreadAndBlock(new Runnable() { - public void run() { - if (layoutWrapper == null) { - /** - * wrap the native item in a layout that we can move - * around on the surface view as we like. - */ - layoutWrapper = new AndroidImplementation.AndroidRelativeLayout(activity, AndroidImplementation.AndroidPeer.this, v); - layoutWrapper.setBackgroundDrawable(null); - v.setVisibility(currentVisible); - v.setFocusable(AndroidImplementation.AndroidPeer.this.isFocusable()); - v.setFocusableInTouchMode(true); - ArrayList viewList = new ArrayList(); - viewList.add(layoutWrapper); - v.addFocusables(viewList, View.FOCUS_DOWN); - v.addFocusables(viewList, View.FOCUS_UP); - v.addFocusables(viewList, View.FOCUS_LEFT); - v.addFocusables(viewList, View.FOCUS_RIGHT); - if (v.isFocusable() || v.isFocusableInTouchMode()) { - if (AndroidImplementation.AndroidPeer.super.hasFocus()) { - AndroidImplementation.this.blockNativeFocusAll(true); - blockNativeFocus(false); - if (!v.hasFocus()) { - v.requestFocus(); - } - - } else { - blockNativeFocus(true); - } - layoutWrapper.setOnKeyListener(new View.OnKeyListener() { - public boolean onKey(View view, int i, KeyEvent ke) { - lastDirectionalKeyEventReceivedByWrapper = CodenameOneView.internalKeyCodeTranslate(ke.getKeyCode()); - - // move focus back to base view. - if (AndroidImplementation.this.myView == null) return false; - AndroidImplementation.this.myView.getAndroidView().requestFocus(); - - /** - * if the wrapper has focus, then only because - * the wrapped native component just lost focus. - * we consume whatever key events we receive, - * just to make sure no half press/release - * sequence reaches the base view (and therefore - * Codename One). - */ - return true; - } - }); - layoutWrapper.setOnFocusChangeListener(new View.OnFocusChangeListener() { - public void onFocusChange(View view, boolean bln) { - Log.d("Codename One", "on focus change. " + view.toString() + " focus:" + bln + " touchmode: " + v.isInTouchMode()); - } - }); - layoutWrapper.setOnTouchListener(new View.OnTouchListener() { - public boolean onTouch(View v, MotionEvent me) { - if (myView == null) return false; - return myView.getAndroidView().onTouchEvent(me); - } - }); - } - if(AndroidImplementation.this.relativeLayout != null){ - // not sure why this happens but we got an exception where add view was called with - // a layout that was already added... - if(layoutWrapper.getParent() != null) { - ((ViewGroup)layoutWrapper.getParent()).removeView(layoutWrapper); - } - AndroidImplementation.this.relativeLayout.addView(layoutWrapper); - } - } - } - }); - } - private Image peerImage; - public void paint(final Graphics g) { - if(superPeerMode) { - Object nativeGraphics = com.codename1.ui.Accessor.getNativeGraphics(g); - - Object o = v.getLayoutParams(); - AndroidAsyncView.LayoutParams lp; - if(o instanceof AndroidAsyncView.LayoutParams) { - lp = (AndroidAsyncView.LayoutParams) o; - if (lp == null) { - lp = new AndroidAsyncView.LayoutParams( - getX() + g.getTranslateX(), - getY() + g.getTranslateY(), - getWidth(), - getHeight(), AndroidPeer.this); - final AndroidAsyncView.LayoutParams finalLp = lp; - activity.runOnUiThread(new Runnable() { - @Override - public void run() { - v.setLayoutParams(finalLp); - } - }); - lp.dirty = true; - } else { - int x = getX() + g.getTranslateX(); - int y = getY() + g.getTranslateY(); - int w = getWidth(); - int h = getHeight(); - if (x != lp.x || y != lp.y || w != lp.w || h != lp.h) { - lp.dirty = true; - lp.x = x; - lp.y = y; - lp.w = w; - lp.h = h; - } - } - } else { - final AndroidAsyncView.LayoutParams finalLp = new AndroidAsyncView.LayoutParams( - getX() + g.getTranslateX(), - getY() + g.getTranslateY(), - getWidth(), - getHeight(), AndroidPeer.this); - activity.runOnUiThread(new Runnable() { - @Override - public void run() { - v.setLayoutParams(finalLp); - } - }); - finalLp.dirty = true; - lp = finalLp; - } - - // this is a mutable image or side menu etc. where the peer is drawn on a different form... - // Special case... - if(nativeGraphics.getClass() == AndroidGraphics.class) { - if(peerImage == null) { - peerImage = generatePeerImage(); - } - //systemOut("Drawing native image"); - g.drawImage(peerImage, getX(), getY()); - return; - } - synchronized(activePeers) { - activePeers.put(v, this); - } - ((AndroidGraphics) nativeGraphics).drawView(v, lp); - if (lightweightMode && peerImage != null) { - g.drawImage(peerImage, getX(), getY(), getWidth(), getHeight()); - } - } else { - super.paint(g); - } - } - - boolean _initialized() { - return isInitialized(); - } - - @Override - protected void onPositionSizeChange() { - if(!superPeerMode) { - Form f = getComponentForm(); - if (v.getVisibility() == View.INVISIBLE - && f != null - && Display.getInstance().getCurrent() == f) { - doSetVisibilityInternal(true); - return; - } - layoutPeer(); - } - } - - protected void layoutPeer(){ - if (getActivity() == null) { - return; - } - if(!superPeerMode) { - // called by Codename One EDT to position the native component. - activity.runOnUiThread(new Runnable() { - public void run() { - if (layoutWrapper != null) { - if (v.getVisibility() == View.VISIBLE) { - - RelativeLayout.LayoutParams layoutParams = layoutWrapper.createMyLayoutParams( - AndroidImplementation.AndroidPeer.this.getAbsoluteX(), - AndroidImplementation.AndroidPeer.this.getAbsoluteY(), - AndroidImplementation.AndroidPeer.this.getWidth(), - AndroidImplementation.AndroidPeer.this.getHeight()); - layoutWrapper.setLayoutParams(layoutParams); - if (AndroidImplementation.this.relativeLayout != null) { - AndroidImplementation.this.relativeLayout.requestLayout(); - } - - } - } - } - }); - } - } - - void blockNativeFocus(boolean block) { - if (layoutWrapper != null) { - layoutWrapper.setDescendantFocusability(block - ? ViewGroup.FOCUS_BLOCK_DESCENDANTS : ViewGroup.FOCUS_AFTER_DESCENDANTS); - } - } - - @Override - public boolean isFocusable() { - // EDT - if (v != null) { - return v.isFocusableInTouchMode() || v.isFocusable(); - } else { - return super.isFocusable(); - } - } - - @Override - public void onSetFocusable(final boolean focusable) { - // EDT - if (getActivity() == null) { - return; - } - getActivity().runOnUiThread(new Runnable() { - public void run() { - v.setFocusable(focusable); - } - }); - } - - @Override - protected void focusGained() { - Log.d("Codename One", "native focus gain"); - // EDT - super.focusGained(); - if (getActivity() == null) { - return; - } - getActivity().runOnUiThread(new Runnable() { - public void run() { - // allow this one to gain focus - blockNativeFocus(false); - if (!v.hasFocus()) { - if (v.isInTouchMode()) { - v.requestFocusFromTouch(); - } else { - v.requestFocus(); - } - } - } - }); - } - - @Override - protected void focusLost() { - Log.d("Codename One", "native focus loss"); - // EDT - super.focusLost(); - if (layoutWrapper != null && getActivity() != null) { - getActivity().runOnUiThread(new Runnable() { - public void run() { - if(isInitialized()) { - // request focus of the wrapper. that will trigger the - // android focus listener and move focus back to the - // base view. - layoutWrapper.requestFocus(); - } - } - }); - } - } - - public void release() { - deinitialize(); - } - - @Override - protected Dimension calcPreferredSize() { - int w = 1; - int h = 1; - Drawable d = v.getBackground(); - if (d != null) { - w = d.getMinimumWidth(); - h = d.getMinimumHeight(); - } - w = Math.max(v.getMeasuredWidth(), w); - h = Math.max(v.getMeasuredHeight(), h); - if (v instanceof TextView) { - TextView tv = (TextView)v; - w = (int) android.text.Layout.getDesiredWidth(((TextView) v).getText(), ((TextView) v).getPaint()); - int heightMeasureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED); - tv.measure(w, heightMeasureSpec); - h = (int)Math.max(h, tv.getMeasuredHeight()); - - - } - return new Dimension(w, h); - } - } - - /** - * inner class that wraps the native components. this is a useful thingy to - * handle focus stuff and buffering. - */ - class AndroidRelativeLayout extends RelativeLayout { - - private AndroidImplementation.AndroidPeer peer; - - public AndroidRelativeLayout(Context activity, AndroidImplementation.AndroidPeer peer, View v) { - super(activity); - - this.peer = peer; - this.setLayoutParams(createMyLayoutParams(peer.getAbsoluteX(), peer.getAbsoluteY(), - peer.getWidth(), peer.getHeight())); - if (v.getParent() != null) { - ((ViewGroup)v.getParent()).removeView(v); - } - this.addView(v, new RelativeLayout.LayoutParams( - RelativeLayout.LayoutParams.FILL_PARENT, - RelativeLayout.LayoutParams.FILL_PARENT)); - this.setDrawingCacheEnabled(false); - this.setAlwaysDrawnWithCacheEnabled(false); - this.setFocusable(true); - this.setFocusableInTouchMode(false); - this.setDescendantFocusability(ViewGroup.FOCUS_AFTER_DESCENDANTS); - - } - - /** - * create a layout parameter object that holds the native component's - * position. - * - * @return - */ - private RelativeLayout.LayoutParams createMyLayoutParams(int x, int y, int width, int height) { - RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams( - RelativeLayout.LayoutParams.WRAP_CONTENT, - RelativeLayout.LayoutParams.WRAP_CONTENT); - layoutParams.addRule(RelativeLayout.ALIGN_PARENT_LEFT); - layoutParams.addRule(RelativeLayout.ALIGN_PARENT_TOP); - layoutParams.width = width; - layoutParams.height = height; - layoutParams.leftMargin = x; - layoutParams.topMargin = y; - return layoutParams; - } - - @Override - public boolean dispatchKeyEvent(KeyEvent event) { - - int keycode = event.getKeyCode(); - keycode = CodenameOneView.internalKeyCodeTranslate(keycode); - if (keycode == AndroidImplementation.DROID_IMPL_KEY_BACK) { - switch (event.getAction()) { - case KeyEvent.ACTION_DOWN: - // Claim the gesture so the activity's - // OnBackInvokedCallback stands down; on Android 16 the - // platform can deliver both for one press. See - // PredictiveBackBridge. - PredictiveBackBridge.keyEventBackStarted(); - Display.getInstance().keyPressed(keycode); - break; - case KeyEvent.ACTION_UP: - PredictiveBackBridge.keyEventBackFinished(); - Display.getInstance().keyReleased(keycode); - break; - } - return true; - } else { - return super.dispatchKeyEvent(event); - } - } - - - } - - private boolean testedNativeTheme; - private boolean nativeThemeAvailable; - - public boolean hasNativeTheme() { - if (!testedNativeTheme) { - testedNativeTheme = true; - try { - InputStream is; - if (android.os.Build.VERSION.SDK_INT < 14 && !isTablet()) { - is = getResourceAsStream(getClass(), "/androidTheme.res"); - } else { - is = getResourceAsStream(getClass(), "/android_holo_light.res"); - } - nativeThemeAvailable = is != null; - if (is != null) { - is.close(); - } - } catch (IOException ex) { - ex.printStackTrace(); - } - } - return nativeThemeAvailable; - } - - /** - * Installs the native theme, this is only applicable if hasNativeTheme() - * returned true. Notice that this method might replace the - * DefaultLookAndFeel instance and the default transitions. - */ - public void installNativeTheme() { - hasNativeTheme(); - if (!nativeThemeAvailable) { - return; - } - try { - // Resolve desired theme flavor. and.themeMode is the per-platform - // hint (auto | modern | material | hololight | legacy); the legacy - // name cn1.androidTheme is still honored for back-compat. The - // cross-platform shortcut nativeTheme=modern/legacy (deprecated - // alias: cn1.nativeTheme) feeds in when no platform-specific hint - // is set. Default stays on android_holo_light - what master - // shipped and what existing screenshot goldens are anchored - // against. The ancient pre-Holo androidTheme.res is only reached - // via explicit and.hololight=true (historical back-compat) or - // and.themeMode=legacy. - Display d = Display.getInstance(); - String mode = d.getProperty("and.themeMode", - d.getProperty("cn1.androidTheme", null)); - if (mode == null) { - String shared = d.getProperty("nativeTheme", - d.getProperty("cn1.nativeTheme", null)); - if ("modern".equalsIgnoreCase(shared)) { - mode = "material"; - } else if ("legacy".equalsIgnoreCase(shared)) { - mode = "hololight"; - } else if ("true".equalsIgnoreCase(d.getProperty("and.hololight", "false"))) { - mode = "legacy"; - } else { - mode = "hololight"; - } - } else { - mode = mode.toLowerCase(); - } - - String resPath; - if ("material".equals(mode) || "modern".equals(mode) || "auto".equals(mode)) { - resPath = "/AndroidMaterialTheme.res"; - } else if ("hololight".equals(mode) || "holo".equals(mode)) { - resPath = "/android_holo_light.res"; - } else { - resPath = "/androidTheme.res"; - } - - InputStream is = getResourceAsStream(getClass(), resPath); - if (is == null) { - // Modern theme may not be in the apk if the framework build - // skipped native-themes generation. Fall back to Holo Light - // (master's default) so the app still boots with a known look. - is = getResourceAsStream(getClass(), "/android_holo_light.res"); - } - Resources r = Resources.open(is); - Hashtable h = r.getTheme(r.getThemeResourceNames()[0]); - h.put("@commandBehavior", "Native"); - UIManager.getInstance().setThemeProps(h); - is.close(); - Display.getInstance().setCommandBehavior(Display.COMMAND_BEHAVIOR_NATIVE); - } catch (IOException ex) { - ex.printStackTrace(); - } - } - - public boolean isNativeBrowserComponentSupported() { - return true; - } - - @Override - public void setNativeBrowserScrollingEnabled(final PeerComponent browserPeer, final boolean e) { - super.setNativeBrowserScrollingEnabled(browserPeer, e); - if (getActivity() == null) { - return; - } - getActivity().runOnUiThread(new Runnable() { - public void run() { - AndroidBrowserComponent bc = (AndroidBrowserComponent)browserPeer; - bc.setScrollingEnabled(e); - } - }); - } - - @Override - public void setPinchToZoomEnabled(final PeerComponent browserPeer, final boolean e) { - super.setPinchToZoomEnabled(browserPeer, e); - if (getActivity() == null) { - return; - } - getActivity().runOnUiThread(new Runnable() { - public void run() { - AndroidBrowserComponent bc = (AndroidBrowserComponent)browserPeer; - bc.setPinchZoomEnabled(e); - } - }); - } - - public PeerComponent createBrowserComponent(final Object parent) { - if (getActivity() == null) { - return null; - } - final AndroidImplementation.AndroidBrowserComponent[] bc = new AndroidImplementation.AndroidBrowserComponent[1]; - final Throwable[] error = new Throwable[1]; - final Object lock = new Object(); - - getActivity().runOnUiThread(new Runnable() { - @Override - public void run() { - - synchronized (lock) { - try { - WebView wv = new WebView(getActivity()) { - - @Override - public boolean dispatchKeyEvent(KeyEvent event) { - - int keycode = event.getKeyCode(); - keycode = CodenameOneView.internalKeyCodeTranslate(keycode); - if (keycode == AndroidImplementation.DROID_IMPL_KEY_BACK || - (keycode == KeyEvent.KEYCODE_MENU && - Display.getInstance().getCommandBehavior() != Display.COMMAND_BEHAVIOR_NATIVE)) { - boolean backKey = - keycode == AndroidImplementation.DROID_IMPL_KEY_BACK; - switch (event.getAction()) { - case KeyEvent.ACTION_DOWN: - // Claim the gesture so the - // activity's OnBackInvokedCallback - // stands down; on Android 16 the - // platform can deliver both for one - // press. See PredictiveBackBridge. - if (backKey) { - PredictiveBackBridge.keyEventBackStarted(); - } - Display.getInstance().keyPressed(keycode); - break; - case KeyEvent.ACTION_UP: - if (backKey) { - PredictiveBackBridge.keyEventBackFinished(); - } - Display.getInstance().keyReleased(keycode); - break; - } - return true; - } else { - if(Display.getInstance().getProperty( - "android.propogateKeyEvents", "false"). - equalsIgnoreCase("true") && - myView instanceof AndroidAsyncView) { - switch (event.getAction()) { - case KeyEvent.ACTION_DOWN: - Display.getInstance().keyPressed(keycode); - break; - case KeyEvent.ACTION_UP: - Display.getInstance().keyReleased(keycode); - break; - } - return true; - } - - return super.dispatchKeyEvent(event); - } - } - }; - wv.setOnTouchListener(new View.OnTouchListener() { - - @Override - public boolean onTouch(View v, MotionEvent event) { - switch (event.getAction()) { - case MotionEvent.ACTION_DOWN: - case MotionEvent.ACTION_UP: - if (!v.hasFocus()) { - v.requestFocus(); - } - break; - } - return false; - } - }); - - if (android.os.Build.VERSION.SDK_INT >= 19) { - if ("true".equals(Display.getInstance().getProperty("android.webContentsDebuggingEnabled", "false"))) { - wv.setWebContentsDebuggingEnabled(true); - } - } - wv.getSettings().setDomStorageEnabled(true); - wv.getSettings().setAllowFileAccess(true); - wv.getSettings().setAllowContentAccess(true); - wv.requestFocus(View.FOCUS_DOWN); - wv.setFocusableInTouchMode(true); - if (android.os.Build.VERSION.SDK_INT >= 17) { - wv.getSettings().setMediaPlaybackRequiresUserGesture(false); - } - bc[0] = new AndroidImplementation.AndroidBrowserComponent(wv, getActivity(), parent); - lock.notify(); - } catch (Throwable t) { - error[0] = t; - lock.notify(); - } - } - } - }); - while (bc[0] == null && error[0] == null) { - Display.getInstance().invokeAndBlock(new Runnable() { - public void run() { - synchronized (lock) { - if (bc[0] == null && error[0] == null) { - try { - lock.wait(20); - } catch (InterruptedException ex) { - ex.printStackTrace(); - } - } - } - } - - }); - } - if (error[0] != null) { - throw new RuntimeException(error[0]); - } - return bc[0]; - } - - public void setBrowserProperty(PeerComponent browserPeer, String key, Object value) { - ((AndroidImplementation.AndroidBrowserComponent) browserPeer).setProperty(key, value); - } - - public String getBrowserTitle(PeerComponent browserPeer) { - return ((AndroidImplementation.AndroidBrowserComponent) browserPeer).getTitle(); - } - - public String getBrowserURL(PeerComponent browserPeer) { - return ((AndroidImplementation.AndroidBrowserComponent) browserPeer).getURL(); - } - - @Override - public void setBrowserURL(PeerComponent browserPeer, String url, Map headers) { - if (url.startsWith("jar:")) { - url = url.substring(6); - if(url.indexOf("/") != 0) { - url = "/"+url; - } - - url = "file:///android_asset"+url; - } - AndroidImplementation.AndroidBrowserComponent bc = (AndroidImplementation.AndroidBrowserComponent) browserPeer; - if(bc.parent.fireBrowserNavigationCallbacks(url)) { - bc.setURL(url, headers); - } - } - - @Override - public boolean isURLWithCustomHeadersSupported() { - return true; - } - - @Override - public void setBrowserURL(PeerComponent browserPeer, String url) { - setBrowserURL(browserPeer, url, null); - } - - public void browserStop(PeerComponent browserPeer) { - ((AndroidImplementation.AndroidBrowserComponent) browserPeer).stop(); - } - - public void browserDestroy(PeerComponent browserPeer) { - ((AndroidImplementation.AndroidBrowserComponent) browserPeer).destroy(); - } - - /** - * Reload the current page - * - * @param browserPeer browser instance - */ - public void browserReload(PeerComponent browserPeer) { - ((AndroidImplementation.AndroidBrowserComponent) browserPeer).reload(); - } - - /** - * Indicates whether back is currently available - * - * @param browserPeer browser instance - * @return true if back should work - */ - public boolean browserHasBack(PeerComponent browserPeer) { - return ((AndroidImplementation.AndroidBrowserComponent) browserPeer).hasBack(); - } - - public boolean browserHasForward(PeerComponent browserPeer) { - return ((AndroidImplementation.AndroidBrowserComponent) browserPeer).hasForward(); - } - - public void browserBack(PeerComponent browserPeer) { - ((AndroidImplementation.AndroidBrowserComponent) browserPeer).back(); - } - - public void browserForward(PeerComponent browserPeer) { - ((AndroidImplementation.AndroidBrowserComponent) browserPeer).forward(); - } - - public void browserClearHistory(PeerComponent browserPeer) { - ((AndroidImplementation.AndroidBrowserComponent) browserPeer).clearHistory(); - } - - public void setBrowserPage(PeerComponent browserPeer, String html, String baseUrl) { - ((AndroidImplementation.AndroidBrowserComponent) browserPeer).setPage(html, baseUrl); - } - - public void browserExposeInJavaScript(PeerComponent browserPeer, Object o, String name) { - ((AndroidImplementation.AndroidBrowserComponent) browserPeer).exposeInJavaScript(o, name); - } - - private boolean useEvaluateJavascript() { - return android.os.Build.VERSION.SDK_INT >= 19; - } - - - private int jsCallbackIndex=0; - - private void execJSUnsafe(WebView web, String js) { - if (useEvaluateJavascript()) { - web.evaluateJavascript(js, null); - } else { - web.loadUrl("javascript:(function(){"+js+"})()"); - } - } - - private void execJSSafe(final WebView web, final String js) { - if (useJSDispatchThread()) { - runOnJSDispatchThread(new Runnable() { - public void run() { - getActivity().runOnUiThread(new Runnable() { - public void run() { - execJSUnsafe(web, js); - } - }); - } - }); - } else { - getActivity().runOnUiThread(new Runnable() { - public void run() { - execJSUnsafe(web, js); - } - }); - } - } - - private void execJSUnsafe(final AndroidBrowserComponent bc, final String javaScript, final ValueCallback resultCallback) { - if (useEvaluateJavascript()) { - try { - bc.web.evaluateJavascript(javaScript, resultCallback); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - resultCallback.onReceiveValue(null); - } - } else { - jsCallbackIndex = (++jsCallbackIndex) % 1024; - int index = jsCallbackIndex; - - // The jsCallback is a special java object exposed to javascript that we use - // to return values from javascript to java. - synchronized (bc.jsCallback){ - // Initialize the return value to null - while (!bc.jsCallback.isIndexAvailable(index)) { - index++; - } - jsCallbackIndex = index+1; - } - final int fIndex = index; - // We are placing the javascript inside eval() so we need to escape - // the input. - String escaped = StringUtil.replaceAll(javaScript, "\\", "\\\\"); - escaped = StringUtil.replaceAll(escaped, "'", "\\'"); - - final String js = "javascript:(function(){" - - + "try{" - +bc.jsCallback.jsInit() - +bc.jsCallback.jsCleanup() - + AndroidBrowserComponentCallback.JS_RETURNVAL_VARNAME+"["+index+"]" - + "=eval('"+escaped +"');} catch (e){console.log(e)};" - + AndroidBrowserComponentCallback.JS_VAR_NAME+".addReturnValue(" + index+", ''+" - - + AndroidBrowserComponentCallback.JS_RETURNVAL_VARNAME+"["+index+"]" - + ");})()"; - - // Send the Javascript string via SetURL. - // NOTE!! This is sent asynchronously so we will need to wait for - // the result to come in. - bc.setURL(js, null); - if (resultCallback == null) { - return; - } - Thread t = new Thread(new Runnable() { - public void run() { - int maxTries = 500; - int tryCounter = 0; - - // If we are not on the EDT, then it is safe to just loop and wait. - while (!bc.jsCallback.isValueSet(fIndex) && tryCounter++ < maxTries) { - synchronized(bc.jsCallback){ - Util.wait(bc.jsCallback, 20); - } - } - - if (bc.jsCallback.isValueSet(fIndex)) { - String retval = bc.jsCallback.getReturnValue(fIndex); - bc.jsCallback.remove(fIndex); - resultCallback.onReceiveValue(retval != null ? JSONObject.quote(retval) : null); - - } else { - com.codename1.io.Log.e(new RuntimeException("Failed to execute javascript "+js+" after maximum wait time.")); - resultCallback.onReceiveValue(null); - } - } - }); - t.start(); - - } - } - - private void execJSSafe(final AndroidBrowserComponent bc, final String javaScript, final ValueCallback resultCallback) { - if (useJSDispatchThread()) { - runOnJSDispatchThread(new Runnable() { - public void run() { - getActivity().runOnUiThread(new Runnable() { - public void run() { - execJSUnsafe(bc, javaScript, resultCallback); - } - }); - } - }); - } else { - getActivity().runOnUiThread(new Runnable() { - public void run() { - execJSUnsafe(bc, javaScript, resultCallback); - } - }); - } - } - - - - @Override - public void browserExecute(final PeerComponent browserPeer, final String javaScript) { - final AndroidImplementation.AndroidBrowserComponent bc = (AndroidImplementation.AndroidBrowserComponent) browserPeer; - execJSSafe(bc.web, javaScript); - } - - private com.codename1.util.EasyThread jsDispatchThread; - private com.codename1.util.EasyThread jsDispatchThread() { - if (jsDispatchThread == null) { - jsDispatchThread = com.codename1.util.EasyThread.start("JS Dispatch Thread"); - } - return jsDispatchThread; - } - - private boolean useJSDispatchThread() { - - // Before version 24, we need a separate JS dispatch thread to prevent deadlocks - return true;//Build.VERSION.SDK_INT < 24; - } - - public boolean isJSDispatchThread() { - if (useJSDispatchThread()) { - return jsDispatchThread().isThisIt(); - } else { - return (Looper.getMainLooper().getThread() == Thread.currentThread()); - } - } - - public boolean runOnJSDispatchThread(Runnable r) { - if (isJSDispatchThread()) { - r.run(); - return true; - } - if (useJSDispatchThread()) { - jsDispatchThread().run(r); - } else { - getActivity().runOnUiThread(r); - } - return false; - } - - /** - * Executes javascript and returns a string result where appropriate. - * @param browserPeer - * @param javaScript - * @return - */ - @Override - public String browserExecuteAndReturnString(final PeerComponent browserPeer, final String javaScript) { - final AndroidImplementation.AndroidBrowserComponent bc = (AndroidImplementation.AndroidBrowserComponent) browserPeer; - final String[] result = new String[1]; - final boolean[] complete = new boolean[1]; - - execJSSafe(bc, javaScript, new ValueCallback() { - @Override - public void onReceiveValue(String value) { - synchronized(result) { - complete[0] = true; - result[0] = value; - result.notify(); - } - } - }); - synchronized(result) { - if (!complete[0]) { - Util.wait(result, 10000); - } - } - if (result[0] == null) { - return null; - } else { - org.json.JSONTokener tok = new org.json.JSONTokener("{\"result\":"+result[0]+"}"); - try { - JSONObject jso = new JSONObject(tok); - return jso.getString("result"); - } catch (Throwable ex) { - com.codename1.io.Log.e(ex); - return null; - } - - } - - - } - - public boolean supportsBrowserExecuteAndReturnString(PeerComponent browserPeer) { - return true; - } - - public boolean canForceOrientation() { - return true; - } - - public void lockOrientation(boolean portrait) { - if (getActivity() == null) { - return; - } - if(portrait){ - getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); - }else{ - getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); - } - } - - public void unlockOrientation() { - if (getActivity() == null) { - return; - } - getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR); - } - - - - public boolean isAffineSupported() { - return true; - } - - public void resetAffine(Object nativeGraphics) { - ((AndroidGraphics) nativeGraphics).resetAffine(); - } - - public void scale(Object nativeGraphics, float x, float y) { - ((AndroidGraphics) nativeGraphics).scale(x, y); - } - - public void rotate(Object nativeGraphics, float angle) { - ((AndroidGraphics) nativeGraphics).rotate(angle); - } - - public void rotate(Object nativeGraphics, float angle, int x, int y) { - ((AndroidGraphics) nativeGraphics).rotate(angle, x, y); - } - - @Override - public void pushClip(Object graphics) { - ((AndroidGraphics) graphics).pushClip(); - } - - @Override - public void popClip(Object graphics) { - ((AndroidGraphics) graphics).popClip(); - } - - @Override - public boolean isTranslateMatrixSupported() { - return true; - } - - @Override - public void translateMatrix(Object nativeGraphics, float x, float y) { - ((AndroidGraphics) nativeGraphics).translateMatrix(x, y); - } - - public void shear(Object nativeGraphics, float x, float y) { - } - - public boolean isTablet() { - return (getContext().getResources().getConfiguration().screenLayout - & Configuration.SCREENLAYOUT_SIZE_MASK) - >= Configuration.SCREENLAYOUT_SIZE_LARGE; - } - - // Foldable / device posture, backed by androidx.window via reflection. The androidx.window - // dependency is only present when the app opts in with the android.foldableSupport build hint; - // when absent these all degrade safely to "not foldable". The tracker is started lazily so it - // only spins up for apps that query the posture APIs. - @Override - public boolean isFoldable() { - AndroidFoldablePosture.start(getActivity()); - return AndroidFoldablePosture.isFoldable(); - } - - @Override - public int getDevicePosture() { - AndroidFoldablePosture.start(getActivity()); - return AndroidFoldablePosture.getPosture(); - } - - @Override - public int getFoldOrientation() { - AndroidFoldablePosture.start(getActivity()); - return AndroidFoldablePosture.getFoldOrientation(); - } - - @Override - public boolean isPostureSeparating() { - AndroidFoldablePosture.start(getActivity()); - return AndroidFoldablePosture.isSeparating(); - } - - @Override - public com.codename1.ui.geom.Rectangle getFoldBounds(com.codename1.ui.geom.Rectangle rect) { - AndroidFoldablePosture.start(getActivity()); - return AndroidFoldablePosture.getFoldBounds(rect); - } - - private Boolean watchCache; - - @Override - public boolean isWatch() { - if(watchCache == null) { - // PackageManager.FEATURE_WATCH ("android.hardware.type.watch") is - // the canonical Wear OS marker; use the string literal so this - // compiles regardless of the configured minimum SDK level. - watchCache = getContext().getPackageManager() - .hasSystemFeature("android.hardware.type.watch"); - } - return watchCache; - } - - private Boolean tvCache; - - @Override - public boolean isTV() { - if(tvCache == null) { - // PackageManager.FEATURE_TELEVISION ("android.hardware.type.television") - // and FEATURE_LEANBACK ("android.software.leanback") are the canonical - // Android TV / Google TV markers; use the string literals so this - // compiles regardless of the configured minimum SDK level. - android.content.pm.PackageManager pm = getContext().getPackageManager(); - boolean tv = pm.hasSystemFeature("android.hardware.type.television") - || pm.hasSystemFeature("android.software.leanback"); - if(!tv) { - // Fall back to the runtime UI mode (covers emulators/devices that - // expose the TV ui-mode without declaring the hardware feature). - android.app.UiModeManager um = (android.app.UiModeManager) - getContext().getSystemService(Context.UI_MODE_SERVICE); - tv = um != null && um.getCurrentModeType() - == Configuration.UI_MODE_TYPE_TELEVISION; - } - tvCache = tv; - } - return tvCache; - } - - @Override - public com.codename1.car.spi.CarBridge getCarBridge() { - // The Android Auto glue (injected by the builder only when the app references - // com.codename1.car) registers its bridge here; null otherwise so the API no-ops. - return AndroidCarSupport.getBridge(); - } - - @Override - public boolean isCarConnected() { - com.codename1.car.spi.CarBridge b = AndroidCarSupport.getBridge(); - return b != null && b.isConnected(); - } - - @Override - public com.codename1.wearable.spi.WearableBridge getWearableBridge() { - // The Wearable Data Layer glue is injected by the builder only when the app references - // com.codename1.wearable; without it this is null and the API no-ops. - Context ctx = getContext(); - return ctx == null ? null : AndroidWearableSupport.getBridge(ctx); - } - - private com.codename1.surfaces.spi.SurfaceBridge surfaceBridge; - - @Override - public com.codename1.surfaces.spi.SurfaceBridge getSurfaceBridge() { - if (surfaceBridge == null) { - surfaceBridge = new com.codename1.impl.android.surfaces.AndroidSurfaceBridge(); - } - return surfaceBridge; - } - - private com.codename1.documents.spi.DocumentProviderBridge documentProviderBridge; - - @Override - public com.codename1.documents.spi.DocumentProviderBridge getDocumentProviderBridge() { - if (documentProviderBridge == null) { - documentProviderBridge = - new com.codename1.impl.android.documents.AndroidDocumentProviderBridge(); - } - return documentProviderBridge; - } - - private com.codename1.continuity.spi.ContinuityBridge continuityBridge; - - /// Returns the continuity bridge, which on Android exists for one job: - /// flushing the state checkpoint when the platform says the process may - /// be killed. Neither cross-device capability exists here and both report - /// themselves unsupported. - /// - /// Synchronized for the reason the intent bridge is: two callers arriving - /// together would each construct one, and each construction registers a - /// lifecycle listener -- so the loser's listener would stay registered and - /// the app would checkpoint twice on every save. - @Override - public synchronized com.codename1.continuity.spi.ContinuityBridge getContinuityBridge() { - if (continuityBridge == null) { - continuityBridge = - new com.codename1.impl.android.continuity.AndroidContinuityBridge(); - } - return continuityBridge; - } - - private com.codename1.intents.spi.IntentBridge intentBridge; - - @Override - // Synchronized for the same reason as the JavaSE bridge: two callers arriving together - // each see a null field and each construct one, and whichever loses the assignment keeps - // the donation or the indexed entities that were recorded through it. Nothing throws. - public synchronized com.codename1.intents.spi.IntentBridge getIntentBridge() { - if (intentBridge == null) { - intentBridge = new com.codename1.impl.android.intents.AndroidIntentBridge(); - } - return intentBridge; - } - - private AndroidHomeBridge homeBridge; - - /// Returns the smart-home bridge. Always returned rather than - /// conditionally null: the bridge answers honestly through - /// {@link AndroidSmartHomeSupport}, which is empty unless the builder - /// injected a delegate, so {@code SmartHome} reports NOT_SUPPORTED - /// without this getter needing to know how the app was built. - /// - /// Note that a delegate being present does not mean the graph is - /// readable. The ordinary Android answer is - /// {@code HomeAvailability.COMMISSIONING_ONLY}: Play services can add a - /// Matter accessory with no setup at all, while reading or controlling - /// one needs the Google Home APIs and a Google Cloud project only the - /// app's developer can create. - @Override - public com.codename1.home.spi.HomeBridge getHomeBridge() { - if (homeBridge == null) { - homeBridge = new AndroidHomeBridge(); - } - return homeBridge; - } - - /// Invoked once the app has started (from the generated stub, next to - /// `deliverPendingSharedContent`) to flush surface actions that arrived through the - /// `CN1SurfaceActionActivity` trampoline before the app instance existed. - public static void deliverPendingSurfaceActions() { - com.codename1.impl.android.surfaces.AndroidSurfaceBridge.deliverPendingActions(); - } - - /// Invoked once the app has started (from the generated stub, beside - /// `deliverPendingSurfaceActions`) to run intent requests the trampoline parked rather than - /// dispatched. - /// - /// A non-headless handler is allowed to touch a `Form`, so the launcher tap can only ask for - /// the app to be brought forward; running the handler has to wait until it is. - public static void deliverPendingIntentRequests() { - // Order matters. The generated bootstrap installs the dispatcher before startContext - // has produced a bridge, so publication is deferred -- and until it happens the bridge - // never sees registerIntents, which is what judges a request the trampoline parked at a - // cold start. Draining the foreground queue alone left such a shortcut opening the app - // and running nothing. - com.codename1.intents.Intents.publishPendingDeclarations(); - com.codename1.impl.android.intents.AndroidIntentBridge.deliverPendingForegroundRequests(); - } - - /** - * Executes r on the UI thread and blocks the EDT to completion - * @param r runnable to execute - */ - public static void runOnUiThreadAndBlock(final Runnable r) { - if (getActivity() == null) { - throw new RuntimeException("Cannot run on UI thread because getActivity() is null. This generally means we are running inside a service in the background so UI access is disabled."); - } - - final boolean[] completed = new boolean[1]; - getActivity().runOnUiThread(new Runnable() { - @Override - public void run() { - try { - r.run(); - } catch(Throwable t) { - com.codename1.io.Log.e(t); - } - synchronized(completed) { - completed[0] = true; - completed.notify(); - } - } - }); - Display.getInstance().invokeAndBlock(new Runnable() { - @Override - public void run() { - synchronized(completed) { - while(!completed[0]) { - try { - completed.wait(); - } catch(InterruptedException err) {} - } - } - } - }); - } - - public static void runOnUiThreadSync(final Runnable r) { - if (getActivity() == null) { - throw new RuntimeException("Cannot run on UI thread because getActivity() is null. This generally means we are running inside a service in the background so UI access is disabled."); - } - - final boolean[] completed = new boolean[1]; - getActivity().runOnUiThread(new Runnable() { - @Override - public void run() { - try { - r.run(); - } catch(Throwable t) { - com.codename1.io.Log.e(t); - } - synchronized(completed) { - completed[0] = true; - completed.notify(); - } - } - }); - synchronized(completed) { - while(!completed[0]) { - try { - completed.wait(); - } catch(InterruptedException err) {} - } - } - } - - - public int convertToPixels(int dipCount, boolean horizontal) { - DisplayMetrics dm = getContext().getResources().getDisplayMetrics(); - float ppi = dm.density * 160f; - return (int) (((float) dipCount) / 25.4f * ppi); - } - - public boolean isPortrait() { - int orientation = getContext().getResources().getConfiguration().orientation; - if (orientation == Configuration.ORIENTATION_UNDEFINED - || orientation == Configuration.ORIENTATION_SQUARE) { - return super.isPortrait(); - } - return orientation == Configuration.ORIENTATION_PORTRAIT; - } - - /** - * Checks if this platform supports sharing cookies between Native components (e.g. BrowserComponent) - * and ConnectionRequests. Currently only Android and iOS ports support this. - * @return - */ - @Override - public boolean isNativeCookieSharingSupported() { - return true; - } - - @Override - public void clearNativeCookies() { - CookieManager mgr = getCookieManager(); - mgr.removeAllCookie(); - } - private static CookieManager cookieManager; - private static synchronized CookieManager getCookieManager() { - if (android.os.Build.VERSION.SDK_INT > 28) { - return CookieManager.getInstance(); - } - if (cookieManager == null) { - CookieSyncManager.createInstance(getContext()); // Fixes a crash on Android 4.3 - // https://stackoverflow.com/a/20552998/2935174 - cookieManager = CookieManager.getInstance(); - } - return CookieManager.getInstance(); - } - - @Override - public Vector getCookiesForURL(String url) { - if (isUseNativeCookieStore()) { - try { - URI uri = new URI(url); - - - CookieManager mgr = getCookieManager(); - mgr.removeExpiredCookie(); - String domain = uri.getHost(); - String cookieStr = mgr.getCookie(url); - if (cookieStr != null) { - String[] cookies = cookieStr.split(";"); - int len = cookies.length; - Vector out = new Vector(); - for (int i = 0; i < len; i++) { - Cookie c = new Cookie(); - String[] parts = cookies[i].split("="); - c.setName(parts[0].trim()); - if (parts.length > 1) { - c.setValue(parts[1].trim()); - } else { - c.setValue(""); - } - c.setDomain(domain); - out.add(c); - } - return out; - } - } catch (Exception ex) { - com.codename1.io.Log.e(ex); - } - return new Vector(); - } - return super.getCookiesForURL(url); - } - - public class WebAppInterface { - BrowserComponent bc; - /** Instantiate the interface and set the context */ - WebAppInterface(BrowserComponent bc) { - this.bc = bc; - } - - @JavascriptInterface // must be added for API 17 or higher - public boolean shouldNavigate(String url) { - return bc.fireBrowserNavigationCallbacks(url); - } - } - - class AndroidBrowserComponent extends AndroidImplementation.AndroidPeer { - - private Activity act; - private WebView web; - private BrowserComponent parent; - private boolean scrollingEnabled = true; - protected AndroidBrowserComponentCallback jsCallback; - private boolean lightweightMode = false; - private ProgressDialog progressBar; - private boolean hideProgress; - private int layerType; - - - public AndroidBrowserComponent(final WebView web, Activity act, Object p) { - super(web); - if(!superPeerMode) { - doSetVisibility(false); - } - parent = (BrowserComponent) p; - this.web = web; - layerType = web.getLayerType(); - web.getSettings().setJavaScriptEnabled(true); - web.getSettings().setSupportZoom(parent.isPinchToZoomEnabled()); - this.act = act; - jsCallback = new AndroidBrowserComponentCallback(); - hideProgress = Display.getInstance().getProperty("WebLoadingHidden", "false").equals("true"); - - web.addJavascriptInterface(jsCallback, AndroidBrowserComponentCallback.JS_VAR_NAME); - web.addJavascriptInterface(new WebAppInterface(parent), "cn1application"); - if (android.os.Build.VERSION.SDK_INT >= 21) { - CookieManager.getInstance().setAcceptThirdPartyCookies(web, true); - } - - web.setWebViewClient(new WebViewClient() { - - - - public void onLoadResource(WebView view, String url) { - if (Display.getInstance().getProperty("syncNativeCookies", "false").equals("true")) { - try { - URI uri = new URI(url); - CookieManager mgr = getCookieManager(); - mgr.removeExpiredCookie(); - String domain = uri.getHost(); - removeCookiesForDomain(domain); - String cookieStr = mgr.getCookie(url); - if (cookieStr != null) { - String[] cookies = cookieStr.split(";"); - int len = cookies.length; - ArrayList out = new ArrayList(); - for (int i = 0; i < len; i++) { - Cookie c = new Cookie(); - String[] parts = cookies[i].split("="); - c.setName(parts[0].trim()); - if (parts.length > 1) { - c.setValue(parts[1].trim()); - } else { - c.setValue(""); - } - c.setDomain(domain); - out.add(c); - } - Cookie[] cookiesArr = new Cookie[out.size()]; - out.toArray(cookiesArr); - AndroidImplementation.this.addCookie(cookiesArr, false); - } - - } catch (URISyntaxException ex) { - - } - } - parent.fireWebEvent("onLoadResource", new ActionEvent(url)); - super.onLoadResource(view, url); - setShouldCalcPreferredSize(true); - } - - @Override - public void onPageStarted(WebView view, String url, Bitmap favicon) { - if (getActivity() == null) { - return; - } - - parent.fireWebEvent("onStart", new ActionEvent(url)); - super.onPageStarted(view, url, favicon); - dismissProgress(); - //show the progress only if there is no ActionBar - if(!hideProgress && !isNativeTitle()){ - progressBar = ProgressDialog.show(getActivity(), null, "Loading..."); - //if the page hasn't finished for more the 10 sec, dismiss - //the dialog - Timer t= new Timer(); - t.schedule(new TimerTask() { - @Override - public void run() { - dismissProgress(); - } - }, 10000); - } - } - - public void onPageFinished(WebView view, String url) { - parent.fireWebEvent("onLoad", new ActionEvent(url)); - super.onPageFinished(view, url); - setShouldCalcPreferredSize(true); - dismissProgress(); - } - - private void dismissProgress() { - if (progressBar != null && progressBar.isShowing()) { - progressBar.dismiss(); - Display.getInstance().callSerially(new Runnable() { - - public void run() { - setVisible(true); - repaint(); - } - }); - } - } - - public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) { - parent.fireWebEvent("onError", new ActionEvent(description, errorCode)); - super.onReceivedError(view, errorCode, description, failingUrl); - super.shouldOverrideKeyEvent(view, null); - dismissProgress(); - } - - public boolean shouldOverrideKeyEvent(WebView view, KeyEvent event) { - int keyCode = event.getKeyCode(); - if (keyCode == KeyEvent.KEYCODE_BACK || keyCode == KeyEvent.KEYCODE_MENU) { - return true; - } - - return super.shouldOverrideKeyEvent(view, event); - } - - public boolean shouldOverrideUrlLoading(WebView view, String url) { - if (url.startsWith("jar:")) { - setURL(url, null); - return true; - } - - // this will fail if dial permission isn't declared - if(url.startsWith("tel:")) { - if(parent.fireBrowserNavigationCallbacks(url)) { - try { - Intent dialer = new Intent(android.content.Intent.ACTION_DIAL, Uri.parse(url)); - getContext().startActivity(dialer); - } catch(Throwable t) {} - } - return true; - } - // this will fail if dial permission isn't declared - if(url.startsWith("mailto:")) { - if(parent.fireBrowserNavigationCallbacks(url)) { - try { - Intent emailIntent = new Intent(Intent.ACTION_SENDTO, Uri.parse(url)); - getContext().startActivity(emailIntent); - } catch(Throwable t) {} - } - return true; - } - return !parent.fireBrowserNavigationCallbacks(url); - } - - - }); - - web.setWebChromeClient(new WebChromeClient(){ - // For 3.0+ Devices (Start) - // onActivityResult attached before constructor - protected void openFileChooser(ValueCallback uploadMsg, String acceptType) - { - mUploadMessage = uploadMsg; - Intent i = new Intent(Intent.ACTION_GET_CONTENT); - i.addCategory(Intent.CATEGORY_OPENABLE); - i.setType(acceptType); - AndroidNativeUtil.getActivity().startActivityForResult(Intent.createChooser(i, "File Browser"), FILECHOOSER_RESULTCODE); - } - - - // For Lollipop 5.0+ Devices - public boolean onShowFileChooser(WebView mWebView, ValueCallback filePathCallback, WebChromeClient.FileChooserParams fileChooserParams) - { - if (uploadMessage != null) { - uploadMessage.onReceiveValue(null); - uploadMessage = null; - } - - uploadMessage = filePathCallback; - - Intent intent = fileChooserParams.createIntent(); - try - { - AndroidNativeUtil.getActivity().startActivityForResult(intent, REQUEST_SELECT_FILE); - } catch (ActivityNotFoundException e) - { - uploadMessage = null; - Toast.makeText(getActivity().getApplicationContext(), "Cannot Open File Chooser", Toast.LENGTH_LONG).show(); - return false; - } - return true; - } - - //For Android 4.1 only - protected void openFileChooser(ValueCallback uploadMsg, String acceptType, String capture) - { - mUploadMessage = uploadMsg; - Intent intent = new Intent(Intent.ACTION_GET_CONTENT); - intent.addCategory(Intent.CATEGORY_OPENABLE); - intent.setType(acceptType); - - AndroidNativeUtil.getActivity().startActivityForResult(Intent.createChooser(intent, "File Browser"), FILECHOOSER_RESULTCODE); - } - - protected void openFileChooser(ValueCallback uploadMsg) - { - mUploadMessage = uploadMsg; - Intent i = new Intent(Intent.ACTION_GET_CONTENT); - i.addCategory(Intent.CATEGORY_OPENABLE); - i.setType("image/*"); - AndroidNativeUtil.getActivity().startActivityForResult(Intent.createChooser(i, "File Chooser"), FILECHOOSER_RESULTCODE); - } - - - @Override - public boolean onConsoleMessage(ConsoleMessage consoleMessage) { - com.codename1.io.Log.p("["+consoleMessage.messageLevel()+"] "+consoleMessage.message()+" On line "+consoleMessage.lineNumber()+" of "+consoleMessage.sourceId()); - return true; - } - - @Override - public void onProgressChanged(WebView view, int newProgress) { - parent.fireWebEvent("Progress", new ActionEvent(parent, ActionEvent.Type.Progress, newProgress)); - if(!hideProgress && isNativeTitle() && getCurrentForm() != null && getCurrentForm().getTitle() != null && getCurrentForm().getTitle().length() > 0 ){ - if(getActivity() != null){ - try{ - getActivity().setProgressBarVisibility(true); - getActivity().setProgress(newProgress * 100); - if(newProgress == 100){ - getActivity().setProgressBarVisibility(false); - } - }catch(Throwable t){ - } - } - } - } - - @Override - public void onGeolocationPermissionsShowPrompt(String origin, - GeolocationPermissions.Callback callback) { - // Always grant permission since the app itself requires location - // permission and the user has therefore already granted it - callback.invoke(origin, true, false); - } - - @Override - public void onPermissionRequest(final PermissionRequest request) { - - Log.d("Codename One", "onPermissionRequest"); - getActivity().runOnUiThread(new Runnable() { - @TargetApi(Build.VERSION_CODES.LOLLIPOP) - @Override - public void run() { - String allowedOrigins = Display.getInstance().getProperty("android.WebView.grantPermissionsFrom", null); - if (allowedOrigins != null) { - String[] origins = Util.split(allowedOrigins, " "); - boolean allowed = false; - for (String origin : origins) { - if (request.getOrigin().toString().equals(origin)) { - allowed = true; - break; - } - } - if (allowed) { - Log.d("Codename One", "Allowing permission for "+Arrays.toString(request.getResources())+" in web view for origin "+request.getOrigin()); - request.grant(request.getResources()); - } else { - Log.d("Codename One", "Denying permission for "+Arrays.toString(request.getResources())+" in web view for origin "+request.getOrigin()); - request.deny(); - } - } - - } - }); - } - }); - } - - @Override - protected void initComponent() { - if(android.os.Build.VERSION.SDK_INT == 21 && web.getLayerType() != layerType){ - act.runOnUiThread(new Runnable() { - @Override - public void run() { - web.setLayerType(layerType, null); //setting layer type to original state - } - }); - } - super.initComponent(); - blockNativeFocus(false); - setPeerImage(null); - } - - - @Override - protected Image generatePeerImage() { - try { - final Bitmap nativeBuffer = Bitmap.createBitmap( - getWidth(), getHeight(), Bitmap.Config.ARGB_8888); - Image image = new AndroidImplementation.NativeImage(nativeBuffer); - getActivity().runOnUiThread(new Runnable() { - @Override - public void run() { - try { - Canvas canvas = new Canvas(nativeBuffer); - web.draw(canvas); - } catch(Throwable t) { - t.printStackTrace(); - } - } - }); - return image; - } catch(Throwable t) { - t.printStackTrace(); - return Image.createImage(5, 5); - } - } - - protected boolean shouldRenderPeerImage() { - return lightweightMode || !isInitialized(); - } - - protected void setLightweightMode(boolean l) { - doSetVisibility(!l); - if (lightweightMode == l) { - return; - } - lightweightMode = l; - } - - - - public void setScrollingEnabled(final boolean enabled){ - this.scrollingEnabled = enabled; - act.runOnUiThread(new Runnable() { - public void run() { - web.setHorizontalScrollBarEnabled(enabled); - web.setVerticalScrollBarEnabled(enabled); - if ( !enabled ){ - web.setOnTouchListener(new View.OnTouchListener(){ - - @Override - public boolean onTouch(View view, MotionEvent me) { - return (me.getAction() == MotionEvent.ACTION_MOVE); - } - - }); - } else { - web.setOnTouchListener(null); - } - } - }); - - } - - public boolean isScrollingEnabled(){ - return scrollingEnabled; - } - - public void setProperty(final String key, final Object value) { - act.runOnUiThread(new Runnable() { - public void run() { - WebSettings s = web.getSettings(); - if(key.equalsIgnoreCase("useragent")) { - s.setUserAgentString((String)value); - return; - } - try { - s.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW); - } catch(Throwable t) { - // the method isn't available in Android 4.x - } - String methodName = "set" + key; - for (Method m : s.getClass().getMethods()) { - if (m.getName().equalsIgnoreCase(methodName) && m.getParameterTypes().length == 1) { - try { - m.invoke(s, value); - } catch (Exception ex) { - ex.printStackTrace(); - } - return; - } - } - } - }); - } - - public String getTitle() { - final String[] retVal = new String[1]; - final boolean[] complete = new boolean[1]; - act.runOnUiThread(new Runnable() { - public void run() { - try { - - retVal[0] = web.getTitle(); - } finally { - complete[0] = true; - } - } - }); - while (!complete[0]) { - Display.getInstance().invokeAndBlock(new Runnable() { - @Override - public void run() { - if (!complete[0]) { - try { - Thread.sleep(20); - } catch (InterruptedException ex) { - } - } - } - }); - } - return retVal[0]; - } - - public String getURL() { - final String[] retVal = new String[1]; - final boolean[] complete = new boolean[1]; - act.runOnUiThread(new Runnable() { - public void run() { - try { - retVal[0] = web.getUrl(); - } finally { - complete[0] = true; - } - } - }); - while (!complete[0]) { - Display.getInstance().invokeAndBlock(new Runnable() { - @Override - public void run() { - if (!complete[0]) { - try { - Thread.sleep(20); - } catch (InterruptedException ex) { - } - } - } - }); - } - return retVal[0]; - } - - public void setURL(final String url, final Map headers) { - act.runOnUiThread(new Runnable() { - public void run() { - if(headers != null) { - web.loadUrl(url, headers); - } else { - web.loadUrl(url); - } - } - }); - } - - public void reload() { - act.runOnUiThread(new Runnable() { - public void run() { - web.reload(); - } - }); - } - - public boolean hasBack() { - final Boolean [] retVal = new Boolean[1]; - final boolean[] complete = new boolean[1]; - - act.runOnUiThread(new Runnable() { - public void run() { - try { - retVal[0] = web.canGoBack(); - } finally { - complete[0] = true; - } - } - }); - while (!complete[0]) { - Display.getInstance().invokeAndBlock(new Runnable() { - @Override - public void run() { - if (!complete[0]) { - try { - Thread.sleep(20); - } catch (InterruptedException ex) { - } - } - } - }); - } - return retVal[0].booleanValue(); - } - - public boolean hasForward() { - final Boolean [] retVal = new Boolean[1]; - final boolean[] complete = new boolean[1]; - - act.runOnUiThread(new Runnable() { - public void run() { - try { - retVal[0] = web.canGoForward(); - } finally { - complete[0] = true; - } - } - }); - - while (!complete[0]) { - Display.getInstance().invokeAndBlock(new Runnable() { - @Override - public void run() { - if (!complete[0]) { - try { - Thread.sleep(20); - } catch (InterruptedException ex) { - } - } - } - }); - } - return retVal[0].booleanValue(); - } - - public void back() { - act.runOnUiThread(new Runnable() { - public void run() { - web.goBack(); - } - }); - } - - public void forward() { - act.runOnUiThread(new Runnable() { - public void run() { - web.goForward(); - } - }); - } - - public void clearHistory() { - act.runOnUiThread(new Runnable() { - public void run() { - web.clearHistory(); - } - }); - } - - public void stop() { - act.runOnUiThread(new Runnable() { - public void run() { - web.stopLoading(); - } - }); - } - - public void destroy() { - act.runOnUiThread(new Runnable() { - public void run() { - web.destroy(); - } - }); - } - - public void setPage(final String html, final String baseUrl) { - act.runOnUiThread(new Runnable() { - public void run() { - web.loadDataWithBaseURL(baseUrl, html, "text/html", "UTF-8", null); - } - }); - } - - public void exposeInJavaScript(final Object o, final String name) { - act.runOnUiThread(new Runnable() { - public void run() { - web.addJavascriptInterface(o, name); - } - }); - } - - public void setPinchZoomEnabled(final boolean e) { - act.runOnUiThread(new Runnable() { - public void run() { - web.getSettings().setSupportZoom(e); - web.getSettings().setBuiltInZoomControls(e); - } - }); - } - - @Override - protected void deinitialize() { - act.runOnUiThread(new Runnable() { - @Override - public void run() { - if(android.os.Build.VERSION.SDK_INT == 21) { // bugfix for Android 5.0.x - web.setLayerType(View.LAYER_TYPE_SOFTWARE, null); //setting layer type to software to prevent the sigseg 11 crash - } - } - }); - super.deinitialize(); - } - } - - - - public Object connect(String url, boolean read, boolean write, int timeout) throws IOException { - URL u = new URL(url); - CookieHandler.setDefault(null); - URLConnection con = u.openConnection(); - if (con instanceof HttpURLConnection) { - HttpURLConnection c = (HttpURLConnection) con; - c.setUseCaches(false); - c.setDefaultUseCaches(false); - c.setInstanceFollowRedirects(false); - if(timeout > -1) { - c.setConnectTimeout(timeout); - } - - if (android.os.Build.VERSION.SDK_INT > 13) { - c.setRequestProperty("Connection", "close"); - } - } - con.setDoInput(read); - con.setDoOutput(write); - return con; - } - - @Override - public void setReadTimeout(Object connection, int readTimeout) { - if (connection instanceof URLConnection) { - ((URLConnection)connection).setReadTimeout(readTimeout); - } - } - - - - @Override - public boolean isReadTimeoutSupported() { - return true; - } - - @Override - public void setInsecure(Object connection, boolean insecure) { - if (insecure) { - if (connection instanceof HttpsURLConnection) { - HttpsURLConnection conn = (HttpsURLConnection)connection; - try { - TrustModifier.relaxHostChecking(conn); - } catch (Exception ex) { - com.codename1.io.Log.e(ex); - } - } - } - } - - - /** - * @inheritDoc - */ - public Object connect(String url, boolean read, boolean write) throws IOException { - return connect(url, read, write, timeout); - } - - - private static final char[] HEX_CHARS = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'}; - - private static String dumpHex(byte[] data) { - final int n = data.length; - final StringBuilder sb = new StringBuilder(n * 3 - 1); - for (int i = 0; i < n; i++) { - if (i > 0) { - sb.append(' '); - } - sb.append(HEX_CHARS[(data[i] >> 4) & 0x0F]); - sb.append(HEX_CHARS[data[i] & 0x0F]); - } - return sb.toString(); - } - - @Override - public String[] getSSLCertificates(Object connection, String url) throws IOException { - if (connection instanceof HttpsURLConnection) { - HttpsURLConnection conn = (HttpsURLConnection)connection; - - try { - conn.connect(); - java.security.cert.Certificate[] certs = conn.getServerCertificates(); - String[] out = new String[certs.length * 2]; - int i=0; - for (java.security.cert.Certificate cert : certs) { - { - MessageDigest md = MessageDigest.getInstance("SHA-256"); - md.update(cert.getEncoded()); - out[i++] = "SHA-256:" + dumpHex(md.digest()); - } - { - MessageDigest md = MessageDigest.getInstance("SHA1"); - md.update(cert.getEncoded()); - out[i++] = "SHA1:" + dumpHex(md.digest()); - } - - } - return out; - } catch (Exception ex) { - ex.printStackTrace(); - } - } - return new String[0]; - - } - - @Override - public boolean canGetSSLCertificates() { - return true; - } - - @Override - public boolean canGetPublicKeyDigests() { - return true; - } - - @Override - public String[] getSSLCertificatesEx(Object connection, String url) throws IOException { - if (connection instanceof HttpsURLConnection) { - HttpsURLConnection conn = (HttpsURLConnection) connection; - try { - conn.connect(); - java.security.cert.Certificate[] certs = conn.getServerCertificates(); - java.util.List out = new java.util.ArrayList(); - for (int i = 0; i < certs.length; i++) { - java.security.cert.Certificate cert = certs[i]; - out.add("CHAIN:" + i); - MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); - sha256.update(cert.getEncoded()); - out.add("SHA-256:" + dumpHex(sha256.digest())); - MessageDigest sha1 = MessageDigest.getInstance("SHA1"); - sha1.update(cert.getEncoded()); - out.add("SHA1:" + dumpHex(sha1.digest())); - // getPublicKey().getEncoded() is already the DER SubjectPublicKeyInfo, - // which is exactly what a public-key pin is computed over. - java.security.PublicKey pk = cert.getPublicKey(); - if (pk != null && pk.getEncoded() != null) { - MessageDigest spki = MessageDigest.getInstance("SHA-256"); - spki.update(pk.getEncoded()); - out.add("SPKI-SHA-256:" - + com.codename1.util.Base64.encodeNoNewline(spki.digest())); - } - } - return out.toArray(new String[out.size()]); - } catch (Exception ex) { - ex.printStackTrace(); - } - } - return new String[0]; - } - - /** - * @inheritDoc - */ - public void setHeader(Object connection, String key, String val) { - ((URLConnection) connection).setRequestProperty(key, val); - } - - @Override - public void setChunkedStreamingMode(Object connection, int bufferLen){ - HttpURLConnection con = ((HttpURLConnection) connection); - con.setChunkedStreamingMode(bufferLen); - } - - - - /** - * @inheritDoc - */ - public OutputStream openOutputStream(Object connection) throws IOException { - if (connection instanceof String) { - String con = (String)connection; - if (con.startsWith("file://")) { - con = con.substring(7); - } - - OutputStream fc = createFileOuputStream((String) con); - BufferedOutputStream o = new BufferedOutputStream(fc, (String) con); - return o; - } - return new BufferedOutputStream(((URLConnection) connection).getOutputStream(), connection.toString()); - } - - /** - * @inheritDoc - */ - public OutputStream openOutputStream(Object connection, int offset) throws IOException { - String con = (String) connection; - con = removeFilePrefix(con); - RandomAccessFile rf = new RandomAccessFile(con, "rw"); - rf.seek(offset); - FileOutputStream fc = new FileOutputStream(rf.getFD()); - BufferedOutputStream o = new BufferedOutputStream(fc, con); - o.setConnection(rf); - return o; - } - - /** - * @inheritDoc - */ - public void cleanup(Object o) { - try { - super.cleanup(o); - if (o != null) { - if (o instanceof RandomAccessFile) { - ((RandomAccessFile) o).close(); - } - } - } catch (Throwable ex) { - ex.printStackTrace(); - } - } - - /** - * @inheritDoc - */ - public InputStream openInputStream(Object connection) throws IOException { - if (connection instanceof String) { - String con = (String) connection; - if (con.startsWith("file://")) { - con = con.substring(7); - } - InputStream fc = createFileInputStream(con); - BufferedInputStream o = new BufferedInputStream(fc, con); - return o; - } - if(connection instanceof HttpURLConnection) { - HttpURLConnection ht = (HttpURLConnection)connection; - if(ht.getResponseCode() < 400) { - return new BufferedInputStream(ht.getInputStream()); - } - return new BufferedInputStream(ht.getErrorStream()); - } else { - return new BufferedInputStream(((URLConnection) connection).getInputStream()); - } - } - - /** - * @inheritDoc - */ - public void setHttpMethod(Object connection, String method) throws IOException { - if(method.equalsIgnoreCase("patch")) { - allowPatch((HttpURLConnection) connection); - } - ((HttpURLConnection) connection).setRequestMethod(method); - } - - // the following block is based on a few suggestions in this stack overflow - // answer https://stackoverflow.com/questions/25163131/httpurlconnection-invalid-http-method-patch - private static boolean enabledPatch; - private static boolean patchFailed; - private static void allowPatch(HttpURLConnection connection) { - if(enabledPatch) { - return; - } - if(patchFailed) { - connection.setRequestProperty("X-HTTP-Method-Override", "PATCH"); - return; - } - try { - Field methodsField = HttpURLConnection.class.getDeclaredField("methods"); - - Field modifiersField = Field.class.getDeclaredField("modifiers"); - modifiersField.setAccessible(true); - modifiersField.setInt(methodsField, methodsField.getModifiers() & ~Modifier.FINAL); - - methodsField.setAccessible(true); - - String[] oldMethods = (String[]) methodsField.get(null); - Set methodsSet = new LinkedHashSet(Arrays.asList(oldMethods)); - methodsSet.addAll(Arrays.asList("PATCH")); - String[] newMethods = methodsSet.toArray(new String[0]); - - methodsField.set(null/*static field*/, newMethods); - enabledPatch = true; - } catch (NoSuchFieldException e) { - patchFailed = true; - connection.setRequestProperty("X-HTTP-Method-Override", "PATCH"); - } catch(IllegalAccessException ee) { - patchFailed = true; - connection.setRequestProperty("X-HTTP-Method-Override", "PATCH"); - } - } - - /** - * @inheritDoc - */ - public void setPostRequest(Object connection, boolean p) { - try { - if (p) { - ((HttpURLConnection) connection).setRequestMethod("POST"); - } else { - ((HttpURLConnection) connection).setRequestMethod("GET"); - } - } catch (IOException err) { - // an exception here doesn't make sense - err.printStackTrace(); - } - } - - /** - * @inheritDoc - */ - public int getResponseCode(Object connection) throws IOException { - // workaround for Android bug discussed here: http://stackoverflow.com/questions/17638398/androids-httpurlconnection-throws-eofexception-on-head-requests - HttpURLConnection con = (HttpURLConnection) connection; - if("head".equalsIgnoreCase(con.getRequestMethod())) { - con.setDoOutput(false); - con.setRequestProperty( "Accept-Encoding", "" ); - } - return ((HttpURLConnection) connection).getResponseCode(); - } - - /** - * @inheritDoc - */ - public String getResponseMessage(Object connection) throws IOException { - return ((HttpURLConnection) connection).getResponseMessage(); - } - - /** - * @inheritDoc - */ - public int getContentLength(Object connection) { - return ((HttpURLConnection) connection).getContentLength(); - } - - /** - * @inheritDoc - */ - public String getHeaderField(String name, Object connection) throws IOException { - return ((HttpURLConnection) connection).getHeaderField(name); - } - - /** - * @inheritDoc - */ - public String[] getHeaderFieldNames(Object connection) throws IOException { - Set s = ((HttpURLConnection) connection).getHeaderFields().keySet(); - String[] resp = new String[s.size()]; - s.toArray(resp); - return resp; - } - - /** - * @inheritDoc - */ - public String[] getHeaderFields(String name, Object connection) throws IOException { - HttpURLConnection c = (HttpURLConnection) connection; - List headers = new ArrayList(); - - // we need to merge headers with differing case since this should be case insensitive - for(String key : c.getHeaderFields().keySet()) { - if(key != null && key.equalsIgnoreCase(name)) { - headers.addAll(c.getHeaderFields().get(key)); - } - } - if (headers.size() > 0) { - List v = new ArrayList(); - v.addAll(headers); - Collections.reverse(v); - String[] s = new String[v.size()]; - v.toArray(s); - return s; - } - // workaround for a bug in some android devices - String f = c.getHeaderField(name); - if(f != null && f.length() > 0) { - return new String[] {f}; - } - return null; - - - - } - - /** - * Directory holding storage writes still in progress. - * - *

A sibling of the files dir rather than something inside it. Every name is a - * legal storage key, so no name reserved inside that namespace can be kept clear - * of the application: a key called after the scratch area would either be - * unstorable or, if it already existed as a file, would stop the directory being - * created and fail every write from then on. Outside the namespace there is - * nothing to collide with. It stays on the same filesystem as the entries, which - * is what lets a write be published by renaming.

- */ - private static final String STORAGE_SCRATCH_DIR = "cn1-storage-scratch"; - - /** - * Suffix of the file each process locks for as long as it is running, so that the - * others can tell whether the writes it left behind are still being written. - * - *

This replaces judging a scratch file by its age. An application may run more - * than one process, each with its own copy of this class and so its own idea of - * what is open, and age was the only thing they all agreed on -- but - * {@code lastModified} is a wall clock reading, and a clock that jumps forward - * makes a file being written this moment look arbitrarily old. A lock says - * whether the writer is there, and the system drops it when a process ends - * however it ends, so it cannot outlive the process it stands for.

- */ - private static final String STORAGE_LIVE_SUFFIX = ".live"; - - /** - * How long to leave between sweeps. A rate limit rather than a judgement about - * any file, measured on the monotonic clock so that setting the wall clock cannot - * disturb it. - */ - private static final long STORAGE_SWEEP_INTERVAL = 5L * 60L * 1000L; - - /** - * Distinguishes the scratch files of concurrent writes. Paired with the process - * id, since a second process counts from the beginning as well. - */ - private static final AtomicLong storageScratchCounter = new AtomicLong(); - - /** - * Guards the instant at which a write is published or abandoned, and the set of - * writes that are still open. Deleting an entry and publishing one have to take - * turns: otherwise a write that renames its scratch file just after another - * thread deleted the entry brings the deleted entry back. - */ - private static final Object storagePublishLock = new Object(); - - /** - * Name of the file whose lock serializes storage writes between processes. - */ - private static final String STORAGE_LOCK_FILE = ".lock"; - - /** - * The cross process lock, and the handle it is taken on, while this process holds - * it. Guarded by {@link #storagePublishLock}, so only one thread here ever has it. - */ - private static RandomAccessFile storageLockHandle; - private static FileLock storageLockAcrossProcesses; - - /** - * The lock this process holds for as long as it runs, saying that the scratch - * files bearing its process id are still being written. Never released: the - * system takes it back when the process ends. - */ - private static RandomAccessFile storageLiveHandle; - private static FileLock storageLiveLock; - - /** - * How many nested claims this process has on the cross process lock. A - * {@code FileLock} is held by the whole VM and cannot be taken twice, and - * clearStorage claims it and then calls deleteStorageFile for every entry. - */ - private static int storageLockDepth; - - /** - * Claims the storage for this process, so that creating a scratch file, deleting - * an entry and publishing a write cannot interleave between processes. - * - *

Unlinking a writer's scratch file is what cancels it, and that only reaches - * the writes that exist when the deletion looks. Without this a second process - * could create its scratch file just after a deletion had scanned for them, and - * publish over the entry that deletion went on to remove. A lock the filesystem - * arbitrates is the only thing both processes can see; the system drops it when a - * process ends however it ends, so it cannot be left held by a crash.

- * - *

Best effort: if the lock cannot be taken the work still goes ahead, since a - * storage that stops writing would be worse than one exposed to a race that only - * an application with more than one process can reach at all.

- * - *

The caller must hold {@link #storagePublishLock}.

- */ - private static void lockStorageAcrossProcesses() { - if (storageLockDepth == 0) { - try { - File dir = storageScratchDir(); - if (dir.isDirectory() || dir.mkdirs() || dir.isDirectory()) { - // kept before the lock is attempted rather than after it succeeds, - // so that a lock which throws still leaves releaseStorageLock - // something to close. Otherwise a filesystem that refuses to lock - // leaks a descriptor on every storage operation until unrelated - // files stop opening. - storageLockHandle = - new RandomAccessFile(new File(dir, STORAGE_LOCK_FILE), "rw"); - storageLockAcrossProcesses = storageLockHandle.getChannel().lock(); - } - } catch (Throwable t) { - // android's log, not ours: the default log writer is a storage stream, - // so reporting this through it would come back through here with the - // depth still at zero and fail the same way, again and again - Log.e("CodenameOne", "Could not lock the storage", t); - releaseStorageLock(); - } - } - storageLockDepth++; - } - - /** - * Gives up this process's claim on the storage. - * - *

The caller must hold {@link #storagePublishLock}.

- */ - private static void unlockStorageAcrossProcesses() { - storageLockDepth--; - if (storageLockDepth == 0) { - releaseStorageLock(); - } - } - - /** - * Drops the cross process lock and the handle it was taken on, whichever of them - * this process actually got. - */ - private static void releaseStorageLock() { - try { - if (storageLockAcrossProcesses != null) { - storageLockAcrossProcesses.release(); - } - } catch (Throwable t) { - Log.e("CodenameOne", "Could not release the storage lock", t); - } - storageLockAcrossProcesses = null; - try { - if (storageLockHandle != null) { - storageLockHandle.close(); - } - } catch (Throwable t) { - Log.e("CodenameOne", "Could not close the storage lock", t); - } - storageLockHandle = null; - } - - /** - * The writes that are currently open, so that deleting an entry can cancel them. - * Guarded by {@link #storagePublishLock}. - */ - private static final List openStorageWrites = - new ArrayList(); - - /** - * When the scratch area is next worth looking at, on the monotonic clock. Keeps - * the sweep from running on every write without ever being the thing that decides - * whether a file is abandoned. Guarded by {@link #storagePublishLock}. - */ - private static long nextStorageScratchSweep; - - /** - * @inheritDoc - */ - public void deleteStorageFile(String name) { - synchronized (storagePublishLock) { - lockStorageAcrossProcesses(); - try { - // cancelled before the entry goes, and under the same lock the - // publishing rename takes, so a write that is already mid close - // cannot put the entry back afterwards. - for (int iter = 0; iter < openStorageWrites.size(); iter++) { - openStorageWrites.get(iter).cancel(name); - } - // the same for writes in another process, which the monitor above - // knows nothing about. Unlinking a scratch file cancels it: the - // writer keeps a working descriptor on an inode with no name, exactly - // as it used to keep one on an entry deleted underneath it, and the - // rename that would have published it can no longer find anything to - // rename. Scratch files go first, so a publish that slips through - // between the two still leaves an entry for the delete to remove. - discardScratchFilesFor(name); - getContext().deleteFile(name); - } finally { - unlockStorageAcrossProcesses(); - } - } - } - - /** - * Unlinks every scratch file being written for the given entry, in this process - * or any other, which is what cancels those writes. - * - * @param name the storage entry - */ - private static void discardScratchFilesFor(String name) { - try { - String prefix = storageScratchPrefix(name); - File[] scratch = storageScratchDir().listFiles(); - if (scratch == null) { - return; - } - for (int iter = 0; iter < scratch.length; iter++) { - if (scratch[iter].getName().startsWith(prefix) && !scratch[iter].delete()) { - com.codename1.io.Log.p("Could not cancel the storage write " - + scratch[iter]); - } - } - } catch (IOException err) { - com.codename1.io.Log.e(err); - } - } - - /** - * @inheritDoc - */ - public void clearStorage() { - synchronized (storagePublishLock) { - // every open write, not just the ones for entries that exist. A write to - // an entry that is not there yet is absent from listStorageEntries, so the - // inherited implementation never reaches it, and it would publish a new - // entry moments after the storage was supposedly emptied. - lockStorageAcrossProcesses(); - try { - for (int iter = 0; iter < openStorageWrites.size(); iter++) { - openStorageWrites.get(iter).cancel(); - } - discardAllScratchFiles(); - super.clearStorage(); - } finally { - unlockStorageAcrossProcesses(); - } - } - } - - /** - * @inheritDoc - */ - public boolean abandonStorageWrite(String name, OutputStream writing) { - // this write and no other. Every write to the entry used to be given up - // together, so a second thread writing the same entry had its value quietly - // discarded and was told the write had succeeded. - if (writing instanceof StorageOutputStream) { - synchronized (storagePublishLock) { - ((StorageOutputStream) writing).cancel(); - } - // such a write leaves the entry untouched until it is published, so - // whatever was stored is still there - return true; - } - // a stream that never opened cannot have touched anything either. Anything - // else wrote into the entry itself and the caller has to clear up after it. - return writing == null; - } - - /** - * @inheritDoc - * - *

Writes into the entry, as it always has. A caller may hold this open and - * expect what it flushes to be readable meanwhile -- the log writer keeps one for - * the life of the application and sendLog reads the entry behind its back -- so - * an entry that appeared only on close would leave the log unreadable and lose - * everything written since the process started. What can be given here without - * changing when the entry appears is the flush that Android does not do on - * close.

- */ - public OutputStream createStorageOutputStream(String name) throws IOException { - return new SyncingStorageOutputStream(getContext().openFileOutput(name, 0)); - } - - /** - * @inheritDoc - */ - public OutputStream createStorageOutputStream(String name, boolean replaceWhenClosed) - throws IOException { - if (!replaceWhenClosed) { - return createStorageOutputStream(name); - } - sweepStorageScratchFiles(); - return new StorageOutputStream(name); - } - - /** - * Forces a stream onto the device as it closes, which Android does not do by - * itself, without changing anything about when what is written becomes visible. - */ - private static final class SyncingStorageOutputStream extends OutputStream { - private final FileOutputStream out; - private boolean closed; - - SyncingStorageOutputStream(FileOutputStream out) { - this.out = out; - } - - @Override - public void write(int b) throws IOException { - out.write(b); - } - - @Override - public void write(byte[] b) throws IOException { - out.write(b); - } - - @Override - public void write(byte[] b, int off, int len) throws IOException { - out.write(b, off, len); - } - - @Override - public void flush() throws IOException { - out.flush(); - } - - @Override - public void close() throws IOException { - if (closed) { - return; - } - closed = true; - try { - out.flush(); - out.getFD().sync(); - } finally { - out.close(); - } - } - } - - /** - * @inheritDoc - */ - public InputStream createStorageInputStream(String name) throws IOException { - return getContext().openFileInput(name); - } - - /** - * @inheritDoc - */ - public boolean storageFileExists(String name) { - String[] fileList = getContext().fileList(); - for (int iter = 0; iter < fileList.length; iter++) { - if (fileList[iter].equals(name)) { - return true; - } - } - return false; - } - - /** - * @inheritDoc - */ - public String[] listStorageEntries() { - return getContext().fileList(); - } - - /** - * @inheritDoc - */ - public int getStorageEntrySize(String name) { - return (int)new File(getContext().getFilesDir(), name).length(); - } - - /** - * Removes the scratch files left behind by a run that died mid write, once they - * are old enough that nothing can still be writing them. - */ - private void sweepStorageScratchFiles() { - synchronized (storagePublishLock) { - long now = android.os.SystemClock.elapsedRealtime(); - if (now < nextStorageScratchSweep) { - return; - } - nextStorageScratchSweep = now + STORAGE_SWEEP_INTERVAL; - // under the lock the other processes take to start a write or to say they - // are running. Finding an owner gone and then deleting its files are two - // steps, and a process id is handed out again the moment its holder is - // gone: without this a process could be given the id just examined, say so - // and start writing, and have this sweep delete the write it had only just - // begun -- or the very file it had said it was alive with, after which - // every later sweep would take it for gone. - lockStorageAcrossProcesses(); - try { - File dir = storageScratchDir(); - File[] files = dir.listFiles(); - if (files == null) { - return; - } - int mine = android.os.Process.myPid(); - for (int iter = 0; iter < files.length; iter++) { - if (isStorageLockFile(files[iter])) { - continue; - } - int owner = storageScratchOwner(files[iter].getName()); - // this process knows what it is doing without asking, and never - // tries to lock its own liveness file, which it already holds - if (owner < 0 || owner == mine || isProcessWriting(dir, owner)) { - continue; - } - if (!files[iter].delete()) { - com.codename1.io.Log.p("Could not remove the abandoned storage " - + "scratch file " + files[iter]); - } - } - } catch (Throwable t) { - // a sweep that fails costs disk space, never correctness - com.codename1.io.Log.e(t); - } finally { - unlockStorageAcrossProcesses(); - } - } - } - - /** - * The process a file in the scratch directory belongs to. - * - * @param fileName the name of the file - * @return the process id, or -1 if the name does not carry one - */ - private static int storageScratchOwner(String fileName) { - String pid; - if (fileName.endsWith(STORAGE_LIVE_SUFFIX)) { - pid = fileName.substring(0, fileName.length() - STORAGE_LIVE_SUFFIX.length()); - } else { - int digest = fileName.indexOf('-'); - int counter = digest < 0 ? -1 : fileName.indexOf('-', digest + 1); - if (counter < 0) { - return -1; - } - pid = fileName.substring(digest + 1, counter); - } - try { - return Integer.parseInt(pid); - } catch (NumberFormatException err) { - return -1; - } - } - - /** - * Whether the given process is still running, and so may still be writing the - * scratch files that carry its id. - * - *

Asked of the filesystem rather than of {@code /proc}, which since Android 9 - * shows a process only itself. A lock that can be taken is one nobody is holding. - * Anything unexpected counts as running, since deleting another process's work on - * a guess is the one outcome worth avoiding here.

- * - * @param dir the scratch directory - * @param pid the process to ask about - * @return true if that process appears to be running - */ - private static boolean isProcessWriting(File dir, int pid) { - File live = new File(dir, pid + STORAGE_LIVE_SUFFIX); - if (!live.exists()) { - return false; - } - RandomAccessFile handle = null; - FileLock held = null; - try { - handle = new RandomAccessFile(live, "rw"); - held = handle.getChannel().tryLock(); - return held == null; - } catch (Throwable t) { - return true; - } finally { - try { - if (held != null) { - held.release(); - } - if (handle != null) { - handle.close(); - } - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - } - } - - /** - * Says, for as long as this process runs, that the scratch files carrying its - * process id are still being written. - * - * @param dir the scratch directory - */ - private static void claimStorageLiveness(File dir) { - synchronized (storagePublishLock) { - if (storageLiveLock != null) { - return; - } - // under the same lock the sweep takes, so that saying this process is - // running and clearing what the last holder of its id left behind cannot - // land in the middle of another process deciding that id is gone - lockStorageAcrossProcesses(); - try { - try { - storageLiveHandle = new RandomAccessFile( - new File(dir, android.os.Process.myPid() + STORAGE_LIVE_SUFFIX), "rw"); - storageLiveLock = storageLiveHandle.getChannel().lock(); - } catch (Throwable t) { - // android's log for the same reason as above - Log.e("CodenameOne", "Could not claim the storage liveness file", t); - try { - if (storageLiveHandle != null) { - storageLiveHandle.close(); - } - } catch (Throwable ignored) { - Log.e("CodenameOne", "Could not close the liveness file", ignored); - } - // the lock as well as the handle: closing the handle gives up the - // lock, and a lock this process still believed it held is one it - // would never take again, which leaves every other process reading - // it as gone and free to delete the writes it has in flight - storageLiveHandle = null; - storageLiveLock = null; - return; - } - try { - discardEarlierIncarnation(dir); - } catch (Throwable t) { - // separately, because the claim above has already succeeded and - // clearing up after whoever held this id last is not worth giving - // it up for. The leftovers keep until a later sweep. - Log.e("CodenameOne", "Could not clear the earlier incarnation", t); - } - } finally { - unlockStorageAcrossProcesses(); - } - } - } - - /** - * Unlinks every scratch file there is, cancelling every write in progress in any - * process. - */ - private static void discardAllScratchFiles() { - try { - File[] scratch = storageScratchDir().listFiles(); - if (scratch == null) { - return; - } - for (int iter = 0; iter < scratch.length; iter++) { - if (!isStorageMarkerFile(scratch[iter]) && !scratch[iter].delete()) { - com.codename1.io.Log.p("Could not cancel the storage write " - + scratch[iter]); - } - } - } catch (IOException err) { - com.codename1.io.Log.e(err); - } - } - - /** - * Whether the given file is the one whose lock serializes the processes, rather - * than a write in progress. - * - *

It has to survive both the clear and the sweep. Linux lets a locked file be - * unlinked, and the lock goes with the inode rather than the name, so a process - * that removed it while holding it would leave the next process free to create - * the name afresh and take a lock on a different inode: both would then hold - * "the" lock and neither would wait for the other. Nothing writes to it either, - * so its age says nothing about whether it is in use.

- * - * @param file a file in the scratch directory - * @return true if the file is the lock - */ - private static boolean isStorageLockFile(File file) { - return STORAGE_LOCK_FILE.equals(file.getName()); - } - - /** - * Removes whatever a previous process left behind under this process's id. - * - *

Android hands out a process id again once the process holding it is gone, so - * after a crash or a reboot the files an earlier incarnation abandoned can be - * sitting under the id this one has just been given. The sweep passes over - * anything bearing its own id, on the grounds that a process knows its own work, - * which would leave those files where they are for good.

- * - *

Usually this runs before the first write, when the process owns nothing and - * everything under its id must belong to the incarnation before it. That is not - * guaranteed: a claim that fails is retried by the next write, by which time this - * process may have writes of its own open. Those are known exactly and are left - * alone -- deleting one would fail a write that had already been serialized.

- * - *

The caller must hold {@link #storagePublishLock}.

- * - * @param dir the scratch directory - */ - private static void discardEarlierIncarnation(File dir) { - File[] files = dir.listFiles(); - if (files == null) { - return; - } - int mine = android.os.Process.myPid(); - for (int iter = 0; iter < files.length; iter++) { - if (!isStorageMarkerFile(files[iter]) - && storageScratchOwner(files[iter].getName()) == mine - && !isOpenStorageWrite(files[iter]) - && !files[iter].delete()) { - com.codename1.io.Log.p("Could not remove the abandoned storage scratch " - + "file " + files[iter]); - } - } - } - - /** - * Whether the given scratch file belongs to a write this process has open. - * - *

The caller must hold {@link #storagePublishLock}.

- * - * @param file a file in the scratch directory - * @return true if a write in this process is using it - */ - private static boolean isOpenStorageWrite(File file) { - for (int iter = 0; iter < openStorageWrites.size(); iter++) { - if (openStorageWrites.get(iter).scratch.equals(file)) { - return true; - } - } - return false; - } - - /** - * Whether the given file is one of the markers the processes keep about - * themselves, rather than a write in progress. - * - *

Clearing the storage throws away the writes, and nothing else. A process - * whose liveness file was taken from underneath it goes on holding the lock, so - * it never notices and never makes the name again, and from then on every other - * process reads it as gone and feels free to delete the writes it has in flight. - * The sweep is the one place a liveness file is removed, and only once its owner - * is known to be gone.

- * - * @param file a file in the scratch directory - * @return true if the file is a marker rather than a pending write - */ - private static boolean isStorageMarkerFile(File file) { - return isStorageLockFile(file) || file.getName().endsWith(STORAGE_LIVE_SUFFIX); - } - - /** - * The start of the name of every scratch file for the given entry. - * - *

A digest rather than the entry itself: an entry name may be as long as the - * filesystem allows on its own, so anything built by appending to one would be - * refused. Fixed width, and specific enough that one entry's deletion does not - * cancel another's write.

- * - * @param name the storage entry - * @return the prefix shared by that entry's scratch files - * @throws IOException if the digest is unavailable - */ - private static String storageScratchPrefix(String name) throws IOException { - try { - byte[] digest = java.security.MessageDigest.getInstance("SHA-256") - .digest(name.getBytes("UTF-8")); - StringBuilder b = new StringBuilder(digest.length * 2); - for (int iter = 0; iter < digest.length; iter++) { - b.append(Character.forDigit((digest[iter] >> 4) & 0xf, 16)); - b.append(Character.forDigit(digest[iter] & 0xf, 16)); - } - return b.append('-').toString(); - } catch (java.security.NoSuchAlgorithmException err) { - throw new IOException("No SHA-256 to name storage scratch files with", err); - } - } - - /** - * Resolves a storage entry to its file, refusing anything that would land outside - * the storage directory. - * - *

{@code openFileOutput} used to make this check on our behalf and reject any - * name holding a path separator. Publishing by rename does not: with name - * normalization turned off a key like {@code ../shared_prefs/settings.xml} - * reaches here as it was written, and {@code File} resolves it, which would put - * the rename anywhere in the application's private data and leave behind an entry - * that Storage itself could no longer read or delete.

- * - * @param name the storage entry - * @return the file the entry is stored in - * @throws IOException if the name does not name an entry in the storage directory - */ - private static File storageEntryFile(String name) throws IOException { - File dir = getContext().getFilesDir(); - if (name.indexOf('/') >= 0 || name.indexOf(File.separatorChar) >= 0) { - throw new IOException("Storage entry " + name + " contains a path separator"); - } - File entry = new File(dir, name); - if (!dir.equals(entry.getParentFile())) { - throw new IOException("Storage entry " + name + " resolves outside " + dir); - } - return entry; - } - - /** - * The directory holding the writes that are in progress. - * - * @return the scratch directory, which is not guaranteed to exist yet - * @throws IOException if the application has no data directory to put it in - */ - private static File storageScratchDir() throws IOException { - File files = getContext().getFilesDir(); - File data = files.getParentFile(); - if (data == null) { - throw new IOException("No application data directory above " + files); - } - return new File(data, STORAGE_SCRATCH_DIR); - } - - /** - * Writes a storage entry to a scratch file, forces the bytes onto the device and - * only then renames that file over the entry. - * - *

{@code openFileOutput} truncates the entry as it opens it, and Android does - * not flush a file on close. Writing the entry in place therefore left a window - * on every single write in which the entry was empty or half written on disk, and - * left the bytes of a completed write sitting in the page cache for as long as - * the kernel felt like holding them. An abrupt end to the process or to the - * device inside either window -- a low memory kill, a force stop, a battery pull, - * a panic -- lost the entry, and on a filesystem that journals the truncation - * ahead of the data it came back as a zero length file. How wide those windows - * are is a property of the filesystem and of how eagerly the vendor kills - * background processes, which is why this only ever showed up on some devices.

- * - *

The entry now changes in a single rename, which the filesystem cannot show - * half done, and the bytes reach the device before that rename is made.

- */ - private static final class StorageOutputStream extends OutputStream { - private final String name; - private final File target; - private final File scratch; - private final FileOutputStream out; - private boolean closed; - private boolean cancelled; - - StorageOutputStream(String name) throws IOException { - this.name = name; - this.target = storageEntryFile(name); - File dir = storageScratchDir(); - if (!dir.isDirectory() && !dir.mkdirs() && !dir.isDirectory()) { - throw new IOException("Could not create the storage scratch directory " - + dir); - } - // the write goes ahead whether or not that succeeded. A claim can only - // fail where the filesystem will not lock, and refusing to write would - // turn that into an application that cannot store anything -- far worse - // than what it costs, which is that another process sweeping at that - // moment may take this write for abandoned and unlink it. That fails the - // write, honestly, and leaves what was already stored where it is; the - // next write claims again. Same trade the cross process lock makes. - claimStorageLiveness(dir); - // the digest of the entry lets another process find and cancel this write. - // The process id separates concurrent processes, whose counters both start - // from the beginning, and the counter separates writes within one. - this.scratch = new File(dir, storageScratchPrefix(name) - + android.os.Process.myPid() + "-" - + storageScratchCounter.incrementAndGet()); - // created and registered as one step under the lock a deletion takes. - // Registering afterwards would leave a write whose scratch file already - // exists but which a concurrent deleteStorageFile cannot see to cancel, - // and that write would rename itself over the entry that was deleted. - synchronized (storagePublishLock) { - lockStorageAcrossProcesses(); - try { - this.out = new FileOutputStream(scratch); - openStorageWrites.add(this); - } finally { - unlockStorageAcrossProcesses(); - } - } - } - - /** - * Marks this write as one that must not be published, whatever entry it is - * for. Called holding {@link #storagePublishLock}. - */ - void cancel() { - cancelled = true; - } - - /** - * Marks this write as one that must not be published, because the entry it - * would publish over has been deleted since it opened. Called holding - * {@link #storagePublishLock}. - * - * @param entry the entry being deleted - */ - void cancel(String entry) { - if (name.equals(entry)) { - cancelled = true; - } - } - - @Override - public void write(int b) throws IOException { - out.write(b); - } - - @Override - public void write(byte[] b) throws IOException { - out.write(b); - } - - @Override - public void write(byte[] b, int off, int len) throws IOException { - out.write(b, off, len); - } - - @Override - public void flush() throws IOException { - out.flush(); - } - - @Override - public void close() throws IOException { - if (closed) { - return; - } - closed = true; - try { - try { - out.flush(); - out.getFD().sync(); - } finally { - out.close(); - } - publish(); - } finally { - synchronized (storagePublishLock) { - openStorageWrites.remove(this); - } - if (scratch.exists() && !scratch.delete()) { - com.codename1.io.Log.p("Could not remove the storage scratch file " - + scratch); - } - } - } - - /** - * Renames the scratch file over the entry, which is the point at which the - * write becomes visible. - * - * @throws IOException if the entry could not be replaced, so that the caller - * that wrote it hears about it rather than being told the write succeeded - */ - private void publish() throws IOException { - synchronized (storagePublishLock) { - lockStorageAcrossProcesses(); - try { - // the one case where not publishing is not a failure: this - // process cancelled the write itself, so the caller either asked - // for the entry to go or is already abandoning the write. Failing - // here would only log noise over an outcome that is already known. - if (cancelled) { - return; - } - if (scratch.renameTo(target)) { - syncStorageDirectory(target.getParentFile()); - return; - } - // A missing scratch file is not reported as a success. Another - // process unlinking it does mean this entry was deleted, and - // failing here reaches the same place -- writeObject deletes the - // entry on a failed write -- while still telling the caller that - // what it wrote did not land. Anything else that removed the file - // gets the same honest answer, where calling it a success would - // leave the caller believing in a value the storage never took. - throw new IOException("Could not store " + name); - } finally { - unlockStorageAcrossProcesses(); - } - } - } - } - - /** - * Forces a rename in the given directory onto the device, so that a completed - * write does not fall back to its previous contents after an abrupt shutdown. - * Best effort: without it a crash can still only cost the newest write, never the - * integrity of an entry. - * - * @param dir the directory holding the storage entries - */ - private static void syncStorageDirectory(File dir) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - return; - } - try { - DirectorySync.sync(dir); - } catch (Throwable t) { - // some filesystems refuse to sync a directory handle - } - } - - /** - * Isolates the API 21 syscalls, so that verifying {@code AndroidImplementation} - * on an older device never has to resolve them. - */ - private static final class DirectorySync { - private DirectorySync() { - } - - static void sync(File dir) throws android.system.ErrnoException { - java.io.FileDescriptor fd = android.system.Os.open(dir.getPath(), - android.system.OsConstants.O_RDONLY, 0); - try { - android.system.Os.fsync(fd); - } finally { - android.system.Os.close(fd); - } - } - } - - private String addFile(String s) { - // I explicitly don't create a "proper URL" since code might rely on the fact that the file isn't encoded - if(s != null && s.startsWith("/")) { - return "file://" + s; - } - return s; - } - - /** - * @inheritDoc - */ - public String[] listFilesystemRoots() { - - if(!checkForPermission(Manifest.permission.READ_EXTERNAL_STORAGE, "This is required to browse the file system")){ - return new String[]{}; - } - - String [] storageDirs = getStorageDirectories(); - if(storageDirs != null){ - String [] roots = new String[storageDirs.length + 1]; - System.arraycopy(storageDirs, 0, roots, 0, storageDirs.length); - roots[roots.length - 1] = addFile(Environment.getRootDirectory().getAbsolutePath()); - return roots; - } - return new String[]{addFile(Environment.getRootDirectory().getAbsolutePath())}; - } - - @Override - public boolean hasCachesDir() { - return true; - } - - @Override - public String getCachesDir() { - return getContext().getCacheDir().getAbsolutePath(); - } - - - - private String[] getStorageDirectories() { - String [] storageDirs = null; - - String storageDev = Environment.getExternalStorageDirectory().getPath(); - String storageRoot = storageDev.substring(0, storageDev.length() - 1); - BufferedReader bufReader = null; - - try { - bufReader = new BufferedReader(new InputStreamReader(new FileInputStream("/proc/mounts"), StandardCharsets.UTF_8)); - ArrayList list = new ArrayList(); - String line; - - while ((line = bufReader.readLine()) != null) { - if (line.contains("vfat") || line.contains("/mnt") || line.contains("/storage")) { - StringTokenizer tokens = new StringTokenizer(line, " "); - String s = tokens.nextToken(); - s = tokens.nextToken(); // Take the second token, i.e. mount point - - if (s.indexOf("secure") != -1) { - continue; - } - - if (s.startsWith(storageRoot) == true) { - list.add(s); - continue; - } - - if (line.contains("vfat") && line.contains("/mnt")) { - list.add(s); - continue; - } - } - } - - int count = list.size(); - - if (count < 2) { - storageDirs = new String[] { - storageDev - }; - } - else { - storageDirs = new String[count]; - - for (int i = 0; i < count; i++) { - storageDirs[i] = (String) list.get(i); - } - } - } - catch (FileNotFoundException e) {} - catch (IOException e) {} - finally { - if (bufReader != null) { - try { - bufReader.close(); - } - catch (IOException e) {} - } - - return storageDirs; - } - } - - /** - * @inheritDoc - */ - public String getAppHomePath() { - return addFile(getContext().getFilesDir().getAbsolutePath() + "/"); - } - - @Override - public String toNativePath(String path) { - return removeFilePrefix(path); - } - - - - /** - * @inheritDoc - */ - public String[] listFiles(String directory) throws IOException { - directory = removeFilePrefix(directory); - return new File(directory).list(); - } - - /** - * @inheritDoc - */ - public long getRootSizeBytes(String root) { - return -1; - } - - /** - * @inheritDoc - */ - public long getRootAvailableSpace(String root) { - return -1; - } - - /** - * @inheritDoc - */ - public void mkdir(String directory) { - directory = removeFilePrefix(directory); - new File(directory).mkdir(); - } - - /** - * @inheritDoc - */ - public void deleteFile(String file) { - file = removeFilePrefix(file); - File f = new File(file); - f.delete(); - } - - /** - * @inheritDoc - */ - public boolean isHidden(String file) { - file = removeFilePrefix(file); - return new File(file).isHidden(); - } - - /** - * @inheritDoc - */ - public void setHidden(String file, boolean h) { - } - - /** - * @inheritDoc - */ - public long getFileLength(String file) { - file = removeFilePrefix(file); - return new File(file).length(); - } - - /** - * @inheritDoc - */ - public long getFileLastModified(String file) { - file = removeFilePrefix(file); - return new File(file).lastModified(); - } - - /** - * @inheritDoc - */ - public boolean isDirectory(String file) { - file = removeFilePrefix(file); - return new File(file).isDirectory(); - } - - /** - * @inheritDoc - */ - public char getFileSystemSeparator() { - return File.separatorChar; - } - - /** - * @inheritDoc - */ - public OutputStream openFileOutputStream(String file) throws IOException { - file = removeFilePrefix(file); - OutputStream os = null; - try{ - os = createFileOuputStream(file); - }catch(FileNotFoundException fne){ - //It is impossible to know if a path is considered an external - //storage on the various android's versions. - //So we try to open the path and if failed due to permission we will - //ask for the permission from the user - if(fne.getMessage().contains("Permission denied")){ - - if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to access the file")){ - //The user refused to give access. - return null; - }else{ - //The user gave permission try again to access the path - return createFileOuputStream(file); - } - - }else{ - throw fne; - } - } - - return os; - } - - static String removeFilePrefix(String file) { - if (file.startsWith("file://")) { - return file.substring(7); - } - if (file.startsWith("file:/")) { - return file.substring(5); - } - return file; - } - - /** - * @inheritDoc - */ - public InputStream openFileInputStream(String file) throws IOException { - file = removeFilePrefix(file); - InputStream is = null; - try{ - is = createFileInputStream(file); - }catch(FileNotFoundException fne){ - //It is impossible to know if a path is considered an external - //storage on the various android's versions. - //So we try to open the path and if failed due to permission we will - //ask for the permission from the user - if(fne.getMessage().contains("Permission denied")){ - - if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to access the file")){ - //The user refused to give access. - return null; - }else{ - //The user gave permission try again to access the path - return openFileInputStream(file); - } - - }else{ - throw fne; - } - } - - return is; - } - - @Override - public boolean isMultiTouch() { - return true; - } - - /** - * @inheritDoc - */ - public boolean exists(String file) { - file = removeFilePrefix(file); - return new File(file).exists(); - } - - /** - * @inheritDoc - */ - public void rename(String file, String newName) { - file = removeFilePrefix(file); - new File(file).renameTo(new File(new File(file).getParentFile(), newName)); - } - - protected File createFileObject(String fileName) { - return new File(fileName); - } - - protected InputStream createFileInputStream(String fileName) throws FileNotFoundException { - return new FileInputStream(removeFilePrefix(fileName)); - } - - protected InputStream createFileInputStream(File f) throws FileNotFoundException { - return new FileInputStream(f); - } - - protected OutputStream createFileOuputStream(String fileName) throws FileNotFoundException { - return new FileOutputStream(removeFilePrefix(fileName)); - } - - protected OutputStream createFileOuputStream(java.io.File f) throws FileNotFoundException { - return new FileOutputStream(f); - } - - /** - * @inheritDoc - */ - public boolean shouldWriteUTFAsGetBytes() { - return true; - } - - - /** - * @inheritDoc - */ - public void closingOutput(OutputStream s) { - // For some reasons the Android guys chose not doing this by default: - // http://android-developers.blogspot.com/2010/12/saving-data-safely.html - // this seems to be a mistake of sacrificing stability for minor performance - // gains which will only be noticeable on a server. - if (s != null) { - if (s instanceof FileOutputStream) { - try { - FileDescriptor fd = ((FileOutputStream) s).getFD(); - if (fd != null) { - fd.sync(); - } - } catch (IOException ex) { - // this exception doesn't help us - ex.printStackTrace(); - } - } - } - } - - /** - * @inheritDoc - */ - public void printStackTraceToStream(Throwable t, Writer o) { - PrintWriter p = new PrintWriter(o); - t.printStackTrace(p); - } - - private AndroidBiometrics biometrics; - private AndroidSecureStorage secureStorage; - private AndroidNfc nfc; - private AndroidBluetooth bluetooth; - - @Override - public com.codename1.security.Biometrics getBiometrics() { - if (biometrics == null) { - biometrics = new AndroidBiometrics(); - } - return biometrics; - } - - @Override - public com.codename1.security.SecureStorage getSecureStorage() { - if (secureStorage == null) { - secureStorage = new AndroidSecureStorage(); - } - return secureStorage; - } - - @Override - public com.codename1.nfc.Nfc getNfc() { - if (nfc == null) { - nfc = new AndroidNfc(this); - } - return nfc; - } - - @Override - public com.codename1.bluetooth.Bluetooth getBluetooth() { - if (bluetooth == null) { - bluetooth = new AndroidBluetooth(); - } - return bluetooth; - } - - private com.codename1.health.Health health; - - /// Returns the Health Connect-backed health entry point. The store - /// degrades to reporting itself unsupported when no bridge has been - /// injected, which is the case for apps that never reference - /// com.codename1.health. - @Override - public com.codename1.health.Health getHealth() { - // Guarded because everything the store serializes is per-instance: - // the authorization queue, the subscription registry, drain - // coalescing and the persisted-cursor lock. Two threads racing this - // getter each got their own store, and two stores coordinate on - // nothing -- they would launch overlapping permission flows despite - // the queue inside each one being correct. - synchronized (AndroidImplementation.class) { - if (health == null) { - health = new AndroidHealth(); - } - return health; - } - } - - /** - * This method returns the platform Location Control - * - * @return LocationControl Object - */ - public LocationManager getLocationManager() { - String permissionMessage = "This is required to get the location"; - if ( - !checkForPermission( Manifest.permission.ACCESS_FINE_LOCATION, permissionMessage) - ) { - return null; - } - if ( - Build.VERSION.SDK_INT >= 29 - && "true".equals(Display.getInstance().getProperty("android.requiresBackgroundLocationPermissionForAPI29", "false")) - ) { - if ( - !checkForPermission( - "android.permission.ACCESS_BACKGROUND_LOCATION", - permissionMessage - ) - ) { - com.codename1.io.Log.e(new RuntimeException("Background location permission denied")); - } - } - - boolean includesPlayServices = Display.getInstance().getProperty("IncludeGPlayServices", "false").equals("true"); - if (includesPlayServices && hasAndroidMarket()) { - try { - Class clazz = Class.forName("com.codename1.location.AndroidLocationPlayServiceManager"); - return (com.codename1.location.LocationManager)clazz.getMethod("getInstance").invoke(null); - } catch (Exception e) { - return AndroidLocationManager.getInstance(getContext()); - } - } else { - return AndroidLocationManager.getInstance(getContext()); - } - } - - private AndroidMotionSensorManager motionSensorManager; - - @Override - public com.codename1.sensors.MotionSensorManager getMotionSensorManager() { - if (motionSensorManager == null) { - Context ctx = getContext(); - if (ctx == null) { - return null; - } - motionSensorManager = new AndroidMotionSensorManager(ctx); - } - return motionSensorManager; - } - - private String fixAttachmentPath(String attachment) { - com.codename1.io.File cn1File = new com.codename1.io.File(attachment); - File mediaStorageDir = new File(new File(getContext().getCacheDir(), "intent_files"), "Attachment"); - - // Create the storage directory if it does not exist - if (!mediaStorageDir.exists()) { - if (!mediaStorageDir.mkdirs()) { - Log.d(Display.getInstance().getProperty("AppName", "CodenameOne"), "failed to create directory"); - return null; - } - } - - File newFile = new File(mediaStorageDir.getPath() + File.separator - + cn1File.getName()); - if (newFile.exists()) { - if (Display.getInstance().getProperty("DeleteCachedFileAfterShare", "false").equals("true")) { - newFile.delete(); - } else { - // Create a media file name - String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date()); - newFile = new File(mediaStorageDir.getPath() + File.separator - + "IMG_" + timeStamp + "_" + cn1File.getName()); - } - } - - - //Uri fileUri = Uri.fromFile(newFile); - newFile.getParentFile().mkdirs(); - //Uri imageUri = Uri.fromFile(newFile); - Uri fileUri = FileProvider.getUriForFile(getContext(), getContext().getPackageName()+".provider", newFile); - - try { - InputStream is = FileSystemStorage.getInstance().openInputStream(attachment); - OutputStream os = new FileOutputStream(newFile); - byte [] buf = new byte[1024]; - int len; - while((len = is.read(buf)) > -1){ - os.write(buf, 0, len); - } - is.close(); - os.close(); - } catch (IOException ex) { - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - } - - return fileUri.toString(); - } - - /** - * @inheritDoc - */ - public void sendMessage(String[] recipients, String subject, Message msg) { - if(editInProgress()) { - stopEditing(true); - } - Intent emailIntent; - String attachment = msg.getAttachment(); - boolean hasAttachment = (attachment != null && attachment.length() > 0) || msg.getAttachments().size() > 0; - - if(msg.getMimeType().equals(Message.MIME_TEXT) && !hasAttachment){ - StringBuilder to = new StringBuilder(); - for (int i = 0; i < recipients.length; i++) { - to.append(recipients[i]); - to.append(";"); - } - emailIntent = new Intent(Intent.ACTION_SENDTO, - Uri.parse( - "mailto:" + to.toString() - + "?subject=" + Uri.encode(subject) - + "&body=" + Uri.encode(msg.getContent()))); - }else{ - if (hasAttachment) { - if(msg.getAttachments().size() > 1) { - emailIntent = new Intent(android.content.Intent.ACTION_SEND_MULTIPLE); - emailIntent.putExtra(android.content.Intent.EXTRA_EMAIL, recipients); - emailIntent.putExtra(android.content.Intent.EXTRA_SUBJECT, subject); - emailIntent.setType(msg.getMimeType()); - ArrayList uris = new ArrayList(); - - for(String path : msg.getAttachments().keySet()) { - uris.add(Uri.parse(fixAttachmentPath(path))); - } - - emailIntent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris); - } else { - emailIntent = new Intent(android.content.Intent.ACTION_SEND); - emailIntent.putExtra(android.content.Intent.EXTRA_EMAIL, recipients); - emailIntent.putExtra(android.content.Intent.EXTRA_SUBJECT, subject); - emailIntent.setType(msg.getMimeType()); - emailIntent.setType(msg.getAttachmentMimeType()); - //if the attachment is in the uder home dir we need to copy it - //to an accessible dir - attachment = fixAttachmentPath(attachment); - emailIntent.putExtra(Intent.EXTRA_STREAM, Uri.parse(attachment)); - } - } else { - emailIntent = new Intent(android.content.Intent.ACTION_SEND); - emailIntent.putExtra(android.content.Intent.EXTRA_EMAIL, recipients); - emailIntent.putExtra(android.content.Intent.EXTRA_SUBJECT, subject); - emailIntent.setType(msg.getMimeType()); - } - if (msg.getMimeType().equals(Message.MIME_HTML)) { - emailIntent.putExtra(android.content.Intent.EXTRA_TEXT, Html.fromHtml(msg.getContent())); - emailIntent.putExtra("android.intent.extra.HTML_TEXT", msg.getContent()); - }else{ - /* - // Attempted this workaround to fix the ClassCastException that occurs on android when - // there are multiple attachments. Unfortunately, this fixes the stack trace, but - // has the unwanted side-effect of producing a blank message body. - // Same workaround for HTML mimetype also fails the same way. - // Conclusion, Just live with the stack trace. It doesn't seem to affect the - // execution of the program... treat it as a warning. - // See https://github.com/codenameone/CodenameOne/issues/1782 - if (msg.getAttachments().size() > 1) { - ArrayList contentArr = new ArrayList(); - contentArr.add(msg.getContent()); - emailIntent.putStringArrayListExtra(android.content.Intent.EXTRA_TEXT, contentArr); - } else { - emailIntent.putExtra(android.content.Intent.EXTRA_TEXT, msg.getContent()); - - }*/ - emailIntent.putExtra(android.content.Intent.EXTRA_TEXT, msg.getContent()); - } - - } - final String attach = attachment; - AndroidNativeUtil.startActivityForResult(Intent.createChooser(emailIntent, "Send mail..."), new IntentResultListener() { - - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - if(attach != null && attach.length() > 0 && attach.contains("tmp")){ - FileSystemStorage.getInstance().delete(attach); - } - } - }); - } - - /** - * @inheritDoc - */ - public void dial(String phoneNumber) { - Intent dialer = new Intent(android.content.Intent.ACTION_DIAL, Uri.parse("tel:" + phoneNumber)); - getContext().startActivity(dialer); - } - - @Override - public int getSMSSupport() { - if(canDial()) { - return Display.SMS_INTERACTIVE; - } - return Display.SMS_NOT_SUPPORTED; - } - - /** - * @inheritDoc - */ - public void sendSMS(final String phoneNumber, final String message, boolean i) throws IOException { - /*if(!checkForPermission(Manifest.permission.SEND_SMS, "This is required to send a SMS")){ - return; - }*/ - if(!checkForPermission(Manifest.permission.READ_PHONE_STATE, "This is required to send a SMS")){ - return; - } - if(i) { - Intent smsIntent = null; - if(android.os.Build.VERSION.SDK_INT < 19){ - smsIntent = new Intent(Intent.ACTION_VIEW); - smsIntent.setType("vnd.android-dir/mms-sms"); - smsIntent.putExtra("address", phoneNumber); - smsIntent.putExtra("sms_body",message); - }else{ - smsIntent = new Intent(Intent.ACTION_SENDTO); - smsIntent.setData(Uri.parse("smsto:" + Uri.encode(phoneNumber))); - smsIntent.putExtra("sms_body", message); - } - getContext().startActivity(smsIntent); - - } /*else { - SmsManager sms = SmsManager.getDefault(); - ArrayList parts = sms.divideMessage(message); - sms.sendMultipartTextMessage(phoneNumber, null, parts, null, null); - }*/ - } - - @Override - public void dismissNotification(Object o) { - NotificationManager notificationManager = (NotificationManager) getContext().getSystemService(Activity.NOTIFICATION_SERVICE); - if(o != null){ - Integer n = (Integer)o; - notificationManager.cancel("CN1", n.intValue()); - }else{ - notificationManager.cancelAll(); - } - } - - @Override - public boolean isNotificationSupported() { - return true; - } - - /** - * Keys of display properties that need to be made available to Services - * i.e. must be accessible even if CN1 is not initialized. - * - * This is accomplished by setting them inside init(). Then they - * are written to file so that they can be accessed inside a service - * like push notification service. - */ - private static final String[] servicePropertyKeys = new String[]{ - "android.NotificationChannel.id", - "android.NotificationChannel.name", - "android.NotificationChannel.description", - "android.NotificationChannel.importance", - "android.NotificationChannel.enableLights", - "android.NotificationChannel.lightColor", - "android.NotificationChannel.enableVibration", - "android.NotificationChannel.vibrationPattern", - "android.NotoficationChannel.soundUri" - }; - - /** - * Flag to indicate if any of the service properties have been changed. - */ - private static boolean servicePropertiesDirty() { - for (String key : servicePropertyKeys) { - if (Display.getInstance().getProperty(key, null) != null) { - return true; - } - } - return false; - } - - /** - * Stores properties that need to be accessible to services. - * i.e. must be accessible even if CN1 is not initialized. - * - * This is accomplished by setting them inside init(). Then they - * are written to file so that they can be accessed inside a service - * like push notification service. - */ - private static Map serviceProperties; - - /** - * Gets the service properties. Will read properties from file so that - * they are available even if CN1 is not initialized. - * @param a - * @return - */ - public static Map getServiceProperties(Context a) { - if (serviceProperties == null) { - InputStream i = null; - try { - serviceProperties = new HashMap(); - try { - i = a.openFileInput("CN1$AndroidServiceProperties"); - if(i == null) { - return serviceProperties; - } - } catch (FileNotFoundException notFoundEx){ - return serviceProperties; - } - DataInputStream is = new DataInputStream(i); - int count = is.readInt(); - for (int idx=0; idx out = getServiceProperties(a); - - - for (String key : servicePropertyKeys) { - - String val = Display.getInstance().getProperty(key, null); - if (val != null) { - out.put(key, val); - } - if ("true".equals(Display.getInstance().getProperty(key+"#delete", null))) { - out.remove(key); - - } - } - - OutputStream os = null; - try { - os = a.openFileOutput("CN1$AndroidServiceProperties", 0); - if (os == null) { - System.out.println("Failed to save service properties null output stream"); - return; - } - DataOutputStream dos = new DataOutputStream(os); - dos.writeInt(out.size()); - for (String key : out.keySet()) { - dos.writeUTF(key); - dos.writeUTF((String)out.get(key)); - } - serviceProperties = null; - } catch (FileNotFoundException ex) { - System.out.println("Service properties file not found. This is normal for the first run. On subsequent runs, the file should exist."); - } catch (IOException ex) { - - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - } finally { - try { - if (os != null) os.close(); - } catch (Throwable ex) { - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - } - } - } - } - - /** - * Gets a "service" display property. This is a property that is available - * even if CN1 is not initialized. They are written to file after init() so that - * they are available thereafter to services like push notification services. - * @param key THe key - * @param defaultValue The default value - * @param context Context - * @return The value. - */ - public static String getServiceProperty(String key, String defaultValue, Context context) { - if (Display.isInitialized()) { - return Display.getInstance().getProperty(key, defaultValue); - } - String val = getServiceProperties(context).get(key); - return val == null ? defaultValue : val; - } - - /** - * Sets the notification channel on a notification builder. Uses service properties to - * set properties of channel. - * @param nm The notification manager. - * @param mNotifyBuilder The notify builder - * @param context The context - * @since 7.0 - */ - public static void setNotificationChannel(NotificationManager nm, NotificationCompat.Builder mNotifyBuilder, Context context) { - setNotificationChannel(nm, mNotifyBuilder, context, (String)null); - - } - - /** - * Sets the notification channel on a notification builder. Uses service properties to - * set properties of channel. - * @param nm The notification manager. - * @param mNotifyBuilder The notify builder - * @param context The context - * @param soundName The name of the sound to use for notifications on this channel. E.g. mysound.mp3. This feature is not yet implemented, but - * parameter is added now to scaffold compatibility with build daemon until implementation is complete. - * @since 7.0 - */ - public static void setNotificationChannel(NotificationManager nm, NotificationCompat.Builder mNotifyBuilder, Context context, String soundName) { - if (android.os.Build.VERSION.SDK_INT >= 26) { - try { - NotificationManager mNotificationManager = nm; - - String id = getServiceProperty("android.NotificationChannel.id", "cn1-channel", context); - - CharSequence name = getServiceProperty("android.NotificationChannel.name", "Notifications", context); - - String description = getServiceProperty("android.NotificationChannel.description", "Remote notifications", context); - - // NotificationManager.IMPORTANCE_LOW = 2 - // NotificationManager.IMPORTANCE_HIGH = 4 // <-- Minimum level to produce sound. - int importance = Integer.parseInt(getServiceProperty("android.NotificationChannel.importance", "4", context)); - // Note: Currently we use a single notification channel for the app, but if the app uses different kinds of - // push notifications, then this may not be sufficient. E.g. The app may send both silent push notifications - // and regular notifications - but their settings (e.g. sound) are all managed through one channel with - // same settings. - // TODO Add support for multiple channels. - // See https://github.com/codenameone/CodenameOne/issues/2583 - - Class clsNotificationChannel = Class.forName("android.app.NotificationChannel"); - //android.app.NotificationChannel mChannel = new android.app.NotificationChannel(id, name, importance); - Constructor constructor = clsNotificationChannel.getConstructor(java.lang.String.class, java.lang.CharSequence.class, int.class); - Object mChannel = constructor.newInstance(new Object[]{id, name, importance}); - - Method method = clsNotificationChannel.getMethod("setDescription", java.lang.String.class); - method.invoke(mChannel, new Object[]{description}); - //mChannel.setDescription(description); - - method = clsNotificationChannel.getMethod("enableLights", boolean.class); - method.invoke(mChannel, new Object[]{Boolean.parseBoolean(getServiceProperty("android.NotificationChannel.enableLights", "true", context))}); - //mChannel.enableLights(Boolean.parseBoolean(getServiceProperty("android.NotificationChannel.enableLights", "true", context))); - - method = clsNotificationChannel.getMethod("setLightColor", int.class); - method.invoke(mChannel, new Object[]{Integer.parseInt(getServiceProperty("android.NotificationChannel.lightColor", "" + android.graphics.Color.RED, context))}); - //mChannel.setLightColor(Integer.parseInt(getServiceProperty("android.NotificationChannel.lightColor", "" + android.graphics.Color.RED, context))); - - method = clsNotificationChannel.getMethod("enableVibration", boolean.class); - method.invoke(mChannel, new Object[]{Boolean.parseBoolean(getServiceProperty("android.NotificationChannel.enableVibration", "false", context))}); - //mChannel.enableVibration(Boolean.parseBoolean(getServiceProperty("android.NotificationChannel.enableVibration", "false", context))); - String vibrationPatternStr = getServiceProperty("android.NotificationChannel.vibrationPattern", null, context); - if (vibrationPatternStr != null) { - String[] parts = vibrationPatternStr.split(","); - int len = parts.length; - long[] pattern = new long[len]; - for (int i = 0; i < len; i++) { - pattern[i] = Long.parseLong(parts[i].trim()); - } - method = clsNotificationChannel.getMethod("setVibrationPattern", long[].class); - method.invoke(mChannel, new Object[]{pattern}); - //mChannel.setVibrationPattern(pattern); - } - - String soundUri = getServiceProperty("android.NotificationChannel.soundUri", null, context); - if (soundUri != null) { - Uri uri= android.net.Uri.parse(soundUri); - - android.media.AudioAttributes audioAttributes = new android.media.AudioAttributes.Builder() - .setContentType(android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION) - .setUsage(android.media.AudioAttributes.USAGE_NOTIFICATION) - .build(); - method = clsNotificationChannel.getMethod("setSound", android.net.Uri.class, android.media.AudioAttributes.class); - method.invoke(mChannel, new Object[]{uri, audioAttributes}); - } - - method = NotificationManager.class.getMethod("createNotificationChannel", clsNotificationChannel); - method.invoke(mNotificationManager, new Object[]{mChannel}); - //mNotificationManager.createNotificationChannel(mChannel); - try { - // For some reason I can't find the app-support-v4.jar for - // API 26 that includes this method so that I can compile in netbeans. - // So we use reflection... If someone coming after can find a newer version - // that has setChannelId(), please rip out this ugly reflection hack and - // replace it with a proper call to mNotifyBuilder.setChannelId(id) - mNotifyBuilder.getClass().getMethod("setChannelId", new Class[]{String.class}).invoke(mNotifyBuilder, new Object[]{id}); - } catch (Exception ex) { - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - } - //mNotifyBuilder.setChannelId(id); - } catch (ClassNotFoundException ex) { - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - } catch (NoSuchMethodException ex) { - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - } catch (SecurityException ex) { - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - } catch (IllegalAccessException ex) { - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - } catch (IllegalArgumentException ex) { - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - } catch (InvocationTargetException ex) { - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - } catch (InstantiationException ex) { - Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); - } - //mNotifyBuilder.setChannelId(id); - } - - } - - public Object notifyStatusBar(String tickerText, String contentTitle, - String contentBody, boolean vibrate, boolean flashLights, Hashtable args) { - int id = getContext().getResources().getIdentifier("icon", "drawable", getContext().getApplicationInfo().packageName); - - NotificationManager notificationManager = (NotificationManager) getContext().getSystemService(Activity.NOTIFICATION_SERVICE); - - Intent notificationIntent = new Intent(); - notificationIntent.setComponent(activityComponentName); - PendingIntent contentIntent = createPendingIntent(getContext(), 0, notificationIntent); - - - NotificationCompat.Builder builder = new NotificationCompat.Builder(getContext()) - .setContentIntent(contentIntent) - .setSmallIcon(id) - .setContentTitle(contentTitle) - .setTicker(tickerText); - if(flashLights){ - builder.setLights(0, 1000, 1000); - } - if(vibrate){ - builder.setVibrate(new long[]{0, 100, 1000}); - } - if(args != null) { - Boolean b = (Boolean)args.get("persist"); - if(b != null && b.booleanValue()) { - builder.setAutoCancel(false); - builder.setOngoing(true); - } else { - builder.setAutoCancel(false); - } - } else { - builder.setAutoCancel(true); - } - Notification notification = builder.build(); - int notifyId = 10001; - notificationManager.notify("CN1", notifyId, notification); - return new Integer(notifyId); - } - - public boolean isContactsPermissionGranted() { - if (android.os.Build.VERSION.SDK_INT < 23) { - return true; - } - - if (android.support.v4.content.ContextCompat.checkSelfPermission(getContext(), - Manifest.permission.READ_CONTACTS) - != PackageManager.PERMISSION_GRANTED) { - return false; - } - return true; - } - - - @Override - public String[] getAllContacts(boolean withNumbers) { - if(!checkForPermission(Manifest.permission.READ_CONTACTS, "This is required to get the contacts")){ - return new String[]{}; - } - return AndroidContactsManager.getInstance().getContacts(getContext(), withNumbers); - } - - @Override - public com.codename1.calendar.LocalCalendarSource getLocalCalendarSource() { - if (calendarSource == null) { - calendarSource = new AndroidCalendarSource(getContext()); - } - return calendarSource; - } - - @Override - public Contact getContactById(String id) { - if(!checkForPermission(Manifest.permission.READ_CONTACTS, "This is required to get the contacts")){ - return null; - } - return AndroidContactsManager.getInstance().getContact(getContext(), id); - } - - @Override - public Contact getContactById(String id, boolean includesFullName, boolean includesPicture, - boolean includesNumbers, boolean includesEmail, boolean includeAddress){ - if(!checkForPermission(Manifest.permission.READ_CONTACTS, "This is required to get the contacts")){ - return null; - } - return AndroidContactsManager.getInstance().getContact(getContext(), id, includesFullName, includesPicture, - includesNumbers, includesEmail, includeAddress); - } - - @Override - public Contact[] getAllContacts(boolean withNumbers, boolean includesFullName, boolean includesPicture, boolean includesNumbers, boolean includesEmail, boolean includeAddress) { - if(!checkForPermission(Manifest.permission.READ_CONTACTS, "This is required to get the contacts")){ - return new Contact[]{}; - } - return AndroidContactsManager.getInstance().getAllContacts(getContext(), withNumbers, includesFullName, includesPicture, includesNumbers, includesEmail, includeAddress); - } - - @Override - public boolean isGetAllContactsFast() { - return true; - } - - @Override - public boolean isContactPickerSupported() { - // Both paths behind AndroidContactPicker exist on every version this - // port runs on: the system picker from Android 17, ACTION_PICK - // against the contacts provider before that. A device with no - // contacts app answers with ActivityNotFoundException, which the - // picker reports as an empty selection -- the same thing a cancelled - // pick reports, so callers need no separate case for it. - // - // Deliberately NOT PackageManager.resolveActivity. Review asked for - // it, to catch the kiosk device that has no contacts app at all, and - // it would answer the wrong question on every ordinary one: from - // Android 11 a resolve query is filtered by package visibility, so an - // app without a matching entry is told nothing handles the - // intent even where the picker works perfectly. LAUNCHING an implicit - // intent is not filtered, which is why the picker itself needs no - // and works regardless. Trading a false yes on a stripped - // device -- whose cost is a pick that reports empty, exactly as a - // cancelled one does -- for a false no on every modern device, whose - // cost is a working feature hidden with no way to find out why, is a - // bad trade. - return getActivity() != null; - } - - @Override - public void pickContacts(int requestedFields, boolean multiSelect, - int selectionLimit, boolean requireAllRequestedFields, - ActionListener response) { - if (getActivity() == null) { - fireContactPickerResult(response, new Contact[0]); - return; - } - if (editInProgress()) { - stopEditing(true); - } - // Deliberately no checkForPermission call. The whole point of the - // picker is that neither path needs READ_CONTACTS, and asking for it - // here would put the permission back into the manifest and in front - // of the user for a flow that does not need it. - AndroidContactPicker.pick(getContext(), requestedFields, multiSelect, - selectionLimit, requireAllRequestedFields, - new ContactPickerResult(response)); - } - - /** - * Hands a picker selection back to the listener that asked for it. - */ - private final class ContactPickerResult implements AndroidContactPicker.Result { - private final ActionListener response; - - ContactPickerResult(ActionListener response) { - this.response = response; - } - - @Override - public void picked(Contact[] picked) { - fireContactPickerResult(response, picked); - } - } - - public String createContact(String firstName, String surname, String officePhone, String homePhone, String cellPhone, String email) { - if(!checkForPermission(Manifest.permission.WRITE_CONTACTS, "This is required to create a contact")){ - return null; - } - return AndroidContactsManager.getInstance().createContact(getContext(), firstName, surname, officePhone, homePhone, cellPhone, email); - } - - public boolean deleteContact(String id) { - if(!checkForPermission(Manifest.permission.WRITE_CONTACTS, "This is required to delete a contact")){ - return false; - } - return AndroidContactsManager.getInstance().deleteContact(getContext(), id); - } - - @Override - public boolean isNativeShareSupported() { - return true; - } - - @Override - public boolean isNativeInAppReviewSupported() { - // True only when the Play In-App Review library was bundled, which the - // AndroidGradleBuilder does when the app references the app-review API. - return getActivity() != null && AppReviewSupport.isSupported(); - } - - @Override - public void requestNativeInAppReview(final SuccessCallback done) { - final CodenameOneActivity activity = getActivity(); - if (activity == null || !AppReviewSupport.isSupported()) { - if (done != null) { - done.onSucess(Boolean.FALSE); - } - return; - } - activity.runOnUiThread(new Runnable() { - public void run() { - AppReviewSupport.requestReview(activity, done); - } - }); - } - - @Override - public void share(String text, String image, String mimeType, Rectangle sourceRect){ - share(text, image, mimeType, sourceRect, null); - } - - @Override - public void share(String text, String image, String mimeType, Rectangle sourceRect, final com.codename1.share.ShareResultListener listener) { - /*if(!checkForPermission(Manifest.permission.READ_PHONE_STATE, "This is required to perform share")){ - return; - }*/ - Intent shareIntent = new Intent(android.content.Intent.ACTION_SEND); - if(image == null){ - if (text.startsWith("file:") && mimeType != null && new com.codename1.io.File(text).exists()) { - shareIntent.setType(mimeType); - shareIntent.putExtra(Intent.EXTRA_STREAM, Uri.parse(fixAttachmentPath(text))); - } else { - shareIntent.setType("text/plain"); - shareIntent.putExtra(android.content.Intent.EXTRA_TEXT, text); - } - }else{ - shareIntent.setType(mimeType); - shareIntent.putExtra(Intent.EXTRA_STREAM, Uri.parse(fixAttachmentPath(image))); - shareIntent.putExtra(Intent.EXTRA_TEXT, text); - } - - Intent chooser; - try { - if (listener != null && android.os.Build.VERSION.SDK_INT >= 22) { - chooser = buildShareChooserWithCallback(shareIntent, listener); - } else { - chooser = Intent.createChooser(shareIntent, "Share with..."); - } - } catch (Throwable t) { - // Fall back to the plain chooser, then synthesize a listener - // result so the app doesn't hang on an unfulfilled callback. - chooser = Intent.createChooser(shareIntent, "Share with..."); - if (listener != null) { - listener.onResult(com.codename1.share.ShareResult.sharedTo(null)); - } - } - getContext().startActivity(chooser); - } - - // ONE receiver for the process, and one listener held at a time. - // - // A receiver per share leaked every cancelled one. It is unregistered from - // inside onReceive, and Android sends nothing when the chooser is - // dismissed -- there is no public dismissal signal -- so a cancelled share - // left its receiver registered on the application context, holding the - // listener and, through it, the button and the form it is on. Each cancel - // added another, for the life of the process, and a share button is - // exactly the kind of control a user opens and backs out of repeatedly. - // - // Reusing one receiver bounds that at a single retained listener: the next - // share replaces the one a dismissal left behind. It cannot be driven to - // zero from here, because knowing the chooser was dismissed is the thing - // Android does not tell us. - // - // Instance fields, not static: there is one implementation per process, - // the receiver belongs to it, and a lazily initialised static is a - // different claim -- one SpotBugs reads as a threading bug, correctly, - // because nothing here would make it safe if it were true. - // - // pendingShareListener is written from the Codename One EDT and read on - // the Android main thread, which is why it is volatile. That is a native - // boundary crossing, not core framework code. - private BroadcastReceiver shareChooserReceiver; - - private String shareChooserAction; - - private volatile com.codename1.share.ShareResultListener pendingShareListener; - - @TargetApi(22) - private Intent buildShareChooserWithCallback(Intent shareIntent, final com.codename1.share.ShareResultListener listener) { - final Context appCtx = getContext().getApplicationContext(); - // The listener this chooser is for. Set before the receiver can - // possibly fire, and replacing whatever a dismissed chooser left. - pendingShareListener = listener; - if (shareChooserReceiver != null) { - // Already registered and listening on the same action, so there is - // nothing to build but the PendingIntent below. - return chooserFor(appCtx, shareIntent, shareChooserAction); - } - final String action = appCtx.getPackageName() + ".CN1_SHARE_CHOSEN"; - shareChooserAction = action; - // The receiver fires once when the user picks a target. Android - // does not expose a dismissal signal for the chooser, so the - // listener simply does not fire on user-cancel (see comment - // further down). - BroadcastReceiver receiver = new BroadcastReceiver() { - @Override - public void onReceive(Context ctx, Intent intent) { - // Taken, so a repeat broadcast cannot deliver twice. The - // receiver stays registered for the next share. - com.codename1.share.ShareResultListener target = pendingShareListener; - pendingShareListener = null; - if (target == null) { - return; - } - String pkg = null; - try { - android.content.ComponentName cn = intent.getParcelableExtra(Intent.EXTRA_CHOSEN_COMPONENT); - if (cn != null) pkg = cn.getPackageName(); - } catch (Throwable ignore) {} - target.onResult(com.codename1.share.ShareResult.sharedTo(pkg)); - } - }; - IntentFilter filter = new IntentFilter(action); - boolean registered = false; - if (android.os.Build.VERSION.SDK_INT >= 33) { - // RECEIVER_EXPORTED = 0x2 -- constant exists at runtime on - // API 33+ but is not present in older android.jar build deps, - // so call the 3-arg overload via reflection to stay source- - // compatible. - try { - java.lang.reflect.Method m = Context.class.getMethod( - "registerReceiver", BroadcastReceiver.class, IntentFilter.class, int.class); - m.invoke(appCtx, receiver, filter, Integer.valueOf(0x2)); - registered = true; - } catch (Throwable ignore) {} - } - if (!registered) { - appCtx.registerReceiver(receiver, filter); - } - // Recorded only once it is really listening, so a registration that - // threw is retried by the next share rather than skipped for ever. - shareChooserReceiver = receiver; - // Android's chooser IntentSender callback never fires on - // dismissal: there is no public API to observe a user-cancel. - // Apps that need a dismissal signal must use Activity-resume. - - return chooserFor(appCtx, shareIntent, action); - } - - /// The chooser Intent itself, wrapping a broadcast PendingIntent on this - /// action. - /// - /// Split out because it is built on every share while the receiver behind - /// it is built once. FLAG_UPDATE_CURRENT is what makes the fixed action - /// safe to reuse: the same PendingIntent is handed back with this - /// chooser's extras, and only one chooser is ever up at a time. - @TargetApi(22) - private Intent chooserFor(Context appCtx, Intent shareIntent, String action) { - Intent pi = new Intent(action).setPackage(appCtx.getPackageName()); - int piFlags = PendingIntent.FLAG_UPDATE_CURRENT; - if (android.os.Build.VERSION.SDK_INT >= 31) { - // FLAG_MUTABLE was introduced in API 31; its numeric value - // (0x02000000) is referenced here directly so the source - // still compiles against pre-31 android.jar build deps. - piFlags |= 0x02000000; - } - PendingIntent pendingIntent = PendingIntent.getBroadcast(appCtx, 0, pi, piFlags); - return Intent.createChooser(shareIntent, "Share with...", pendingIntent.getIntentSender()); - } - - /// Printing uses the Android print framework which requires API 19 - /// and a foreground activity to host the print dialog. - @Override - public boolean isPrintingSupported() { - return android.os.Build.VERSION.SDK_INT >= 19 && getActivity() != null; - } - - /// Print through the Android print framework. PDF files are streamed - /// verbatim into a `android.print.PrintDocumentAdapter`; images go - /// through the support library `PrintHelper` which scales them to the - /// page. - /// - /// Outcome reporting is best effort: the PDF path polls the returned - /// `android.print.PrintJob` and treats a queued/started job as - /// completed since Android offers no callback for the terminal job - /// state once it was handed to the print service. The image path - /// reports completed when `PrintHelper` finishes because it can't - /// distinguish a dismissed dialog from a printed page. - @Override - public void print(final String filePath, final String mimeType, final com.codename1.printing.PrintResultListener listener) { - final PrintResultDispatcher dispatcher = new PrintResultDispatcher(listener); - if (!isPrintingSupported()) { - dispatcher.fire(com.codename1.printing.PrintResult.failed( - "Printing requires Android 4.4 or newer and a foreground activity")); - return; - } - if (filePath == null) { - dispatcher.fire(com.codename1.printing.PrintResult.failed("No file to print")); - return; - } - final File file = new File(removeFilePrefix(filePath)); - if (!file.exists()) { - dispatcher.fire(com.codename1.printing.PrintResult.failed("File not found: " + filePath)); - return; - } - getActivity().runOnUiThread(new Runnable() { - @Override - public void run() { - try { - // PrintSupport touches android.print which only exists - // on API 19+; the isPrintingSupported() gate above keeps - // the class from loading on older devices. - PrintSupport.startPrint(getActivity(), file, mimeType, dispatcher); - } catch (Throwable t) { - dispatcher.fire(com.codename1.printing.PrintResult.failed( - "Failed to start print job: " + t)); - } - } - }); - } - - /// Delivers a [com.codename1.printing.PrintResult] to the listener at - /// most once. The listener may be null and results may arrive from any - /// thread; `Display` moves the callback onto the EDT. - private static final class PrintResultDispatcher { - private final com.codename1.printing.PrintResultListener listener; - private boolean fired; - - PrintResultDispatcher(com.codename1.printing.PrintResultListener listener) { - this.listener = listener; - } - - void fire(com.codename1.printing.PrintResult result) { - synchronized (this) { - if (fired) { - return; - } - fired = true; - } - if (listener != null) { - listener.onResult(result); - } - } - } - - /// All android.print framework access lives in this class so the - /// classes it references are only loaded behind the API 19 check in - /// [#print]. - @TargetApi(19) - private static final class PrintSupport { - - private static final int JOB_PENDING = 0; - private static final int JOB_COMPLETED = 1; - private static final int JOB_CANCELLED = 2; - private static final int JOB_FAILED = 3; - - /// How long the poller waits for the print dialog/job to reach a - /// terminal state before giving up. - private static final long POLL_TIMEOUT = 15 * 60 * 1000L; - private static final long POLL_INTERVAL = 500; - - /// Must run on the UI thread: `PrintManager.print` and - /// `PrintHelper.printBitmap` both require it. - static void startPrint(Activity activity, File file, String mimeType, PrintResultDispatcher dispatcher) { - String jobName = file.getName(); - if ("application/pdf".equalsIgnoreCase(mimeType)) { - android.print.PrintManager printManager = - (android.print.PrintManager) activity.getSystemService(Context.PRINT_SERVICE); - if (printManager == null) { - dispatcher.fire(com.codename1.printing.PrintResult.failed("Print service unavailable")); - return; - } - android.print.PrintJob job = printManager.print(jobName, - new PdfFilePrintAdapter(jobName, file), null); - pollPrintJob(activity, job, dispatcher); - } else if (mimeType != null && mimeType.startsWith("image/")) { - printImage(activity, file, jobName, dispatcher); - } else { - dispatcher.fire(com.codename1.printing.PrintResult.failed( - "Unsupported print document type: " + mimeType)); - } - } - - private static void printImage(Activity activity, File file, String jobName, - final PrintResultDispatcher dispatcher) { - Bitmap bitmap = BitmapFactory.decodeFile(file.getAbsolutePath()); - if (bitmap == null) { - dispatcher.fire(com.codename1.printing.PrintResult.failed( - "Unable to decode image for printing")); - return; - } - android.support.v4.print.PrintHelper helper = new android.support.v4.print.PrintHelper(activity); - helper.setScaleMode(android.support.v4.print.PrintHelper.SCALE_MODE_FIT); - helper.printBitmap(jobName, bitmap, new android.support.v4.print.PrintHelper.OnPrintFinishCallback() { - @Override - public void onFinish() { - // PrintHelper fires onFinish when the print flow ends - // without exposing whether the user printed or - // dismissed the dialog; report completed best effort. - dispatcher.fire(com.codename1.printing.PrintResult.completed()); - } - }); - } - - /// Watches the print job from a background thread and reports the - /// first terminal state. The job object must only be queried on - /// the UI thread, so every tick bounces through `runOnUiThread`. - private static void pollPrintJob(final Activity activity, final android.print.PrintJob job, - final PrintResultDispatcher dispatcher) { - Thread poller = new Thread(new Runnable() { - @Override - public void run() { - long deadline = System.currentTimeMillis() + POLL_TIMEOUT; - while (System.currentTimeMillis() < deadline) { - try { - Thread.sleep(POLL_INTERVAL); - } catch (InterruptedException ignore) { - } - final int[] state = new int[]{JOB_PENDING}; - final boolean[] done = new boolean[1]; - final Object lock = new Object(); - activity.runOnUiThread(new Runnable() { - @Override - public void run() { - int s = JOB_PENDING; - try { - if (job.isCancelled()) { - s = JOB_CANCELLED; - } else if (job.isFailed()) { - s = JOB_FAILED; - } else if (job.isCompleted()) { - s = JOB_COMPLETED; - } else if (job.isQueued() || job.isStarted() || job.isBlocked()) { - // The dialog phase is over and the - // job belongs to the print service; - // that is as "completed" as Android - // lets us observe reliably. - s = JOB_COMPLETED; - } - } catch (Throwable t) { - s = JOB_FAILED; - } - synchronized (lock) { - state[0] = s; - done[0] = true; - lock.notifyAll(); - } - } - }); - synchronized (lock) { - long waitUntil = System.currentTimeMillis() + 5000; - while (!done[0] && System.currentTimeMillis() < waitUntil) { - try { - lock.wait(POLL_INTERVAL); - } catch (InterruptedException ignore) { - } - } - if (!done[0]) { - // UI thread didn't get to us; try again on - // the next tick until the deadline passes. - continue; - } - } - switch (state[0]) { - case JOB_COMPLETED: - dispatcher.fire(com.codename1.printing.PrintResult.completed()); - return; - case JOB_CANCELLED: - dispatcher.fire(com.codename1.printing.PrintResult.cancelled()); - return; - case JOB_FAILED: - dispatcher.fire(com.codename1.printing.PrintResult.failed("Print job failed")); - return; - default: - // still in the dialog phase, keep polling - } - } - dispatcher.fire(com.codename1.printing.PrintResult.failed( - "Timed out waiting for the print job status")); - } - }, "CN1PrintJobPoller"); - poller.setDaemon(true); - poller.start(); - } - - /// Streams an existing PDF file into the print system unchanged. - /// Layout/write failures are routed through the framework - /// callbacks which fail the print job; the poller in - /// [#pollPrintJob] then reports the failure to the listener, so - /// the dispatcher still fires exactly once. - private static final class PdfFilePrintAdapter extends android.print.PrintDocumentAdapter { - private final String jobName; - private final File file; - - PdfFilePrintAdapter(String jobName, File file) { - this.jobName = jobName; - this.file = file; - } - - @Override - public void onLayout(android.print.PrintAttributes oldAttributes, - android.print.PrintAttributes newAttributes, - android.os.CancellationSignal cancellationSignal, - LayoutResultCallback callback, Bundle extras) { - if (cancellationSignal != null && cancellationSignal.isCanceled()) { - callback.onLayoutCancelled(); - return; - } - try { - android.print.PrintDocumentInfo info = new android.print.PrintDocumentInfo.Builder(jobName) - .setContentType(android.print.PrintDocumentInfo.CONTENT_TYPE_DOCUMENT) - .setPageCount(android.print.PrintDocumentInfo.PAGE_COUNT_UNKNOWN) - .build(); - callback.onLayoutFinished(info, !newAttributes.equals(oldAttributes)); - } catch (Throwable t) { - callback.onLayoutFailed(t.toString()); - } - } - - @Override - public void onWrite(android.print.PageRange[] pages, - android.os.ParcelFileDescriptor destination, - android.os.CancellationSignal cancellationSignal, - WriteResultCallback callback) { - FileInputStream in = null; - FileOutputStream out = null; - try { - in = new FileInputStream(file); - out = new FileOutputStream(destination.getFileDescriptor()); - byte[] buffer = new byte[8192]; - int count; - while ((count = in.read(buffer)) > -1) { - if (cancellationSignal != null && cancellationSignal.isCanceled()) { - callback.onWriteCancelled(); - return; - } - out.write(buffer, 0, count); - } - callback.onWriteFinished(new android.print.PageRange[]{android.print.PageRange.ALL_PAGES}); - } catch (Throwable t) { - callback.onWriteFailed(t.toString()); - } finally { - if (in != null) { - try { - in.close(); - } catch (Throwable ignore) { - } - } - if (out != null) { - try { - out.close(); - } catch (Throwable ignore) { - } - } - } - } - } - } - - /** - * @inheritDoc - */ - public String getPlatformName() { - return "and"; - } - - /** - * Snapshot of the recent process logcat for crash protection. Since - * Android 4.1 (API 16) apps can only read their own process log - * without the READ_LOGS permission, which is exactly what we want. - * Returns the last ~200 lines (capped at 32 KB). - */ - @Override - public String getNativeLogSnapshot() { - java.io.BufferedReader reader = null; - Process proc = null; - try { - proc = Runtime.getRuntime().exec(new String[]{ - "logcat", "-d", "-t", "200", "-v", "threadtime"}); - reader = new java.io.BufferedReader( - new java.io.InputStreamReader(proc.getInputStream(), "UTF-8")); - StringBuilder sb = new StringBuilder(8192); - String line; - while ((line = reader.readLine()) != null) { - sb.append(line).append('\n'); - if (sb.length() > 32 * 1024) { - break; - } - } - return sb.length() == 0 ? null : sb.toString(); - } catch (Throwable ignored) { - // logcat unavailable (very old Android, locked-down ROM, - // etc.) -- crash protection still works, just without the - // device log context. - return null; - } finally { - if (reader != null) { - try { reader.close(); } catch (java.io.IOException ignored) { } - } - if (proc != null) { - try { proc.destroy(); } catch (Throwable ignored) { } - } - } - } - - /** - * @inheritDoc - */ - public String[] getPlatformOverrides() { - if (isWatch()) { - return new String[]{"watch", "android", "android-watch"}; - } - if (isTV()) { - return new String[]{"tv", "android", "android-tv"}; - } - if (isTablet()) { - return new String[]{"tablet", "android", "android-tab"}; - } else { - return new String[]{"phone", "android", "android-phone"}; - } - } - - /** - * @inheritDoc - */ - public void copyToClipboard(final Object obj) { - super.copyToClipboard(obj); - if (getActivity() == null) { - return; - } - getActivity().runOnUiThread(new Runnable() { - @Override - public void run() { - int sdk = android.os.Build.VERSION.SDK_INT; - if (sdk < 11) { - android.text.ClipboardManager clipboard = (android.text.ClipboardManager) getActivity().getSystemService(Context.CLIPBOARD_SERVICE); - clipboard.setText(obj.toString()); - // Afterwards, as in the branch below: a clip that was never published has - // not replaced the one the system is still holding, and unpinning that one - // first left its files reclaimable while it was still there to be pasted. - clipboardHolds(0); - } else { - android.content.ClipboardManager clipboard = (android.content.ClipboardManager) getActivity().getSystemService(Context.CLIPBOARD_SERVICE); - android.content.ClipData clip; - long staged = 0; - boolean assembled = false; - if (obj instanceof ClipboardContent) { - AssembledClip built = clipDataFor((ClipboardContent) obj); - clip = built == null ? null : built.getData(); - staged = built == null ? 0 : built.getClip(); - assembled = true; - if (clip == null) { - // A copy of nothing is an empty clipboard, which is a thing the user - // asked for and can paste. A *drag* of nothing is not: there the null - // refuses to start, because a drag that carries nothing still lands - // somewhere and tells that receiver it succeeded. - clip = ClipData.newPlainText("Codename One", ""); - } - } else { - // Nothing of ours is staged for a plain text clip. - clip = ClipData.newPlainText("Codename One", obj.toString()); - } - watchPrimaryClip(clipboard); - // Pinned for the length of the call, held only if it returns. setPrimaryClip - // can throw -- a payload past the Binder transaction limit is the usual way - // -- and switching the hold beforehand handed the *old* clip's files to - // reclamation while the system was still holding that clip, pinned the ones - // that never reached the clipboard in their place, and left a callback - // counted that would never arrive. The pin in between is what keeps the new - // clip's own files from being reclaimed in the window this opens. - clipboardPublishing(staged); - boolean published = false; - try { - clipboard.setPrimaryClip(clip); - published = true; - } finally { - clipboardPublished(staged, published); - if (assembled) { - // Taken over by the clipboard, or given up on. Either way this - // assembly is no longer one nothing has claimed. - endStagingClip(staged); - } - } - } - } - }); - } - - /// Builds the Android clip that publishes a `ClipboardContent`, for a clipboard copy and - /// for a native drag alike -- both hand another application the same thing, so both go - /// through the same conversion, including the file provider URIs that let the receiving - /// application read generated image bytes. - /// - /// #### Parameters - /// - /// - `content`: the representations to publish - /// - /// #### Returns - /// - /// the clip, or null when the content produced no representation at all - AssembledClip clipDataFor(ClipboardContent content) { - // Held here and handed down, never read back off the field. A clipboard copy runs - // on the Android UI thread and a drag on the Codename One event dispatch thread, so - // two assemblies can overlap -- and one reading the field mid-way filed its - // remaining files under the other's id, which split one clip across two and left - // the half nobody pinned free to be deleted while the clip still referenced it. - final long clip = beginStagingClip(); - // Every read this assembly makes goes through here; see Assembly for why it is not the - // content's own memory of what its providers produced. - Assembly assembly = new Assembly(content); - int sdk = android.os.Build.VERSION.SDK_INT; - List mimeTypes = new ArrayList(); - List items = new ArrayList(); - String plain = assembly.text(ClipboardContent.MIME_TEXT); - String html = assembly.text(ClipboardContent.MIME_HTML); - // A clip carries one text payload. Where the content has no text/plain but does have - // some other text representation -- markdown, AsciiDoc, a URI list -- that one is the - // payload, since publishing an empty clip instead would lose it outright. - String primaryTextMime = plain != null ? ClipboardContent.MIME_TEXT : null; - // Not when there is HTML: that is already the payload, and the plain text beside it is - // derived from the markup below rather than searched for among the other - // representations, which would put an unrelated one under the HTML. - if (plain == null && html == null) { - String[] advertised = content.getMimeTypes(); - for (int iter = 0; iter < advertised.length && plain == null; iter++) { - if (!advertised[iter].startsWith("text/")) { - // Text types only, however the value happens to be carried. A String under - // application/json -- or under an application's own type -- is that type's - // encoding and not a reading the source offered as text, and publishing it - // as the clip's text let a text-only application paste a representation - // nobody advertised to it. Nothing is lost by refusing: a String under a - // type that is not text travels as a typed content URI like any other - // representation, under its own name. The file list is covered by the same - // test, since that is not a text type either. - // - // The types getMimeTypes answers with are normalized to lower case, so this - // is an ASCII comparison against an ASCII constant and no locale enters it. - continue; - } - String value = assembly.text(advertised[iter]); - if (value != null) { - plain = value; - primaryTextMime = advertised[iter]; - } - } - } - // The types are recorded here, but the text does not become an item of its own yet. A - // clip item is a dragged *object*, so a text item beside a file item is two things - // being dragged at once, and a receiver that imports everything takes the document - // *and* a stray piece of text instead of choosing the best form of one thing. Where - // the clip carries a URI, the text rides on it -- see attachCarriedText below. - boolean carriesHtml = sdk >= 16 && html != null; - if (carriesHtml && plain == null) { - // Android *requires* it: ClipData.Item refuses HTML with no plain text beside it, - // and threw IllegalArgumentException out of the thread that was building the clip - // -- so content offering nothing but MIME_HTML crashed a copy and silently failed - // a drag. Rendered from the markup rather than being the markup, which would show - // every receiver the tags. - plain = htmlToPlainText(html); - } - if (carriesHtml) { - mimeTypes.add(ClipboardContent.MIME_TEXT); - mimeTypes.add(ClipboardContent.MIME_HTML); - } else if (plain != null) { - mimeTypes.add(ClipboardContent.MIME_TEXT); - if (primaryTextMime != null && !mimeTypes.contains(primaryTextMime)) { - mimeTypes.add(primaryTextMime); - } - } - // One pass at a time. Together under a single catch, a failure in the first abandoned - // the two after it as well, so a clip whose image could not be written went out - // without the document and the typed representations it also had. - try { - addBinaryContent(assembly, mimeTypes, items, clip); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - try { - addPublishedUris(assembly, mimeTypes, items, clip); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - try { - addRemainingRepresentations(assembly, plain, mimeTypes, items, clip); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - if (carriesHtml || plain != null) { - attachCarriedText(items, plain, carriesHtml ? html : null); - } - if (items.isEmpty()) { - // Nothing was produced. Every representation this content offered is a provider that - // answered null or threw, which ClipboardDataProvider explicitly permits -- so there - // is no clip, and the callers decide what that means. Answering with empty text - // instead replaced the payload with a different one: a drag offering only - // application/pdf reported success and let another application accept blank text. - return new AssembledClip(null, clip); - } - // Built from the union of the types, not by appending to a text clip. ClipData.addItem - // does not add the item's type to the description, so a clip assembled that way - // describes itself as text only -- and both a Codename One drop target filtering on - // MIME_FILE and an external receiver choosing a representation read the description. - ClipData data = new ClipData("Codename One", - mimeTypes.toArray(new String[mimeTypes.size()]), items.get(0)); - for (int iter = 1; iter < items.size(); iter++) { - data.addItem(items.get(iter)); - } - return new AssembledClip(data, clip); - } - - /// A clip and the assembly that built it. - /// - /// The id travels with the clip because that is the only way its caller can say which - /// assembly the clipboard or the drag now holds: a field read afterwards answers about - /// whichever assembly began most recently, and two of them can be in flight at once. - static final class AssembledClip { - /// The clip, or null when the content produced nothing that could be published. - private final ClipData data; - private final long clip; - - AssembledClip(ClipData data, long clip) { - this.data = data; - this.clip = clip; - } - - ClipData getData() { - return data; - } - - long getClip() { - return clip; - } - } - - // ------------------------------------------------------------------------------------ - // Native drag and drop. See AndroidNativeDragAndDrop; the payload is the same ClipData a - // copy publishes, which is why a drag out of the application lands in another application - // exactly as a paste would. - // ------------------------------------------------------------------------------------ - - @Override - public boolean isNativeDragAndDropSupported() { - return AndroidNativeDragAndDrop.isSupported(); - } - - @Override - public boolean isNativeDragOutsideApplicationSupported() { - return AndroidNativeDragAndDrop.isOutsideApplicationSupported(); - } - - @Override - public boolean startNativeDrag(com.codename1.ui.NativeDragOperation op) { - return AndroidNativeDragAndDrop.startDrag(this, op); - } - - @Override - public void cancelNativeDrag() { - AndroidNativeDragAndDrop.cancelDrag(); - } - - /** - * Collects the image bytes and file references carried by the ClipboardContent as items and - * MIME types, exposing binary content as FileProvider content:// URIs. The caller assembles - * the ClipData from the union of everything collected here and the text types, because - * ClipData.addItem cannot widen a description that already exists. - */ - private void addBinaryContent(Assembly assembly, List mimeTypes, - List items, long clip) throws IOException { - String authority = getContext().getPackageName() + ".provider"; - - // The files first, then the byte-backed representations. Android's ClipData.Item holds - // exactly one Uri, so two representations that are both bytes cannot be one item -- the - // platform has no way to say "another reading of the same object" for them, only for - // the text and markup that attachCarriedText rides on the item below. Publishing them - // is still right: they are what the description advertises, and dropping them would - // refuse the very target that accepted the hover on one. What order fixes is which - // object a receiver reading only the first item takes -- the document, not its - // thumbnail. - // - // It is also what puts the carried text on the document rather than on the thumbnail. - - // File references: MIME_FILE may be a single String or a String[] - Object fileData = assembly.value(ClipboardContent.MIME_FILE); - if (fileData != null) { - String[] paths; - if (fileData instanceof String[]) { - paths = (String[]) fileData; - } else { - paths = new String[]{ fileData.toString() }; - } - for (int i = 0; i < paths.length; i++) { - String pathOrUri = paths[i]; - if (pathOrUri == null || pathOrUri.length() == 0) { - continue; - } - // Each file on its own. A path outside the roots the file provider was - // configured with throws, and one throwing on the second of three used to - // abandon the third as well *and* skip every representation after the file - // loop -- so the clip went out holding one file, silently, and the drag - // reported success. - try { - Uri u; - if (hasScheme(pathOrUri, "content:")) { - u = Uri.parse(pathOrUri); - } else { - File file = hasScheme(pathOrUri, "file:") - ? new File(Uri.parse(pathOrUri).getPath()) - : new File(pathOrUri); - u = shareableUriFor(file, authority, clip); - } - if (!mimeTypes.contains("text/uri-list")) { - mimeTypes.add("text/uri-list"); - } - // And whatever the document actually is. A receiver in another application - // reads the description and nothing else while the drag hovers, so a PDF - // dragged out of here described only as a URI list was refused by every - // target that filters on application/pdf -- the type was there for the - // asking on the URI, and only this side can ask it in time. The alias the - // hover adds locally cannot help them; it never leaves this process. - // - // Only a type the resolver actually knows. octet-stream is what a provider - // answers when it has nothing to say, and advertising that would tell a - // receiver the clip holds a type it cannot use. - String resolved = bareMimeType( - getContext().getContentResolver().getType(u)); - if (resolved != null && resolved.length() > 0 - && !"application/octet-stream".equals(resolved) - && !mimeTypes.contains(resolved)) { - mimeTypes.add(resolved); - } - items.add(new ClipData.Item(u)); - } catch (Throwable t) { - // Absent rather than advertised: nothing named it a type of its own, so - // no receiver is told the clip holds a file it does not. - com.codename1.io.Log.e(t); - } - } - } - - // Image bytes: prefer PNG, then JPEG, then GIF - String imageMime = null; - byte[] imageBytes = null; - String imageExt = null; - imageBytes = assembly.bytes(ClipboardContent.MIME_PNG); - if (imageBytes != null) { - imageMime = ClipboardContent.MIME_PNG; - imageExt = "png"; - } else { - imageBytes = assembly.bytes(ClipboardContent.MIME_JPEG); - if (imageBytes != null) { - imageMime = ClipboardContent.MIME_JPEG; - imageExt = "jpg"; - } else { - imageBytes = assembly.bytes(ClipboardContent.MIME_GIF); - if (imageBytes != null) { - imageMime = ClipboardContent.MIME_GIF; - imageExt = "gif"; - } - } - } - if (imageBytes != null) { - try { - Uri imageUri = writeAsProviderUri(imageBytes, imageExt, imageMime, clip); - if (imageUri != null) { - if (!mimeTypes.contains(imageMime)) { - mimeTypes.add(imageMime); - } - items.add(new ClipData.Item(imageUri)); - } - } catch (Throwable t) { - // On its own, so a picture that cannot be written does not take the files - // and the other representations with it. - com.codename1.io.Log.e(t); - } - } - } - - /// The text of an HTML fragment, for the plain text Android requires beside it. - /// - /// Empty rather than null when the markup renders to nothing: an item may carry empty text - /// with its HTML, and may not carry none. - private static String htmlToPlainText(String html) { - try { - CharSequence text = android.os.Build.VERSION.SDK_INT >= 24 - ? android.text.Html.fromHtml(html, android.text.Html.FROM_HTML_MODE_LEGACY) - : android.text.Html.fromHtml(html); - return text == null ? "" : text.toString(); - } catch (Throwable t) { - // Markup this platform will not parse still has to travel; the HTML is the payload - // and the text beside it is what Android asks for, not what the clip is for. - com.codename1.io.Log.e(t); - return ""; - } - } - - /// Puts the URIs a text/uri-list names on the clip as URIs. - /// - /// A URI is what an Android receiver reads off `ClipData.Item#getUri()`, and a link has - /// nothing else to be read off. Left to the passes around this one a uri-list became - /// carried text, or -- where the clip had text already -- a content URI holding the list - /// as a document; either way a receiver that took the clip because it advertised - /// text/uri-list found no URI on it at all. - /// - /// One item per URI, because an item is a dragged object and a list of three links is - /// three of them. The clip's text still rides on the first, as it does on a file. - private void addPublishedUris(Assembly assembly, List mimeTypes, - List items, long clip) { - String list = assembly.text(ClipboardContent.MIME_URI_LIST); - if (list == null) { - return; - } - // The files the source published, which the clip is already carrying: each went onto - // it as a content URI this application minted, so the list's own spelling of the same - // document -- a path, or a file: URI of it -- would drag that document a second time. - // - // Compared against those paths rather than against the minted URIs, which are not - // equal to anything the source wrote. Entry by entry, too: returning on the first file - // threw away every *other* line, so a document published beside its own web address - // advertised text/uri-list and delivered the document alone. - List alreadyCarried = new ArrayList(); - Object files = assembly.value(ClipboardContent.MIME_FILE); - if (files instanceof String[]) { - String[] paths = (String[]) files; - for (int iter = 0; iter < paths.length; iter++) { - if (paths[iter] != null) { - alreadyCarried.add(publishedUriKey(paths[iter])); - } - } - } else if (files instanceof String) { - alreadyCarried.add(publishedUriKey((String) files)); - } - boolean carriesPublishedFile = false; - for (int iter = 0; iter < items.size(); iter++) { - Uri carried = items.get(iter).getUri(); - // A *generated* URI is not one of the source's. It carries a representation's - // bytes -- an image, a document this application encoded -- and a reader filters - // it out precisely because the source never published it as a URI. - if (carried != null && !isGeneratedClipFile(carried)) { - carriesPublishedFile = true; - break; - } - } - boolean any = false; - String[] lines = list.split("\n"); - for (int iter = 0; iter < lines.length; iter++) { - String line = lines[iter].trim(); - // RFC 2483: a line opening with a hash is a comment, not a URI. - if (line.length() == 0 || line.charAt(0) == '#') { - continue; - } - if (alreadyCarried.contains(publishedUriKey(line))) { - continue; - } - Uri published = publishableUri(line, clip); - if (published == null) { - continue; - } - items.add(new ClipData.Item(published)); - any = true; - } - // Declared when the clip can produce one: the entries just added, the published files - // a reader builds the list back out of, or both. - if (any || carriesPublishedFile) { - declareUriList(mimeTypes); - } - } - - /// One entry of a URI list, in a form the clip may leave this process with, or null when - /// it cannot be published at all. - /// - /// A file: URI is the case that needs the work. Android refuses to let a clip carrying one - /// cross the application boundary -- prepareToLeaveProcess throws FileUriExposedException - /// from API 24 -- so a copy of a list naming a local document threw out of the UI thread it - /// was made on, and a global drag of one never started. It goes through the file provider - /// exactly as the file representation does, which is also what makes it *readable* by the - /// receiver rather than merely legal. - /// - /// Anything else -- an http address, a mailto:, another application's content URI -- is - /// already publishable and travels as it was written. - private Uri publishableUri(String line, long clip) { - if (!hasScheme(line, "file:")) { - return Uri.parse(line); - } - String path = Uri.parse(line).getPath(); - if (path == null || path.length() == 0) { - return null; - } - try { - return shareableUriFor(new File(path), - getContext().getPackageName() + ".provider", clip); - } catch (Throwable t) { - // Absent rather than advertised, as the file representation does it: a document - // outside the roots the provider was configured with cannot be handed over, and - // naming it anyway tells the receiver the clip holds something it will not get. - com.codename1.io.Log.e(t); - return null; - } - } - - /// What two spellings of one file have in common. - /// - /// ClipboardContent's file representation permits a raw path, and a URI list beside it - /// commonly names the same document as a file: URI -- percent encoded, as a URI is. They - /// are one document, and putting both on the clip drags it twice. - private static String publishedUriKey(String value) { - if (hasScheme(value, "file:")) { - String path = Uri.parse(value).getPath(); - return path == null ? value : path; - } - return value; - } - - private static void declareUriList(List mimeTypes) { - if (!mimeTypes.contains(ClipboardContent.MIME_URI_LIST)) { - mimeTypes.add(ClipboardContent.MIME_URI_LIST); - } - } - - /// Puts the clip's text on the first item that carries a URI, or makes an item of it when - /// there is none. - /// - /// Android has no notion of "an alternative reading of this object": every item is another - /// thing being dragged. A file and its text fallback therefore have to be one item, or a - /// receiver importing the clip gets two objects where the source published one. The same - /// mistake on the iOS side made a receiver import a document and a stray piece of text. - private static void attachCarriedText(List items, String plain, String html) { - for (int iter = 0; iter < items.size(); iter++) { - Uri uri = items.get(iter).getUri(); - if (uri != null) { - items.set(iter, html != null - ? new ClipData.Item(plain, html, null, uri) - : new ClipData.Item(plain, null, uri)); - return; - } - } - // Nothing to ride on, so the text is the object. First, as it was before there was - // anything else in the clip at all. - items.add(0, html != null ? new ClipData.Item(plain, html) : new ClipData.Item(plain)); - } - - /// Adds the representations neither the text nor the binary pass above has taken. - /// - /// Byte-backed types -- a PDF, an archive, an application's own format -- become typed - /// content URIs, which is the only labelled way an Android clip carries bytes. Text types - /// are advertised only when their value *is* the text the clip already carries: a clip has - /// one text payload, so advertising a second, different reading of it would tell a receiver - /// the clip holds something it cannot then produce, and a Codename One target would accept - /// the hover and be refused at the drop. - private void addRemainingRepresentations(Assembly assembly, String carriedText, - List mimeTypes, List items, long clip) throws IOException { - String[] advertised = assembly.content().getMimeTypes(); - for (int iter = 0; iter < advertised.length; iter++) { - String mime = advertised[iter]; - if (mimeTypes.contains(mime) || ClipboardContent.MIME_FILE.equals(mime)) { - continue; - } - // Each representation on its own: a provider that throws is one type absent, not - // every type after it. ClipboardDataProvider permits it to fail. - Object value = assembly.value(mime); - byte[] bytes = null; - if (value instanceof String) { - if (carriedText != null && carriedText.equals(value)) { - // The same text the clip already carries, so naming the type is enough. - mimeTypes.add(mime); - continue; - } - // A *different* reading -- Markdown source beside its plain rendering, say. - // A clip carries one text payload, so this one travels as a typed content URI - // the way binary does. Dropping it instead, which is what this did, lost a - // representation the application deliberately published. - bytes = ((String) value).getBytes("UTF-8"); - } else if (value instanceof byte[]) { - bytes = (byte[]) value; - } - if (bytes != null) { - try { - Uri uri = writeAsProviderUri(bytes, extensionForMime(mime), mime, clip); - if (uri != null) { - mimeTypes.add(mime); - items.add(new ClipData.Item(uri)); - } - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - } - } - } - - /// A content URI another application can read for this file. - /// - /// The file provider is configured with a fixed set of roots -- the application's files - /// directory and cache/intent_files -- and getUriForFile throws for anything outside them. - /// Plenty of perfectly good paths are outside them: FileSystemStorage lists external - /// storage roots, and a file there used to throw, be logged, and be left out of the clip - /// entirely -- taking the whole drag with it when it was the only thing being dragged. - /// - /// So it is copied where the provider can reach, under its own name, which is what a - /// receiver sees. Not through writeAsProviderUri: that names and records what it mints as - /// transport for a representation's bytes, and this is a file the source published. - private static final long MAX_STAGED_SHARE_BYTES = 8L * 1024 * 1024; - private static final String SHARED_COPY_PREFIX = "cn1-shared-"; - - private Uri shareableUriFor(File file, String authority, long clip) throws IOException { - try { - Uri direct = FileProvider.getUriForFile(getContext(), authority, file); - getContext().grantUriPermission("android", direct, - Intent.FLAG_GRANT_READ_URI_PERMISSION); - return direct; - } catch (Throwable outsideTheRoots) { - com.codename1.io.Log.e(outsideTheRoots); - } - // The copy runs on the thread that started the drag, which is the event dispatch - // thread, and a drag has to begin while the finger is still down -- so this cannot be - // moved off it and cannot be allowed to take long. Android stops waiting for input after - // five seconds; a few megabytes is far below that on any storage, and a file bigger than - // this has no business being copied at all. It belongs under a provider root, which is - // where the roots above now put the external storage such files actually live on. - if (file.length() > MAX_STAGED_SHARE_BYTES) { - throw new IOException("refusing to copy " + file.length() + " bytes on the event " - + "dispatch thread to share " + file); - } - File dir = new File(getContext().getCacheDir(), "intent_files"); - dir.mkdirs(); - // Its own directory, so the copy keeps the original name without colliding with - // another file of the same name in the same drag. - File holder = File.createTempFile(SHARED_COPY_PREFIX, "", dir); - if (!holder.delete() || !holder.mkdirs()) { - throw new IOException("could not stage " + file + " for sharing"); - } - File copy = new File(holder, file.getName()); - boolean registered = false; - try { - InputStream in = new FileInputStream(file); - try { - OutputStream os = new FileOutputStream(copy); - try { - byte[] buffer = new byte[8192]; - int read; - while ((read = in.read(buffer)) > 0) { - os.write(buffer, 0, read); - } - } finally { - os.close(); - } - } finally { - in.close(); - } - Uri shared = FileProvider.getUriForFile(getContext(), authority, copy); - getContext().grantUriPermission("android", shared, - Intent.FLAG_GRANT_READ_URI_PERMISSION); - // Remembered so it is cleaned up, but not as transport: this is a file the source - // published, and it has to read back as one. - rememberStagedClipFile(shared, copy, false, clip); - registered = true; - return shared; - } finally { - if (!registered) { - // A source that vanished, a read that failed, a disk that filled: the holder - // and whatever was written into it exist by now, and nothing has registered - // them for reclamation -- so every failed export left its partial copy in the - // cache for good. - // - // Registration, not the copy, is what ends the window. Naming the file to the - // provider can fail on its own -- a path the manifest's roots do not cover is - // refused there and nowhere else -- and with the flag set at the end of the - // copy, that failure leaked exactly what this was written to prevent. - copy.delete(); - holder.delete(); - } - } - } - - /// One clip assembly's reading of a content, kept to itself. - /// - /// A representation registered as a provider is resolved once per transfer, and the memory - /// of that lives on the ClipboardContent -- which is fine for a transfer that owns it and - /// wrong for two that overlap. A copy assembles on Android's UI thread and a drag on the - /// event dispatch thread, so one could reset the shared memo halfway through the other and - /// hand it a value produced for a different transfer: a clip built from two generations of - /// a payload that changes. - /// - /// So an assembly reads through this instead. The provider is asked at most once per type - /// *per assembly*, which is what the promise actually is, and neither assembly can disturb - /// the other because neither touches the content's own memory. - private static final class Assembly { - private final ClipboardContent content; - private final Map produced = new HashMap(); - - Assembly(ClipboardContent content) { - this.content = content; - } - - ClipboardContent content() { - return content; - } - - Object value(String mimeType) { - if (content == null || mimeType == null) { - return null; - } - if (produced.containsKey(mimeType)) { - return produced.get(mimeType); - } - Object value = null; - try { - value = com.codename1.ui.NativeDragAndDrop.produceTransferValue(content, mimeType); - } catch (Throwable err) { - // A provider that fails is one type absent, not a clip abandoned -- and the - // failure is remembered like any other answer, so a second read of the same - // type does not run it again. Same rule as clipboardValue. - com.codename1.io.Log.e(err); - } - produced.put(mimeType, value); - return value; - } - - String text(String mimeType) { - Object value = value(mimeType); - return value instanceof String ? (String) value : null; - } - - byte[] bytes(String mimeType) { - Object value = value(mimeType); - return value instanceof byte[] ? (byte[]) value : null; - } - } - - /// Writes bytes somewhere the application's file provider can serve them from and returns - /// the content URI, which is how an Android clip carries anything that is not text. - /// - /// AndroidGradleBuilder exposes cache/intent_files through the app's FileProvider, so - /// generated payloads stay inside that root and FileProvider can safely name them. - /// - /// The name carries `mime` so the read back is an answer rather than a guess -- see - /// `#decodeMimeFromFileName(java.lang.String)`. - private Uri writeAsProviderUri(byte[] bytes, String extension, String mime, long clip) - throws IOException { - if (bytes == null) { - return null; - } - // A zero length payload is still a payload: refusing it would leave the clip without a - // type it had advertised, and a target filtering on that type would accept the hover - // and be refused the drop. - File dir = new File(getContext().getCacheDir(), "intent_files"); - dir.mkdirs(); - // A name built from the clock and the payload's length collided: two representations of - // one payload that share an extension and a byte length are written within the same - // millisecond, and the second overwrote the first -- leaving both clip items pointing at - // the second one's bytes. createTempFile is the guarantee rather than a longer guess. - String encoded = encodeMimeForFileName(mime); - File file = File.createTempFile( - encoded == null ? CLIP_FILE_PREFIX : CLIP_FILE_PREFIX + encoded + "-", - "." + extension, dir); - boolean registered = false; - try { - OutputStream os = new FileOutputStream(file); - try { - os.write(bytes); - } finally { - os.close(); - } - Uri uri = FileProvider.getUriForFile(getContext(), - getContext().getPackageName() + ".provider", file); - // Grant broadly so any paste or drop target can read the content:// URI - getContext().grantUriPermission("android", uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); - rememberStagedClipFile(uri, file, true, clip); - registered = true; - return uri; - } finally { - if (!registered) { - // The file exists from createTempFile onwards, and reclamation only ever sees - // what was registered -- so a cache that fills mid-write, or a provider that - // refuses to name the file, left a partial cn1-clip- file behind that nothing - // would ever collect. The same window the published-file copy above closes. - file.delete(); - } - } - } - - /// The name every generated clip file starts with, and the alphabet - /// `#encodeMimeForFileName(java.lang.String)` writes the type in. - private static final String CLIP_FILE_PREFIX = "cn1-clip-"; - private static final String CLIP_MIME_HEX = "0123456789abcdef"; - - /// Writes a MIME type into something that is legal in a file name and reads back as itself. - /// - /// The extension cannot do this job. It is derived from the type and the derivation is - /// lossy -- `application/x-foo` and `application/x-foo+json` both reduce to `xfoo` -- so two - /// representations of one payload can produce URIs no reader can tell apart, and both are - /// then dropped rather than mispaired. Hex is unlovely for a file name nobody reads, and it - /// is exact: every byte of the type survives, and no character it produces means anything to - /// a file system, a URI or `#decodeMimeFromFileName(java.lang.String)`. - /// - /// Answers null for a type this cannot carry, and the file is then named without one. - private static String encodeMimeForFileName(String mime) { - if (mime == null || mime.length() == 0 || mime.length() > 60) { - return null; - } - StringBuilder out = new StringBuilder(); - for (int iter = 0; iter < mime.length(); iter++) { - int c = mime.charAt(iter); - if (c > 0xff) { - return null; - } - out.append(CLIP_MIME_HEX.charAt((c >> 4) & 0xf)).append(CLIP_MIME_HEX.charAt(c & 0xf)); - } - return out.toString(); - } - - /// The MIME type `#encodeMimeForFileName(java.lang.String)` wrote into this name, or null - /// when the name did not come from there -- a clip another application published, or one - /// whose type was too long to carry. - private static String decodeMimeFromFileName(String name) { - if (name == null || !name.startsWith(CLIP_FILE_PREFIX)) { - return null; - } - int end = name.indexOf('-', CLIP_FILE_PREFIX.length()); - if (end < 0) { - return null; - } - String hex = name.substring(CLIP_FILE_PREFIX.length(), end); - if (hex.length() == 0 || (hex.length() & 1) != 0) { - return null; - } - StringBuilder out = new StringBuilder(); - for (int iter = 0; iter < hex.length(); iter += 2) { - int hi = Character.digit(hex.charAt(iter), 16); - int lo = Character.digit(hex.charAt(iter + 1), 16); - if (hi < 0 || lo < 0) { - return null; - } - out.append((char) ((hi << 4) | lo)); - } - return asciiLower(out.toString()); - } - - /// A file extension for a MIME type, used to name the temporary file a content URI is - /// served from. - /// - /// Android's own table first, because a FileProvider derives the URI's type from the - /// extension: a synthesized one it does not recognize makes ContentResolver.getType answer - /// application/octet-stream, and the type the clip advertised is then unrecoverable when - /// the clip is read back. - private static String extensionForMime(String mime) { - try { - String known = android.webkit.MimeTypeMap.getSingleton().getExtensionFromMimeType(mime); - if (known != null && known.length() > 0) { - return known; - } - } catch (Throwable t) { - // Fall through to the synthesized extension below. - } - int slash = mime.indexOf('/'); - String sub = slash < 0 ? mime : mime.substring(slash + 1); - int plus = sub.indexOf('+'); - if (plus > 0) { - sub = sub.substring(0, plus); - } - StringBuilder out = new StringBuilder(); - for (int iter = 0; iter < sub.length(); iter++) { - char c = sub.charAt(iter); - if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) { - out.append(c); - } - } - return out.length() == 0 ? "bin" : out.toString(); - } - - /// The MIME type to file an incoming image's bytes under: the framework's constant for the - /// three formats it names, and the type the content resolver reported for anything else. - /// - /// `#mimeForImageType(java.lang.String)` answers PNG for everything it does not recognize, - /// which for a WebP meant filing WebP bytes as a PNG -- undecodable by anything that - /// believed the label, and invisible to a target filtering on the type the drag advertised, - /// so the hover was accepted and the drop refused. - private static String imageMimeFor(String type) { - String lower = asciiLower(type); - if (lower.startsWith(ClipboardContent.MIME_PNG) - || lower.startsWith(ClipboardContent.MIME_JPEG) - || lower.startsWith(ClipboardContent.MIME_GIF)) { - return mimeForImageType(lower); - } - return lower; - } - - /** - * Maps a content resolver image MIME type to the corresponding ClipboardContent MIME constant, - * defaulting to PNG for unrecognized image types. - */ - private static String mimeForImageType(String type) { - if (type == null) { - return ClipboardContent.MIME_PNG; - } - if (type.startsWith(ClipboardContent.MIME_JPEG)) { - return ClipboardContent.MIME_JPEG; - } - if (type.startsWith(ClipboardContent.MIME_GIF)) { - return ClipboardContent.MIME_GIF; - } - return ClipboardContent.MIME_PNG; - } - - /** - * @inheritDoc - */ - public Object getPasteDataFromClipboard() { - if (getContext() == null) { - return null; - } - final Object[] response = new Object[1]; - runOnUiThreadAndBlock(new Runnable() { - @Override - public void run() { - int sdk = android.os.Build.VERSION.SDK_INT; - if (sdk < 11) { - android.text.ClipboardManager clipboard = (android.text.ClipboardManager) getActivity().getSystemService(Context.CLIPBOARD_SERVICE); - response[0] = clipboard.getText().toString(); - } else { - android.content.ClipboardManager clipboard = (android.content.ClipboardManager) getActivity().getSystemService(Context.CLIPBOARD_SERVICE); - ClipData clip = clipboard.getPrimaryClip(); - if (clip == null || clip.getItemCount() == 0) { - return; - } - // With the description, exactly as a drop is read. Without it the only - // types a paste could report were the ones an item produced by itself, - // so another application's text published under a type of its own -- - // text/markdown, an application's own format -- arrived as nothing but - // text/plain and the type it was published under was gone. - ClipboardContent content = contentFromClip(clip, clip.getDescription()); - String plain = content.getText(ClipboardContent.MIME_TEXT); - // What the clip actually holds, not how many types it happens to name. - // Counting worked only because every clip used to acquire a text/plain of - // its own, empty or not: with that padding gone an image-only clip counted - // as one type, fell through to the plain-text answer, and a paste that had - // a perfectly good PNG in it returned null. - String[] types = content.getMimeTypes(); - boolean textOnly = types.length == 0 - || (types.length == 1 && ClipboardContent.MIME_TEXT.equals(types[0])); - if (!textOnly) { - response[0] = content; - } else { - response[0] = plain != null && plain.length() > 0 ? plain : null; - } - } - } - }); - return response[0]; - } - - /// Reads an Android `android.content.ClipData` into the framework's `ClipboardContent`. - /// - /// Shared by paste and by a native drop, because Android describes both the same way: a - /// list of items that are each text, HTML or a URI, and a URI is either an image to be read - /// or a file reference to be passed along. The plain text representation is always present, - /// even when empty, so a caller can tell "nothing but text" from "something richer" by the - /// number of MIME types. - /// - /// #### Parameters - /// - /// - `clip`: the clip data, which may be null - /// - /// #### Returns - /// - /// the content, never null - ClipboardContent contentFromClip(ClipData clip) { - return contentFromClip(clip, clip == null ? null : clip.getDescription()); - } - - /// Reads a clip, and where a description is given also honours the MIME types it - /// advertises. - /// - /// A drag is filtered twice: once against the description while it hovers, and again - /// against the materialized content when it is dropped. If the second view is narrower than - /// the first, a target accepts the hover and is then refused the drop -- which is what - /// happened to a component filtering on `ClipboardContent#MIME_URI_LIST`, because a URI - /// item materializes as `MIME_FILE` alone. Nothing is invented here: an advertised type is - /// only filled from a value the clip actually produced. - /// - /// A paste is read the same way, from the primary clip's own description. It used to pass - /// none, on the reasoning that a paste should report only what the clip produced -- but - /// the description *is* what the clip says it holds, and without it a type another - /// application published its text under was simply lost. What is filled from it is still - /// only ever a value the clip produced. - /// - /// #### Parameters - /// - /// - `clip`: the clip data, which may be null - /// - /// - `description`: what the source advertised, or null to report only what was read -- - /// which no caller does any more, though a port that has no description to offer - /// still may - /// - /// #### Returns - /// - /// the content, never null - ClipboardContent contentFromClip(ClipData clip, ClipDescription description) { - ClipboardContent content = new ClipboardContent(); - if (clip == null) { - content.setData(ClipboardContent.MIME_TEXT, ""); - return content; - } - int sdk = android.os.Build.VERSION.SDK_INT; - String plain = null; - String html = null; - List fileUris = new ArrayList(); - // Every URI the clip carried that the source published, files or not. A link dragged out - // of a browser belongs here and not in fileUris: it is a URI, and it is not a document on - // disk. The two lists differ only by that, and by the transport URIs this exporter mints, - // which are in neither because the source never published them as URIs at all. - List publishedUris = new ArrayList(); - // URIs the content resolver could not name. An application defined type has no entry in - // Android's table, so a FileProvider serving it reports octet-stream or nothing at all. - List unnamedUris = new ArrayList(); - for (int i = 0; i < clip.getItemCount(); i++) { - ClipData.Item item = clip.getItemAt(i); - try { - Uri uri = item.getUri(); - if (uri != null) { - // Without the parameters, because a bare MIME type is what everything here - // compares against: a provider answering "text/plain; charset=utf-8" would - // file the document under a type no target asks for, and would slip past - // the MIME_TEXT check below that stops the synthesized empty text from - // overwriting it. - String type = bareMimeType(getContext().getContentResolver().getType(uri)); - if (type != null && type.startsWith("image/")) { - // Promised, not read. Reading it here opened the URI and pulled the - // whole image across on Android's own UI thread, before the drop was - // even queued -- so a photo dropped on a target that wanted nothing - // but getFiles() stalled the application, or ran it out of memory, - // for bytes nobody asked for. The same promise the typed branch below - // makes, and safe for the same reason: the grant this drop was given - // lasts as long as the activity, so a read a moment later on the - // event dispatch thread still succeeds. See uriBytesProvider. - String imageMime = imageMimeFor(type); - if (!content.hasMimeType(imageMime)) { - content.setDataProvider(imageMime, uriBytesProvider(uri)); - } - } else if (type != null && type.length() > 0 - && !"application/octet-stream".equals(type)) { - // A typed URI is a file reference *and* that type. Reducing it to a file - // alone let a target filtering on, say, application/pdf accept the hover - // -- the description advertised the type -- and then be refused the - // drop, because the content it is filtered against a second time no - // longer had it. The bytes are promised rather than read: a target that - // only wants the path should not pay for a document it never opens. - if (!content.hasMimeType(type)) { - content.setDataProvider(type, uriBytesProvider(uri)); - } - } else { - unnamedUris.add(uri); - } - // A URI item is a file reference as well as whatever its type made of it -- - // unless it is one this exporter minted to carry bytes. The image branch - // used to return before reaching this at all, so dragging a PNG *file* - // produced image bytes and no file, and a target filtering on MIME_FILE - // accepted the hover -- the description still advertised text/uri-list -- - // and was refused the drop. Adding every URI unconditionally is the other - // error: a payload of nothing but application/pdf bytes travels as a - // content URI without text/uri-list ever being advertised, and calling that - // a file both invents a representation the source never published and lets - // a nested file-only target take a drop the PDF-capable one was chosen for - // while it hovered. - // - // The two are told apart by the exporter's own record of what it minted, - // not by anything about the URI or its name -- an application may publish a - // file called anything at all. - if (!isGeneratedClipFile(uri) && mayCarryAcrossApplications(uri)) { - publishedUris.add(uri.toString()); - if (namesALocalFile(uri)) { - fileUris.add(uri.toString()); - } - } - // No continue: an item carrying a URI carries the clip's text too, because - // that is where this exporter puts it -- a text item of its own would be a - // second object being dragged. Returning here dropped the fallback the - // source published on its own round trip. - } - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - if (html == null && sdk >= 16) { - // Empty markup is a value, not an absence: getHtmlText answers null when the - // item carries no HTML at all, so anything else is what the source published. - // Discarding it left fillAdvertisedTypes to rebuild the advertised text/html - // from the plain text, handing the target something the source never wrote -- - // and this exporter publishes exactly that item for content whose HTML is empty. - html = item.getHtmlText(); - } - if (plain == null) { - // What the item literally carries first, and empty counts: getText answers - // null when the item holds no text at all, so anything else is what the - // source published -- the same reading getHtmlText gets above. Discarding an - // empty one left an advertised text/markdown with nothing to restore it - // from, and a target that took the hover on that type was refused the drop. - CharSequence literal = item.getText(); - if (literal != null) { - plain = literal.toString(); - } else if (item.getUri() == null) { - // Nothing literal, so it is derived -- and only for an item with no URI. - // coerceToText on one of those goes and reads the document behind it, - // which is a different value altogether and none of this branch's - // business. An empty derivation means the item had nothing to give - // rather than that the source published nothing, so it does not stop - // the search. - CharSequence derived = item.coerceToText(getContext()); - if (derived != null && derived.length() > 0) { - plain = derived.toString(); - } - } - } - } - if (html != null) { - // A value the clip's own item published, so it wins over a URI the resolver happened - // to type text/html -- an .html file being dragged. Same rule as the text below, - // and the reason that one needs a guard and this one does not: there is no - // synthesized empty HTML to write over a representation that already answered. - content.setData(ClipboardContent.MIME_HTML, html); - } - if (!fileUris.isEmpty()) { - content.setFiles(fileUris.toArray(new String[fileUris.size()])); - } - // Not when the clip named exactly one type and it is not text/plain. That type is what - // the text *is*: another application publishing a direct item of its own format -- - // application/json, say -- carries the value as the item's text, because an Android - // item has nowhere else to put a string. Calling it text/plain lost the name the clip - // gave it, and a target filtered to that name accepted the hover and was refused the - // drop; fillAdvertisedTypes below hands the value to the type instead. - if (plain != null && soleAdvertisedType(description) == null) { - content.setData(ClipboardContent.MIME_TEXT, plain); - } else if (plain == null && !content.hasMimeType(ClipboardContent.MIME_TEXT) - && description != null && description.hasMimeType(ClipboardContent.MIME_TEXT)) { - // The clip promised text and no item produced it, so the empty string keeps that - // promise: a target that accepted the hover on text/plain would otherwise be - // refused the drop it was told it could have. Only then, though -- a clip that - // never mentioned text does not acquire it here. findTarget runs again against the - // materialized content, so inventing text/plain let a nested text-only component - // take a drop the type-capable ancestor had been chosen for while it hovered, and - // that component never saw an enter event at all. - // - // Nor over a representation that answered: a URI the resolver typed text/plain, - // which is what a dragged .txt is, has already registered the document's own - // contents, and writing over that handed the target an empty document. - content.setData(ClipboardContent.MIME_TEXT, ""); - } - if (description != null) { - fillAdvertisedTypes(content, description, plain, publishedUris, unnamedUris); - } else if (!publishedUris.isEmpty() && !content.hasMimeType(ClipboardContent.MIME_URI_LIST)) { - // A paste is told nothing about what the clip advertises, so what it reports can - // only come from what the clip carried -- and what this one carried is URIs. - // Another application copying a link publishes exactly that, one item with a URI - // and no text at all: nothing above it produces a representation, so without this - // the read answered with an empty content and the paste with null. - // - // Nothing is invented by it either. These are the URIs the clip itself carried, - // minus the ones this exporter minted as transport, which is what a URI list is. - content.setData(ClipboardContent.MIME_URI_LIST, uriListOf(publishedUris)); - } - return content; - } - - /// The content URIs this exporter minted to carry bytes, oldest first. - /// - /// Remembered, not recognized. The file name cannot answer the question: an application may - /// publish a file of its own by any name it likes, and one called cn1-clip-roundtrip.txt is - /// exactly what the clipboard round trip publishes -- which a prefix test then threw away - /// as one of ours, losing the file reference it had just copied. The type cannot answer it - /// either, since a PDF published as bytes and a PDF published as a file both arrive as - /// application/pdf. Only the exporter knows, so the exporter records it. - /// - /// Bounded: a clip that has been replaced on the clipboard can no longer be pasted, so the - /// oldest entries are of no further use. A clip that outlives the process falls back to - /// being read as a file, which is what it was read as before any of this existed. - /// It also names the file, because every one of these is a file this application wrote - /// into its own cache and nothing else will ever come back for it. A clip that has been - /// replaced cannot be pasted, so when one falls off the end its file goes with it -- - /// otherwise copying documents or images repeatedly leaves every one of them on disk for - /// the life of the installation. - /// - /// Kept by the clip rather than one file at a time. A single payload can stage more files - /// than any per-file bound, and counting them individually deleted the earliest ones while - /// clipDataFor was still building the very clip that referenced them -- so the clip went - /// out pointing at files that were already gone. Whole clips are what is forgotten, never - /// the one being assembled. - /// - /// Bounded by bytes rather than by a count of clips. A receiver may hold a content URI - /// this application handed it and read it much later -- a queued upload does exactly that, - /// and the grant stays valid -- so counting clips deleted a file somebody was still - /// entitled to as soon as eight more copies had been made, however small. What can - /// actually fill a device is bytes: a hundred staged text fragments cost nothing and all - /// survive, while a few videos are reclaimed as soon as they add up. - /// - /// There is no signal that says a receiver is finished with one, and inventing one would - /// be a new public API every application had to adopt to keep behaving as it does today. - /// The same reasoning, and the same budget, as the dropped copies on iOS. - private static final long GENERATED_CLIP_BUDGET = 64L * 1024 * 1024; - private static final java.util.LinkedHashMap STAGED_CLIP_FILES = - new java.util.LinkedHashMap(); - - /// One file staged for a clip: where it is, and whether it carries a representation's - /// bytes rather than being a file the source published. - private static final class StagedClipFile { - private final String path; - private final boolean transport; - private final long clip; - /// What it occupies, for the budget above. Taken when it is staged, because by the - /// time it is reclaimed the file may be gone and a size of zero would make a large - /// clip look free. - private final long bytes; - - StagedClipFile(String path, boolean transport, long clip, long bytes) { - this.path = path; - this.transport = transport; - this.clip = clip; - this.bytes = bytes; - } - } - - /// The clip being assembled. Incremented as each one starts, so everything staged for it - /// is recognisable as belonging together. - private static long stagingClip; - - /// The clip the system clipboard is holding, and the clip a running drag is carrying. - /// - /// Neither is superseded by anything newer, which is what a window of recent clips would - /// otherwise assume. A clipboard holds its clip until something replaces it, and every - /// drag in between advances the count -- so nine drags after a copy deleted the files the - /// clipboard was still pointing at, and the paste the user eventually made produced a - /// content URI nothing could read. - private static long clipboardClip; - private static long draggingClip; - - /// The assembly a publication in progress is about to put on the clipboard, exempt from - /// reclamation until the attempt is over. Nothing holds it yet -- the clipboard has not - /// taken it -- and without this the window between assembling a clip and the system - /// accepting it was one in which its own files could be deleted. - private static long publishingClip; - - /// Changes to the primary clip this application is about to make itself, which the watcher - /// below hears about like any other and must not read as somebody else's copy. - /// - /// A count rather than a flag: a copy can be made while an earlier one's callback is still - /// queued, and a flag cleared by the first would have made the second look foreign. - private static int expectedClipChanges; - - /// True once the primary clip watcher is installed, which happens the first time this - /// application puts anything on the clipboard. - private static boolean clipboardWatched; - - /// The assemblies that have begun and whose caller has not yet taken them over. - /// - /// An assembly is exempt from reclamation while it is being built -- its files are being - /// referenced by a clip that does not exist yet -- and stays exempt until whoever asked for - /// it has put it on the clipboard or handed it to a drag. Exempting only the clip currently - /// growing was not enough: a copy assembles on Android's UI thread while a drag assembles - /// on the event dispatch thread, so one could finish and be waiting for its caller to claim - /// it while the other's staging triggered a reclamation that deleted its files. The caller - /// then published, or dragged, a clip of dead URIs. - private static final java.util.Set ASSEMBLING_CLIPS = new java.util.HashSet(); - - private static long beginStagingClip() { - synchronized (STAGED_CLIP_FILES) { - long clip = ++stagingClip; - ASSEMBLING_CLIPS.add(Long.valueOf(clip)); - return clip; - } - } - - /// Ends an assembly's exemption, because its caller has taken it over -- or has given up on - /// it, which is the same thing as far as its files are concerned. - /// - /// #### Parameters - /// - /// - `clip`: the assembly, or zero when there was none - static void endStagingClip(long clip) { - if (clip == 0) { - return; - } - synchronized (STAGED_CLIP_FILES) { - ASSEMBLING_CLIPS.remove(Long.valueOf(clip)); - reclaimStagedClipFiles(); - } - } - - /// Starts listening for the primary clip being replaced, once. - /// - /// A clip this application published is exempt from reclamation for as long as the - /// clipboard holds it, and nothing but another copy of our own used to end that -- so a - /// copy made in *another* application left ours pinned for good, and an oversized one then - /// sat in the cache above the budget with nothing able to reclaim it. - /// - /// Called on the Android UI thread, from the copy that is about to pin something. - /// - /// Android only delivers these callbacks to an application that has focus, so a copy made - /// elsewhere while this one is in the background is still missed. That leaves the hold in - /// place until the next copy either application makes, which is the behaviour this - /// replaces rather than a new failure -- and the files are in the cache directory, which - /// the system reclaims under pressure whatever this bookkeeping believes. - private static void watchPrimaryClip(android.content.ClipboardManager clipboard) { - synchronized (STAGED_CLIP_FILES) { - if (clipboardWatched) { - return; - } - clipboardWatched = true; - } - try { - clipboard.addPrimaryClipChangedListener( - new android.content.ClipboardManager.OnPrimaryClipChangedListener() { - @Override - public void onPrimaryClipChanged() { - synchronized (STAGED_CLIP_FILES) { - if (expectedClipChanges > 0) { - // Our own copy, which has already said what it holds. - expectedClipChanges--; - return; - } - } - // A clip somebody else published replaced ours, so what ours was carrying - // is nobody's to paste any more. - clipboardHolds(0); - } - }); - } catch (Throwable t) { - // A device that will not register the listener keeps the old behaviour, which is - // a hold that outlives the clip rather than a crash on copy. - com.codename1.io.Log.e(t); - synchronized (STAGED_CLIP_FILES) { - clipboardWatched = false; - // Nothing will consume what was counted for the copy this call belongs to. - expectedClipChanges = 0; - } - } - } - - /// Records that this application is about to replace the primary clip, so the watcher does - /// not mistake its own callback for another application's copy, and pins what the clip is - /// about to carry for the length of the attempt. - /// - /// #### Parameters - /// - /// - `clip`: the assembly being published, or zero for a clip with nothing staged - private static void clipboardPublishing(long clip) { - synchronized (STAGED_CLIP_FILES) { - if (clipboardWatched) { - expectedClipChanges++; - } - // Only while something is listening. Counting a copy no callback will ever arrive - // for -- a device that refused the listener -- left the count standing, and if a - // later copy did install the watcher, that phantom swallowed the first genuinely - // foreign clipboard change: the clip stayed pinned and its files stayed out of - // reach of the budget. - publishingClip = clip; - } - } - - /// Ends a publication, either committing it or putting back what it had provisionally - /// taken. - /// - /// #### Parameters - /// - /// - `clip`: the assembly that was being published - /// - /// - `published`: true when setPrimaryClip returned - private static void clipboardPublished(long clip, boolean published) { - synchronized (STAGED_CLIP_FILES) { - publishingClip = 0; - if (!published && expectedClipChanges > 0) { - // No callback is coming for a clip that never reached the clipboard. - expectedClipChanges--; - } - } - if (published) { - // Now, and only now, is the clip the clipboard's -- which is also what stops the - // one it replaced from being pinned. - clipboardHolds(clip); - } - } - - /// Records which clip the system clipboard now holds, or zero for a clip with nothing - /// staged for it. - /// - /// Called for every clip put on the clipboard, plain text included: what matters as much - /// is that the clip it held *before* is not the clipboard's any more, so its files may go - /// when they age out. - static void clipboardHolds(long clip) { - synchronized (STAGED_CLIP_FILES) { - clipboardClip = clip; - // Letting go is as good a moment to reconsider as staging is: a clip that was - // over the budget on its own could not be reclaimed while it was held, and - // nothing else would have looked at it again until some later transfer staged - // a file -- which for an application that drags one large payload and then - // stops is never. - reclaimStagedClipFiles(); - } - } - - /// The clip a drag is carrying right now, so a release queued for one drag can tell - /// whether it is still the drag whose hold it is about to end. - static long draggingClip() { - synchronized (STAGED_CLIP_FILES) { - return draggingClip; - } - } - - /// Ends the hold on one drag's clip, and only that one. - /// - /// A drop's release is queued onto the event dispatch thread, and a callback that enters a - /// nested event loop can let another drag start before it runs. Clearing the shared slot - /// unconditionally then let go of the *new* drag's clip, whose files a cache over budget - /// could delete while the receiving application was still to read them. - /// - /// #### Parameters - /// - /// - `clip`: the clip whose drag has finished, or zero to release whatever is held - static void releaseDragHold(long clip) { - synchronized (STAGED_CLIP_FILES) { - if (clip != 0 && draggingClip != clip) { - return; - } - // Compared and cleared without letting go of the lock in between. A completion - // listener on the event dispatch thread can start the next drag at any moment, and - // it claims this slot: reading it, releasing the lock and then clearing it let go - // of a drag that had begun after the comparison said it was safe. The body is - // dragHolds(0) written out for that reason and nothing else. - draggingClip = 0; - reclaimStagedClipFiles(); - } - } - - /// Records the clip a drag is carrying, or zero once it has ended. - static void dragHolds(long clip) { - synchronized (STAGED_CLIP_FILES) { - draggingClip = clip; - reclaimStagedClipFiles(); - } - } - - private static void rememberStagedClipFile(Uri uri, File file, boolean transport, - long clip) { - synchronized (STAGED_CLIP_FILES) { - STAGED_CLIP_FILES.remove(uri.toString()); - STAGED_CLIP_FILES.put(uri.toString(), - new StagedClipFile(file.getAbsolutePath(), transport, clip, file.length())); - reclaimStagedClipFiles(); - } - } - - /// Reclaims staged files, oldest first, until what is left fits the budget. - /// - /// Never an assembly whose caller has yet to take it over -- it is still growing, or - /// waiting to be handed to a clipboard or a drag -- and never the one the clipboard, a - /// running drag or a publication in progress is carrying, none of which are superseded by - /// anything however old they are. Called when a file is staged and again when any of those - /// is released, because a clip too large for the budget on its own can only be reclaimed - /// once nothing holds it any more. - private static void reclaimStagedClipFiles() { - synchronized (STAGED_CLIP_FILES) { - long held = 0; - for (StagedClipFile staged : STAGED_CLIP_FILES.values()) { - held += staged.bytes; - } - java.util.Iterator> entries = - STAGED_CLIP_FILES.entrySet().iterator(); - while (held > GENERATED_CLIP_BUDGET && entries.hasNext()) { - StagedClipFile staged = entries.next().getValue(); - if (ASSEMBLING_CLIPS.contains(Long.valueOf(staged.clip)) - || staged.clip == clipboardClip || staged.clip == draggingClip - || staged.clip == publishingClip) { - continue; - } - held -= staged.bytes; - entries.remove(); - deleteStagedClipFile(staged); - } - } - } - - /// Removes a staged file, and the directory it was given to itself when it had one. - /// - /// Best effort by design: a file that will not delete is one the cache directory will - /// eventually reclaim, which is what a cache directory is for -- and is also what bounds - /// the files left behind by a process that ended before it could let go of them. - private static void deleteStagedClipFile(StagedClipFile staged) { - try { - File file = new File(staged.path); - File holder = file.getParentFile(); - if (file.delete() && holder != null - && holder.getName().startsWith(SHARED_COPY_PREFIX)) { - holder.delete(); - } - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - } - - /// True when this content URI is one `#writeAsProviderUri(byte[], java.lang.String, - /// java.lang.String)` minted to carry a representation's bytes, rather than a file the - /// source published. - private static boolean isGeneratedClipFile(Uri uri) { - synchronized (STAGED_CLIP_FILES) { - StagedClipFile staged = STAGED_CLIP_FILES.get(uri.toString()); - return staged != null && staged.transport; - } - } - - /// True when a URI another application put on a clip is one this application may carry. - /// - /// A file: URI, or a bare path, is not. Android has refused to let a clip carrying one - /// cross an application boundary since API 24 -- prepareToLeaveProcess throws for exactly - /// that -- so one arriving here was never published by a well behaved application, and it - /// comes with no grant that would make it readable in the first place. Taking it at its - /// word is worse than useless: the path is read with *this* application's permissions, and - /// republishing it -- a copy, a drag onward -- would hand somebody else a file the sender - /// could not open, named by the sender. A content: URI carries a grant and is the only - /// spelling a clip is entitled to use for a document; everything remote is carried as a - /// URI and never opened as a path. - /// - /// This is about what *arrives*. What the application itself publishes through - /// `ClipboardContent#setFiles(java.lang.String...)` is its own file and is unaffected. - private static boolean mayCarryAcrossApplications(Uri uri) { - String scheme = uri.getScheme(); - if (scheme == null) { - return false; - } - return !"file".equalsIgnoreCase(scheme); - } - - /// True when this URI names something on this device rather than somewhere on the web. - /// - /// A link dragged out of a browser arrives as a text/uri-list item whose URI is https, - /// and calling that a file handed a file-only target a URL through getFiles() as though - /// it were a document on disk. It is still carried, under MIME_URI_LIST, which is what - /// it actually is. - private static boolean namesALocalFile(Uri uri) { - String scheme = uri.getScheme(); - if (scheme == null) { - // A bare path, which is a local file by construction. - return true; - } - // equalsIgnoreCase rather than a fold: it compares character by character and is - // locale independent, which String.toLowerCase() is not. - return "content".equalsIgnoreCase(scheme) || "file".equalsIgnoreCase(scheme); - } - - /// Lowercases ASCII letters only, so the result never depends on the device locale. - /// - /// String.toLowerCase() is locale sensitive, and a Turkish or Azerbaijani default turns - /// I into a dotless i: IMAGE/PNG normalized under one of those locales stopped being - /// equal to image/png, so every check against the framework's own constants failed and - /// a port no longer recognized the representation at all. MIME types, schemes and file - /// extensions are ASCII by definition, which is what makes folding only ASCII correct - /// rather than merely safe. Codename One has no java.util.Locale to ask for the root - /// locale instead. - /// True when this value opens with that scheme, whatever case it was written in. - /// - /// A URI scheme is case insensitive by specification, and a case-sensitive prefix test - /// read FILE:///sdcard/report.pdf as a literal path -- a file that does not exist, so - /// the only representation a file-only clip had was quietly dropped. - /// - /// #### Parameters - /// - /// - `value`: the path or URI - /// - /// - `scheme`: the scheme to test for, colon included, in lower case - private static boolean hasScheme(String value, String scheme) { - return value.length() >= scheme.length() - && value.regionMatches(true, 0, scheme, 0, scheme.length()); - } - - static String asciiLower(String s) { - StringBuilder out = new StringBuilder(s.length()); - for (int iter = 0; iter < s.length(); iter++) { - char c = s.charAt(iter); - out.append(c >= 'A' && c <= 'Z' ? (char) (c + 32) : c); - } - return out.toString(); - } - - /// A MIME type without its parameters, lower case, or null when there is none. - private static String bareMimeType(String type) { - if (type == null) { - return null; - } - int semicolon = type.indexOf(';'); - String bare = asciiLower((semicolon < 0 ? type : type.substring(0, semicolon)).trim()); - return bare.length() == 0 ? null : bare; - } - - /// Reads a content URI's bytes when something actually asks for them. - /// - /// The drag-and-drop permission this drop was granted lasts for the life of the activity -- - /// nothing calls release() on it -- so a read that happens a moment later on the event - /// dispatch thread still succeeds. Once read the value is kept, so a target that reads - /// during the drop may hold the result for as long as it likes. - /// - /// What it does not survive is the activity: a representation *first* asked for after the - /// activity that received the drop has been destroyed reads through a grant that no - /// longer exists, and answers null. Copying every representation into this application's - /// own storage at drop time is the only way round that, and it is the wrong trade -- it - /// is the eager read that stalls the platform's thread with a document nobody asked for, - /// which is why this is a promise in the first place. Component.nativeDrop says so where - /// an application will read it. - private ClipboardDataProvider uriBytesProvider(final Uri uri) { - return new ClipboardDataProvider() { - @Override - public Object getClipboardData(String mimeType) { - try { - InputStream in = getContext().getContentResolver().openInputStream(uri); - if (in == null) { - return null; - } - byte[] bytes; - try { - bytes = Util.readInputStream(in); - } finally { - in.close(); - } - // A text type reads back as text: the framework's getText() answers null - // for a byte array, so a Markdown representation that went out as a typed - // URI would come back unreadable to the very API that asked for it. - if (bytes != null && mimeType != null && mimeType.startsWith("text/")) { - return new String(bytes, "UTF-8"); - } - return bytes; - } catch (Throwable t) { - com.codename1.io.Log.e(t); - return null; - } - } - }; - } - - /// The `text/uri-list` spelling of the URIs a clip carried: one per line, CRLF separated - /// as RFC 2483 has it. - private static String uriListOf(List uris) { - StringBuilder out = new StringBuilder(); - for (int iter = 0; iter < uris.size(); iter++) { - if (iter > 0) { - out.append("\r\n"); - } - out.append(uris.get(iter)); - } - return out.toString(); - } - - /// Fills the MIME types the drag advertised but the read did not produce, from what it did. - /// - /// An Android clip carries a single text payload and the description says what that text - /// is, so a type the description names and the clip did not otherwise yield is that text -- - /// `text/uri-list` excepted, which is the list of URIs the clip carried. A type with no - /// value to give it is left absent rather than advertised empty. - private void fillAdvertisedTypes(ClipboardContent content, ClipDescription description, - String plain, List publishedUris, List unnamedUris) { - List unsatisfiedBinary = new ArrayList(); - List unsatisfiedText = new ArrayList(); - for (int iter = 0; iter < description.getMimeTypeCount(); iter++) { - String mime = description.getMimeType(iter); - if (mime == null) { - continue; - } - mime = asciiLower(mime); - if (content.hasMimeType(mime)) { - continue; - } - if ("text/uri-list".equals(mime)) { - // Every URI, not only the ones that name files: a URI list is a URI list, and a - // link the source published belongs in it even though it is not a document. - if (!publishedUris.isEmpty()) { - content.setData(ClipboardContent.MIME_URI_LIST, uriListOf(publishedUris)); - } - continue; - } - // A text type is *not* assumed to be the carried text here. The exporter writes a - // text representation whose value differs from that text into a content URI exactly - // as it writes binary, so assuming made a target asking for an application's own - // text format receive the plain fallback instead of the value it published. - if (mime.startsWith("text/")) { - unsatisfiedText.add(mime); - } else { - unsatisfiedBinary.add(mime); - } - } - List unclaimed = new ArrayList(unnamedUris); - for (int iter = unclaimed.size() - 1; iter >= 0; iter--) { - Uri uri = unclaimed.get(iter); - String named = mimeForUnnamedUri(uri, unsatisfiedBinary, unsatisfiedText); - if (named != null) { - content.setDataProvider(named, uriBytesProvider(uri)); - unsatisfiedBinary.remove(named); - unsatisfiedText.remove(named); - unclaimed.remove(iter); - } - } - if (unclaimed.size() == 1) { - // One representation the clip promised and could not produce, and one URI whose - // type Android could not name: the pairing cannot be anything else. A byte backed - // type is taken first because bytes can only have come from a URI, where a text one - // may also be another reading of the text the clip carries. With more of either it - // could be, and inventing an association would tell a target it has something it - // may not -- which is the failure this whole path exists to avoid -- so those are - // left absent and the target correctly refuses. - String only = null; - if (unsatisfiedBinary.size() == 1) { - only = unsatisfiedBinary.remove(0); - } else if (unsatisfiedBinary.isEmpty() && unsatisfiedText.size() == 1) { - only = unsatisfiedText.remove(0); - } - if (only != null) { - content.setDataProvider(only, uriBytesProvider(unclaimed.get(0))); - } - } - if (plain != null) { - for (int iter = 0; iter < unsatisfiedText.size(); iter++) { - // What is left: an Android clip carries a single text payload, and a text type - // no URI accounted for is another name for that payload -- which is exactly how - // the exporter advertises a reading whose value *is* the carried text. - content.setData(unsatisfiedText.get(iter), plain); - } - if (unsatisfiedText.isEmpty() && unsatisfiedBinary.size() == 1 && unclaimed.isEmpty() - && !content.hasMimeType(ClipboardContent.MIME_TEXT)) { - // And a type that is not text, when it is the only thing left unaccounted for - // and the carried text was not published as text either -- which is the clip - // that named one format of its own and put the value in the item, and only - // that clip. The pairing cannot be anything else, the same reasoning the one - // unclaimed URI above is matched by. - content.setData(unsatisfiedBinary.get(0), plain); - } - } - } - - /// The one type a clip advertises when that is all it advertises and it is not plain - /// text, or null. - /// - /// A clip that names a single format of its own is the case where the item's text is that - /// format rather than a plain reading of it; anything advertising text/plain, or more than - /// one type, is read the way it always was. - private static String soleAdvertisedType(ClipDescription description) { - if (description == null || description.getMimeTypeCount() != 1) { - return null; - } - String mime = description.getMimeType(0); - if (mime == null) { - return null; - } - mime = asciiLower(mime); - return ClipboardContent.MIME_TEXT.equals(mime) ? null : mime; - } - - /// The type an untyped content URI was published as, recovered from the name of the file it - /// serves. - /// - /// ContentResolver could not name it -- MimeTypeMap has no entry for an application defined - /// type, so the FileProvider serving it reports octet-stream. What this application wrote - /// still says so in its own name, exactly, which is the answer; a clip from elsewhere gets - /// the extension read as a type, which is a good guess and is treated as one -- an extension - /// two advertised types share answers nothing. - private String mimeForUnnamedUri(Uri uri, List binary, List text) { - String name = displayNameFor(uri); - if (name == null) { - return null; - } - String declared = decodeMimeFromFileName(name); - if (declared != null) { - // Written by this application, which named the type outright. It answers even when - // it names a type that is not among the candidates -- that means the type is already - // satisfied, or was never advertised, and either way this URI is not the missing - // one. Guessing past an exact answer would be strictly worse. - return binary.contains(declared) || text.contains(declared) ? declared : null; - } - int dot = name.lastIndexOf('.'); - if (dot < 0 || dot == name.length() - 1) { - return null; - } - String extension = asciiLower(name.substring(dot + 1)); - String match = null; - for (int pass = 0; pass < 2; pass++) { - List candidates = pass == 0 ? binary : text; - for (int iter = 0; iter < candidates.size(); iter++) { - String candidate = candidates.get(iter); - if (extension.equals(extensionForMime(candidate))) { - if (match != null) { - return null; - } - match = candidate; - } - } - } - return match; - } - - /// The file name behind a content URI, which is where the extension an exporter chose - /// survives. A provider that will not answer OpenableColumns still has the name in its path. - private String displayNameFor(Uri uri) { - Cursor cursor = null; - try { - cursor = getContext().getContentResolver().query(uri, - new String[]{android.provider.OpenableColumns.DISPLAY_NAME}, - null, null, null); - if (cursor != null && cursor.moveToFirst()) { - int column = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME); - if (column >= 0) { - String name = cursor.getString(column); - if (name != null && name.length() > 0) { - return name; - } - } - } - } catch (Throwable t) { - // Fall through to the path below. - } finally { - if (cursor != null) { - cursor.close(); - } - } - return uri.getLastPathSegment(); - } - - public static MediaException createMediaException(int extra) { - MediaErrorType type; - String message; - switch (extra) { - - case MediaPlayer.MEDIA_ERROR_IO: - type = MediaErrorType.Network; - message = "IO error"; - break; - case MediaPlayer.MEDIA_ERROR_MALFORMED: - type = MediaErrorType.Decode; - message = "Media was malformed"; - break; - case MediaPlayer.MEDIA_ERROR_NOT_VALID_FOR_PROGRESSIVE_PLAYBACK: - type = MediaErrorType.SrcNotSupported; - message = "Not valie for progressive playback"; - break; - case MediaPlayer.MEDIA_ERROR_SERVER_DIED: - type = MediaErrorType.Network; - message = "Server died"; - break; - case MediaPlayer.MEDIA_ERROR_TIMED_OUT: - type = MediaErrorType.Network; - message = "Timed out"; - break; - - case MediaPlayer.MEDIA_ERROR_UNKNOWN: - type = MediaErrorType.Network; - message = "Unknown error"; - break; - case MediaPlayer.MEDIA_ERROR_UNSUPPORTED: - type = MediaErrorType.SrcNotSupported; - message = "Unsupported media"; - break; - default: - type = MediaErrorType.Network; - message = "Unknown error"; - } - return new MediaException(type, message); - } - - - public class Video extends AndroidImplementation.AndroidPeer implements AsyncMedia { - - private VideoView nativeVideo; - private Activity activity; - private boolean fullScreen = false; - private Rectangle bounds; - private boolean nativeController = true; - private boolean nativePlayer; - private Form curentForm; - private List completionHandlers; - private final EventDispatcher errorListeners = new EventDispatcher(); - - private final EventDispatcher stateChangeListeners = new EventDispatcher(); - private PlayRequest pendingPlayRequest; - private PauseRequest pendingPauseRequest; - private boolean androidSeekPreviewWorkaroundEnabled; - - @Override - public State getState() { - if (isPlaying()) { - return State.Playing; - } else { - return State.Paused; - } - } - - protected void fireMediaStateChange(State newState) { - if (stateChangeListeners.hasListeners() && newState != getState()) { - stateChangeListeners.fireActionEvent(new MediaStateChangeEvent(this, getState(), newState)); - } - } - - @Override - public void addMediaStateChangeListener(ActionListener l) { - - stateChangeListeners.addListener(l); - } - - @Override - public void removeMediaStateChangeListener(ActionListener l) { - - stateChangeListeners.removeListener(l); - } - - @Override - public void addMediaErrorListener(ActionListener l) { - errorListeners.addListener(l); - } - - @Override - public void removeMediaErrorListener(ActionListener l) { - errorListeners.removeListener(l); - } - - @Override - public PlayRequest playAsync() { - final PlayRequest out = new PlayRequest(); - out.ready(new SuccessCallback() { - @Override - public void onSucess(AsyncMedia value) { - if (out == pendingPlayRequest) { - pendingPlayRequest = null; - } - } - }).except(new SuccessCallback() { - @Override - public void onSucess(Throwable value) { - if (out == pendingPlayRequest) { - pendingPlayRequest = null; - } - } - }); - ; - if (pendingPlayRequest != null) { - pendingPlayRequest.ready(new SuccessCallback() { - @Override - public void onSucess(AsyncMedia value) { - if (!out.isDone()) { - out.complete(value); - } - } - }).except(new SuccessCallback() { - @Override - public void onSucess(Throwable value) { - if (!out.isDone()) { - out.error(value); - } - } - }); - return out; - } else { - pendingPlayRequest = out; - } - - ActionListener onStateChange = new ActionListener() { - @Override - public void actionPerformed(MediaStateChangeEvent evt) { - stateChangeListeners.removeListener(this); - if (!out.isDone()) { - if (evt.getNewState() == State.Playing) { - out.complete(Video.this); - } - } - - } - - }; - - stateChangeListeners.addListener(onStateChange); - play(); - - return out; - - } - - @Override - public PauseRequest pauseAsync() { - final PauseRequest out = new PauseRequest(); - out.ready(new SuccessCallback() { - @Override - public void onSucess(AsyncMedia value) { - if (out == pendingPauseRequest) { - pendingPauseRequest = null; - } - } - }).except(new SuccessCallback() { - @Override - public void onSucess(Throwable value) { - if (out == pendingPauseRequest) { - pendingPauseRequest = null; - } - } - }); - ; - if (pendingPauseRequest != null) { - pendingPauseRequest.ready(new SuccessCallback() { - @Override - public void onSucess(AsyncMedia value) { - if (!out.isDone()) { - out.complete(value); - } - } - }).except(new SuccessCallback() { - @Override - public void onSucess(Throwable value) { - if (!out.isDone()) { - out.error(value); - } - } - }); - return out; - } else { - pendingPauseRequest = out; - } - - ActionListener onStateChange = new ActionListener() { - @Override - public void actionPerformed(MediaStateChangeEvent evt) { - stateChangeListeners.removeListener(this); - if (!out.isDone()) { - if (evt.getNewState() == State.Paused) { - out.complete(Video.this); - } - } - - } - - }; - - stateChangeListeners.addListener(onStateChange); - play(); - - return out; - } - - - public Video(final VideoView nativeVideo, final Activity activity, final Runnable onCompletion) { - super(new RelativeLayout(activity)); - this.nativeVideo = nativeVideo; - RelativeLayout rl = (RelativeLayout)getNativePeer(); - - rl.addView(nativeVideo); - RelativeLayout.LayoutParams layout = new RelativeLayout.LayoutParams(getWidth(), getHeight()); - layout.addRule(RelativeLayout.CENTER_HORIZONTAL); - layout.addRule(RelativeLayout.CENTER_VERTICAL); - rl.setLayoutParams(layout); - rl.requestLayout(); - - this.activity = activity; - if (nativeController) { - MediaController mc = new AndroidImplementation.CN1MediaController(); - nativeVideo.setMediaController(mc); - } - - nativeVideo.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { - @Override - public void onCompletion(MediaPlayer arg0) { - fireMediaStateChange(State.Paused); - - fireCompletionHandlers(); - } - }); - if (onCompletion != null) { - addCompletionHandler(onCompletion); - } - - nativeVideo.setOnErrorListener(new MediaPlayer.OnErrorListener() { - @Override - public boolean onError(MediaPlayer mp, int what, int extra) { - com.codename1.io.Log.p("Media player error: " + mp + " what: " + what + " extra: " + extra); - errorListeners.fireActionEvent(new MediaErrorEvent(Video.this, createMediaException(extra))); - fireMediaStateChange(State.Paused); - fireCompletionHandlers(); - return true; - } - }); - - } - - - - private void fireCompletionHandlers() { - if (completionHandlers != null && !completionHandlers.isEmpty()) { - Display.getInstance().callSerially(new Runnable() { - public void run() { - if (completionHandlers != null && !completionHandlers.isEmpty()) { - ArrayList toRun; - synchronized(Video.this) { - toRun = new ArrayList(completionHandlers); - } - for (Runnable r : toRun) { - r.run(); - } - } - } - }); - } - } - private void setNativeController(final boolean nativeController) { - if (nativeController != this.nativeController) { - this.nativeController = nativeController; - if (nativeVideo != null) { - Activity activity = getActivity(); - if (activity != null) { - activity.runOnUiThread(new Runnable() { - - @Override - public void run() { - if (nativeVideo != null) { - MediaController mc = new AndroidImplementation.CN1MediaController(); - nativeVideo.setMediaController(mc); - if (!nativeController) mc.setVisibility(View.GONE); - else mc.setVisibility(View.VISIBLE); - - } - } - - }); - } - - } - } - } - - @Override - public void init() { - super.init(); - setVisible(true); - } - - public void prepare() { - } - - @Override - public void play() { - Component cmp = getVideoComponent(); - if (cmp.getParent() == null && nativePlayer && curentForm == null) { - curentForm = Display.getInstance().getCurrent(); - Form f = new Form(); - f.setBackCommand(new Command("") { - @Override - public void actionPerformed(ActionEvent evt) { - Component cmp = getVideoComponent(); - if(cmp != null) { - cmp.remove(); - pause(); - } - curentForm.showBack(); - curentForm = null; - } - }); - f.setLayout(new BorderLayout()); - - if(cmp.getParent() != null) { - cmp.getParent().removeComponent(cmp); - } - f.addComponent(BorderLayout.CENTER, cmp); - f.show(); - } - nativeVideo.start(); - fireMediaStateChange(State.Playing); - } - - @Override - public void pause() { - if(nativeVideo != null && nativeVideo.canPause()){ - nativeVideo.pause(); - fireMediaStateChange(State.Paused); - } - } - - @Override - public void cleanup() { - if(nativeVideo != null) { - nativeVideo.stopPlayback(); - fireMediaStateChange(State.Paused); - } - nativeVideo = null; - if (nativePlayer && curentForm != null) { - curentForm.showBack(); - curentForm = null; - } - } - - @Override - public int getTime() { - if(nativeVideo != null){ - return nativeVideo.getCurrentPosition(); - } - return -1; - } - - @Override - public void setTime(int time) { - if(nativeVideo != null){ - final int seekTime = time; - activity.runOnUiThread(new Runnable() { - @Override - public void run() { - if (nativeVideo == null) { - return; - } - nativeVideo.seekTo(seekTime); - if (androidSeekPreviewWorkaroundEnabled && !nativeVideo.isPlaying()) { - final int refreshSeekTime = Math.max(0, seekTime - 1); - nativeVideo.postDelayed(new Runnable() { - @Override - public void run() { - if (nativeVideo != null && !nativeVideo.isPlaying()) { - nativeVideo.seekTo(refreshSeekTime); - nativeVideo.seekTo(seekTime); - nativeVideo.invalidate(); - } - } - }, 60); - } - } - }); - } - } - - @Override - public int getDuration() { - if(nativeVideo != null){ - return nativeVideo.getDuration(); - } - return -1; - } - - @Override - public void setVolume(int vol) { - // float v = ((float) vol) / 100.0F; - AudioManager am = (AudioManager) activity.getSystemService(Context.AUDIO_SERVICE); - int max = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC); - am.setStreamVolume(AudioManager.STREAM_MUSIC, vol, 0); - } - - @Override - public int getVolume() { - AudioManager am = (AudioManager) activity.getSystemService(Context.AUDIO_SERVICE); - return am.getStreamVolume(AudioManager.STREAM_MUSIC); - } - - @Override - public boolean isVideo() { - return true; - } - - @Override - public boolean isFullScreen() { - return fullScreen || nativePlayer; - } - - @Override - public void setFullScreen(boolean fullScreen) { - this.fullScreen = fullScreen; - if (fullScreen) { - bounds = new Rectangle(getBounds()); - setX(0); - setY(0); - setWidth(Display.getInstance().getDisplayWidth()); - setHeight(Display.getInstance().getDisplayHeight()); - } else { - if (bounds != null) { - setX(bounds.getX()); - setY(bounds.getY()); - setWidth(bounds.getSize().getWidth()); - setHeight(bounds.getSize().getHeight()); - } - } - repaint(); - } - - @Override - public Component getVideoComponent() { - return this; - } - - @Override - protected Dimension calcPreferredSize() { - if(nativeVideo != null){ - return new Dimension(nativeVideo.getWidth(), nativeVideo.getHeight()); - } - return new Dimension(); - } - - @Override - public void setWidth(final int width) { - super.setWidth(width); - final int currH = getHeight(); - if(nativeVideo != null){ - activity.runOnUiThread(new Runnable() { - - public void run() { - float nh = nativeVideo.getHeight(); - float nw = nativeVideo.getWidth(); - float w = width; - float h = currH; - if (nh != 0 && nw != 0) { - h = width * nh / nw; - if (h > getHeight()) { - h = getHeight(); - w = h * nw / nh; - } - if (w > getWidth()) { - w = getWidth(); - h = w * nh / nw; - } - } - RelativeLayout.LayoutParams layout = new RelativeLayout.LayoutParams((int)w, (int)h); - layout.addRule(RelativeLayout.CENTER_HORIZONTAL); - layout.addRule(RelativeLayout.CENTER_VERTICAL); - nativeVideo.setLayoutParams(layout); - nativeVideo.requestLayout(); - nativeVideo.getHolder().setSizeFromLayout(); - } - }); - } - } - - @Override - public void setHeight(final int height) { - super.setHeight(height); - final int currW = getWidth(); - if(nativeVideo != null){ - activity.runOnUiThread(new Runnable() { - - public void run() { - float nh = nativeVideo.getHeight(); - float nw = nativeVideo.getWidth(); - float h = height; - float w = currW; - if (nh != 0 && nw != 0) { - w = h * nw / nh; - if (h > getHeight()) { - h = getHeight(); - w = h * nw / nh; - } - if (w > getWidth()) { - w = getWidth(); - h = w * nh / nw; - } - } - RelativeLayout.LayoutParams layout = new RelativeLayout.LayoutParams((int)w, (int)h); - layout.addRule(RelativeLayout.CENTER_HORIZONTAL); - layout.addRule(RelativeLayout.CENTER_VERTICAL); - nativeVideo.setLayoutParams(layout); - nativeVideo.requestLayout(); - nativeVideo.getHolder().setSizeFromLayout(); - } - }); - } - } - - @Override - public void setNativePlayerMode(boolean nativePlayer) { - this.nativePlayer = nativePlayer; - } - - @Override - public boolean isNativePlayerMode() { - return nativePlayer; - } - - @Override - public boolean isPlaying() { - if(nativeVideo != null){ - return nativeVideo.isPlaying(); - } - return false; - } - - public void setVariable(String key, Object value) { - if (nativeVideo != null && Media.VARIABLE_NATIVE_CONTRLOLS_EMBEDDED.equals(key) && value instanceof Boolean) { - setNativeController((Boolean)value); - return; - } - if (Media.VARIABLE_ANDROID_SEEK_PREVIEW_WORKAROUND.equals(key) && value instanceof Boolean) { - androidSeekPreviewWorkaroundEnabled = ((Boolean)value).booleanValue(); - } - } - - public Object getVariable(String key) { - return null; - } - - @Override - public void addMediaCompletionHandler(Runnable onComplete) { - addCompletionHandler(onComplete); - } - - - - private void addCompletionHandler(Runnable onCompletion) { - synchronized(this) { - if (completionHandlers == null) { - completionHandlers = new ArrayList(); - } - completionHandlers.add(onCompletion); - } - } - - private void removeCompletionHandler(Runnable onCompletion) { - synchronized(this) { - if (completionHandlers != null) { - completionHandlers.remove(onCompletion); - } - } - } - - - } - - - private String getImageFilePath(Uri uri) { - String scheme = uri.getScheme(); - String[] filePathColumn = {MediaStore.Images.Media.DATA}; - Cursor cursor = getContext().getContentResolver().query( - android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, - new String[]{ MediaStore.Images.Media.DATA}, - null, - null, - null - ); - // Some gallery providers may return an empty cursor on modern Android builds. - String filePath = null; - if (cursor != null) { - try { - int columnIndex = cursor.getColumnIndex(filePathColumn[0]); - if (columnIndex >= 0 && cursor.moveToFirst()) { - filePath = cursor.getString(columnIndex); - } - } finally { - cursor.close(); - } - } - - if (filePath == null || "content".equals(scheme)) { - //if the file is not on the filesystem download it and save it - //locally - InputStream inputStream = null; - OutputStream tmp = null; - try { - inputStream = getContext().getContentResolver().openInputStream(uri); - if (inputStream != null) { - String name = new File(uri.toString()).getName();//getContentName(getContext().getContentResolver(), uri); - if (name != null) { - String homePath = getAppHomePath(); - if (homePath.endsWith("/")) { - homePath = homePath.substring(0, homePath.length()-1); - } - filePath = homePath - + getFileSystemSeparator() + name; - File f = new File(removeFilePrefix(filePath)); - tmp = createFileOuputStream(f); - Util.copy(inputStream, tmp); - } - } - } catch (Exception e) { - com.codename1.io.Log.e(e); - } finally { - Util.cleanup(tmp); - Util.cleanup(inputStream); - } - } - return filePath; - } - - @Override - public void onActivityResult(int requestCode, int resultCode, Intent intent) { - - if (requestCode == ZOOZ_PAYMENT) { - ((IntentResultListener) pur).onActivityResult(requestCode, resultCode, intent); - return; - } - - takePersistablePermissionsFromIntent(intent); - - if (requestCode == REQUEST_SELECT_FILE || requestCode == FILECHOOSER_RESULTCODE) { - if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - if (requestCode == REQUEST_SELECT_FILE) { - if (uploadMessage == null) return; - Uri[] results = null; - - // Check that the response is a good one - if (resultCode == Activity.RESULT_OK) { - if (intent != null) { - // If there is not data, then we may have taken a photo - String dataString = intent.getDataString(); - ClipData clipData = intent.getClipData(); - - if (clipData != null) { - results = new Uri[clipData.getItemCount()]; - for (int i = 0; i < clipData.getItemCount(); i++) { - ClipData.Item item = clipData.getItemAt(i); - results[i] = item.getUri(); - } - } else if (dataString != null) { - results = new Uri[]{Uri.parse(dataString)}; - } - } - } - - uploadMessage.onReceiveValue(results); - uploadMessage = null; - } - } - else if (requestCode == FILECHOOSER_RESULTCODE) { - if (null == mUploadMessage) { - return; - } - // Use MainActivity.RESULT_OK if you're implementing WebView inside Fragment - // Use RESULT_OK only if you're implementing WebView inside an Activity - Uri result = intent == null || resultCode != Activity.RESULT_OK ? null : intent.getData(); - mUploadMessage.onReceiveValue(result); - mUploadMessage = null; - } - else { - - Toast.makeText(getActivity().getApplicationContext(), "Failed to Upload File", Toast.LENGTH_LONG).show(); - } - return; - } - - - if (resultCode == Activity.RESULT_OK) { - if (requestCode == CAPTURE_IMAGE) { - try { - String imageUri = (String) Storage.getInstance().readObject("imageUri"); - Vector pathandId = StringUtil.tokenizeString(imageUri, ";"); - String path = (String)pathandId.get(0); - String lastId = (String)pathandId.get(1); - Storage.getInstance().deleteStorageFile("imageUri"); - clearMediaDB(lastId, path); - callback.fireActionEvent(new ActionEvent(addFile(path))); - return; - } catch (Exception e) { - e.printStackTrace(); - } - } else if (requestCode == CAPTURE_VIDEO) { - String path = (String) Storage.getInstance().readObject("videoUri"); - Storage.getInstance().deleteStorageFile("videoUri"); - callback.fireActionEvent(new ActionEvent(addFile(path))); - return; - } else if (requestCode == CAPTURE_AUDIO) { - Uri data = intent.getData(); - String path = convertImageUriToFilePath(data, getContext()); - callback.fireActionEvent(new ActionEvent(addFile(path))); - return; - - } else if (requestCode == OPEN_GALLERY_MULTI) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { - if(intent.getClipData() != null){ - // If it was a multi-request - ArrayList selectedPaths = new ArrayList(); - int count = intent.getClipData().getItemCount(); - for (int i=0; i= 0 && cursor.moveToFirst()) { - filePath = cursor.getString(columnIndex); - } - } finally { - cursor.close(); - } - } - boolean fileExists = false; - if (filePath != null) { - File file = new File(filePath); - fileExists = file.exists() && file.canRead(); - } - - if (!fileExists && "content".equals(scheme)) { - //if the file is not on the filesystem download it and save it - //locally - try { - InputStream inputStream = getContext().getContentResolver().openInputStream(selectedImage); - if (inputStream != null) { - String name = getContentName(getContext().getContentResolver(), selectedImage); - if (name != null) { - filePath = getAppHomePath() - + getFileSystemSeparator() + name; - File f = new File(removeFilePrefix(filePath)); - OutputStream tmp = createFileOuputStream(f); - byte[] buffer = new byte[1024]; - int read = -1; - while ((read = inputStream.read(buffer)) > -1) { - tmp.write(buffer, 0, read); - } - tmp.close(); - inputStream.close(); - } - } - } catch (Exception e) { - e.printStackTrace(); - } - } - - if (filePath == null) { - callback.fireActionEvent(null); - return; - } - - callback.fireActionEvent(new ActionEvent(new String[]{filePath})); - return; - } else if (requestCode == OPEN_GALLERY) { - - Uri selectedImage = intent.getData(); - String scheme = intent.getScheme(); - - String[] filePathColumn = {MediaStore.Images.Media.DATA}; - Cursor cursor = getContext().getContentResolver().query(selectedImage, filePathColumn, null, null, null); - - // Some gallery providers may return an empty cursor on modern Android builds. - String filePath = null; - if (cursor != null) { - try { - int columnIndex = cursor.getColumnIndex(filePathColumn[0]); - if (columnIndex >= 0 && cursor.moveToFirst()) { - filePath = cursor.getString(columnIndex); - } - } finally { - cursor.close(); - } - } - boolean fileExists = false; - if (filePath != null) { - File file = new File(filePath); - fileExists = file.exists() && file.canRead(); - } - - if (!fileExists && "content".equals(scheme)) { - //if the file is not on the filesystem download it and save it - //locally - try { - InputStream inputStream = getContext().getContentResolver().openInputStream(selectedImage); - if (inputStream != null) { - String name = getContentName(getContext().getContentResolver(), selectedImage); - if (name != null) { - filePath = getAppHomePath() - + getFileSystemSeparator() + name; - File f = new File(removeFilePrefix(filePath)); - OutputStream tmp = createFileOuputStream(f); - byte[] buffer = new byte[1024]; - int read = -1; - while ((read = inputStream.read(buffer)) > -1) { - tmp.write(buffer, 0, read); - } - tmp.close(); - inputStream.close(); - } - } - } catch (Exception e) { - e.printStackTrace(); - } - } - - if (filePath == null) { - callback.fireActionEvent(null); - return; - } - - callback.fireActionEvent(new ActionEvent(filePath)); - return; - } else { - if(callback != null) { - callback.fireActionEvent(new ActionEvent("ok")); - } - return; - } - } - //clean imageUri - String imageUri = (String) Storage.getInstance().readObject("imageUri"); - if(imageUri != null){ - Storage.getInstance().deleteStorageFile("imageUri"); - } - - if(callback != null) { - callback.fireActionEvent(null); - } - } - - - - @Override - public void capturePhoto(ActionListener response) { - if (getActivity() == null) { - throw new RuntimeException("Cannot capture photo in background mode"); - } - if (PermissionsHelper.requiresExternalStoragePermissionForMediaAccess()) { - if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to take a picture")){ - return; - } - } - - if (getRequestedPermissions().contains(Manifest.permission.CAMERA)) { - // Normally we don't need to request the CAMERA permission since we use - // the ACTION_IMAGE_CAPTURE intent, which handles permissions itself. - // BUT: If the camera permission is included in the Manifest file, the - // intent will defer to the app's permissions, and on Android 6, - // the permission is denied unless we do the runtime check for permission. - // See https://github.com/codenameone/CodenameOne/issues/2409#issuecomment-391696058 - if(!checkForPermission(Manifest.permission.CAMERA, "This is required to take a picture")){ - return; - } - } - callback = new EventDispatcher(); - callback.addListener(response); - Intent intent = new Intent(android.provider.MediaStore.ACTION_IMAGE_CAPTURE); - - File newFile = getOutputMediaFile(false); - newFile.getParentFile().mkdirs(); - newFile.getParentFile().setWritable(true, false); - //Uri imageUri = Uri.fromFile(newFile); - Uri imageUri = FileProvider.getUriForFile(getContext(), getContext().getPackageName()+".provider", newFile); - intent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, imageUri); - - String lastImageID = getLastImageId(); - Storage.getInstance().writeObject("imageUri", newFile.getAbsolutePath() + ";" + lastImageID); - - intent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, imageUri); - intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); - - if (Build.VERSION.SDK_INT < 21) { - List resInfoList = getContext().getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); - for (ResolveInfo resolveInfo : resInfoList) { - String packageName = resolveInfo.activityInfo.packageName; - getContext().grantUriPermission(packageName, imageUri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); - } - } - - getActivity().startActivityForResult(intent, CAPTURE_IMAGE); - } - - @Override - public void captureVideo(ActionListener response) { - captureVideo(null, response); - } - - @Override - public void captureVideo(VideoCaptureConstraints cnst, ActionListener response) { - if (getActivity() == null) { - throw new RuntimeException("Cannot capture video in background mode"); - } - if (PermissionsHelper.requiresExternalStoragePermissionForMediaAccess()) { - if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to take a video")){ - return; - } - } - - if (getRequestedPermissions().contains(Manifest.permission.CAMERA)) { - // Normally we don't need to request the CAMERA permission since we use - // the ACTION_VIDEO_CAPTURE intent, which handles permissions itself. - // BUT: If the camera permission is included in the Manifest file, the - // intent will defer to the app's permissions, and on Android 6, - // the permission is denied unless we do the runtime check for permission. - // See https://github.com/codenameone/CodenameOne/issues/2409#issuecomment-391696058 - if(!checkForPermission(Manifest.permission.CAMERA, "This is required to take a video")){ - return; - } - } - callback = new EventDispatcher(); - callback.addListener(response); - Intent intent = new Intent(android.provider.MediaStore.ACTION_VIDEO_CAPTURE); - if (cnst != null) { - switch (cnst.getQuality()) { - case VideoCaptureConstraints.QUALITY_LOW: - intent.putExtra(MediaStore.EXTRA_VIDEO_QUALITY, 0); - break; - case VideoCaptureConstraints.QUALITY_HIGH: - intent.putExtra(MediaStore.EXTRA_VIDEO_QUALITY, 1); - break; - } - - if (cnst.getMaxFileSize() > 0) { - intent.putExtra(MediaStore.EXTRA_SIZE_LIMIT, cnst.getMaxFileSize()); - } - if (cnst.getMaxLength() > 0) { - intent.putExtra(MediaStore.EXTRA_DURATION_LIMIT, cnst.getMaxLength()); - } - } - - - File newFile = getOutputMediaFile(true); - newFile.getParentFile().mkdirs(); - newFile.getParentFile().setWritable(true, false); - Uri videoUri = FileProvider.getUriForFile(getContext(), getContext().getPackageName()+".provider", newFile); - - Storage.getInstance().writeObject("videoUri", newFile.getAbsolutePath()); - - intent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, videoUri); - intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); - if (Build.VERSION.SDK_INT < 21) { - List resInfoList = getContext().getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); - for (ResolveInfo resolveInfo : resInfoList) { - String packageName = resolveInfo.activityInfo.packageName; - getContext().grantUriPermission(packageName, videoUri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); - } - } - - this.getActivity().startActivityForResult(intent, CAPTURE_VIDEO); - } - - public void captureAudio(final ActionListener response) { - - if(!checkForPermission(Manifest.permission.RECORD_AUDIO, "This is required to record the audio")){ - return; - } - - try { - final Form current = Display.getInstance().getCurrent(); - - final File temp = File.createTempFile("mtmp", ".3gpp"); - temp.deleteOnExit(); - - if (recorder != null) { - recorder.release(); - } - recorder = new MediaRecorder(); - recorder.setAudioSource(MediaRecorder.AudioSource.MIC); - recorder.setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP); - recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AMR_WB); - recorder.setOutputFile(temp.getAbsolutePath()); - - final Form recording = new Form("Recording"); - recording.setTransitionInAnimator(CommonTransitions.createEmpty()); - recording.setTransitionOutAnimator(CommonTransitions.createEmpty()); - recording.setLayout(new BorderLayout()); - - recorder.prepare(); - recorder.start(); - - final Label time = new Label("00:00"); - time.getAllStyles().setAlignment(Component.CENTER); - Font f = Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_LARGE); - f = f.derive(getDisplayHeight() / 10, Font.STYLE_PLAIN); - time.getAllStyles().setFont(f); - recording.addComponent(BorderLayout.CENTER, time); - - recording.registerAnimated(new Animation() { - - long current = System.currentTimeMillis(); - long zero = current; - int sec = 0; - - public boolean animate() { - long now = System.currentTimeMillis(); - if (now - current > 1000) { - current = now; - sec++; - return true; - } - return false; - } - - public void paint(Graphics g) { - int seconds = sec % 60; - int minutes = sec / 60; - - String secStr = seconds < 10 ? "0" + seconds : "" + seconds; - String minStr = minutes < 10 ? "0" + minutes : "" + minutes; - - String txt = minStr + ":" + secStr; - time.setText(txt); - } - }); - - Container south = new Container(new com.codename1.ui.layouts.GridLayout(1, 2)); - Command cancel = new Command("Cancel") { - - @Override - public void actionPerformed(ActionEvent evt) { - if (recorder != null) { - recorder.stop(); - recorder.release(); - recorder = null; - } - current.showBack(); - response.actionPerformed(null); - } - - }; - recording.setBackCommand(cancel); - south.add(new com.codename1.ui.Button(cancel)); - south.add(new com.codename1.ui.Button(new Command("Save") { - - @Override - public void actionPerformed(ActionEvent evt) { - if (recorder != null) { - recorder.stop(); - recorder.release(); - recorder = null; - } - current.showBack(); - response.actionPerformed(new ActionEvent(temp.getAbsolutePath())); - } - - })); - recording.addComponent(BorderLayout.SOUTH, south); - recording.show(); - - } catch (IOException ex) { - ex.printStackTrace(); - throw new RuntimeException("failed to start audio recording"); - } - - } - - /** - * Opens the device image gallery - * - * @param response callback for the resulting image - * - * - * DISABLING: openGallery() should take care of this - public void openImageGallery(ActionListener response) { - if (getActivity() == null) { - throw new RuntimeException("Cannot open image gallery in background mode"); - } - if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to browse the photos")){ - return; - } - - if(editInProgress()) { - stopEditing(true); - } - - callback = new EventDispatcher(); - callback.addListener(response); - Intent galleryIntent = new Intent(Intent.ACTION_PICK, android.provider.MediaStore.Images.Media.INTERNAL_CONTENT_URI); - this.getActivity().startActivityForResult(galleryIntent, OPEN_GALLERY); - } - * */ - - @Override - public boolean isGalleryTypeSupported(int type) { - if (super.isGalleryTypeSupported(type)) { - return true; - } - if (type == -9999 || type == -9998) { - return true; - } - if (android.os.Build.VERSION.SDK_INT >= 16) { - switch (type) { - - case Display.GALLERY_ALL_MULTI: - case Display.GALLERY_VIDEO_MULTI: - case Display.GALLERY_IMAGE_MULTI: - return true; - } - } - return false; - } - - - - public void openGallery(final ActionListener response, int type){ - if (!isGalleryTypeSupported(type)) { - throw new IllegalArgumentException("Gallery type "+type+" not supported on this platform."); - } - if (getActivity() == null) { - throw new RuntimeException("Cannot open galery in background mode"); - } - if (PermissionsHelper.requiresExternalStoragePermissionForMediaAccess()) { - if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to browse the photos")){ - return; - } - } - if(editInProgress()) { - stopEditing(true); - } - final boolean multi; - switch (type) { - case Display.GALLERY_ALL_MULTI: - multi=true; - type = Display.GALLERY_ALL; - break; - case Display.GALLERY_VIDEO_MULTI: - multi=true; - type = Display.GALLERY_VIDEO; - break; - case Display.GALLERY_IMAGE_MULTI: - multi = true; - type = Display.GALLERY_IMAGE; - break; - case -9998: - multi = true; - type = -9999; - break; - default: - multi = false; - } - - callback = new EventDispatcher(); - callback.addListener(response); - Intent galleryIntent = new Intent(Intent.ACTION_PICK, android.provider.MediaStore.Images.Media.INTERNAL_CONTENT_URI); - galleryIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - if (multi) { - galleryIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); - } - if(type == Display.GALLERY_VIDEO){ - galleryIntent.setType("video/*"); - }else if(type == Display.GALLERY_IMAGE){ - galleryIntent.setType("image/*"); - }else if(type == Display.GALLERY_ALL){ - galleryIntent.setType("image/* video/*"); - }else if (type == -9999) { - galleryIntent = new Intent(); - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { - galleryIntent.setAction(Intent.ACTION_OPEN_DOCUMENT); - } else { - galleryIntent.setAction(Intent.ACTION_GET_CONTENT); - } - galleryIntent.addCategory(Intent.CATEGORY_OPENABLE); - galleryIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { - galleryIntent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); - } - - // set MIME type for image - galleryIntent.setType("*/*"); - galleryIntent.putExtra(Intent.EXTRA_MIME_TYPES, Display.getInstance().getProperty("android.openGallery.accept", "*/*").split(",")); - }else{ - galleryIntent.setType("*/*"); - } - this.getActivity().startActivityForResult(galleryIntent, multi ? OPEN_GALLERY_MULTI: OPEN_GALLERY); - } - - @Override - public void openFileChooser(final ActionListener response, String accept) { - if (getActivity() == null) { - throw new RuntimeException("Cannot open file chooser in background mode"); - } - if(editInProgress()) { - stopEditing(true); - } - callback = new EventDispatcher(); - callback.addListener(response); - Intent pickerIntent = new Intent(); - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { - pickerIntent.setAction(Intent.ACTION_OPEN_DOCUMENT); - } else { - pickerIntent.setAction(Intent.ACTION_GET_CONTENT); - } - pickerIntent.addCategory(Intent.CATEGORY_OPENABLE); - pickerIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { - pickerIntent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); - } - String[] mimeTypes = getFileChooserMimeTypes(accept); - pickerIntent.setType("*/*"); - if (mimeTypes.length > 0) { - pickerIntent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes); - } - this.getActivity().startActivityForResult(pickerIntent, OPEN_GALLERY); - } - - private String[] getFileChooserMimeTypes(String accept) { - if (accept == null || accept.trim().length() == 0 || "*/*".equals(accept.trim())) { - return new String[0]; - } - ArrayList out = new ArrayList(); - String[] tokens = accept.split(","); - for (int iter = 0; iter < tokens.length; iter++) { - String token = tokens[iter].trim(); - if (token.length() == 0 || "*".equals(token)) { - continue; - } - if (token.indexOf('/') > 0) { - out.add(token); - } - } - if (out.isEmpty()) { - out.add("*/*"); - } - return out.toArray(new String[out.size()]); - } - - class NativeImage extends Image { - - public NativeImage(Bitmap nativeImage) { - super(nativeImage); - } - } - - /** - * Persist read permissions that were granted by an activity result so that media playback can - * continue after {@link Activity#onActivityResult(int, int, Intent)} returns. - * - *

Android 13 and newer revoke temporary grants immediately after the callback unless the - * app calls {@link ContentResolver#takePersistableUriPermission(Uri, int)}. Without this call - * {@link #createMedia(String, boolean, Runnable)} loses access to the {@code content://} URI - * provided by the system picker and playback fails on Android 15.

- */ - private void takePersistablePermissionsFromIntent(Intent intent) { - if (intent == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { - return; - } - int takeFlags = intent.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); - if (takeFlags == 0) { - return; - } - ContentResolver resolver = getContext().getContentResolver(); - if (resolver == null) { - return; - } - ClipData clip = intent.getClipData(); - if (clip != null) { - for (int i = 0; i < clip.getItemCount(); i++) { - Uri uri = clip.getItemAt(i).getUri(); - if (uri != null) { - try { - resolver.takePersistableUriPermission(uri, takeFlags); - } catch (SecurityException ignored) { - } - } - } - } - Uri dataUri = intent.getData(); - if (dataUri != null) { - try { - resolver.takePersistableUriPermission(dataUri, takeFlags); - } catch (SecurityException ignored) { - } - } - } - - /** - * Create a File for saving an image or video - */ - private File getOutputMediaFile(boolean isVideo) { - // To be safe, you should check that the SDCard is mounted - // using Environment.getExternalStorageState() before doing this. - if (getActivity() != null) { - return GetOutputMediaFile.getOutputMediaFile(isVideo, getActivity()); - } else { - return GetOutputMediaFile.getOutputMediaFile(isVideo, getContext(), "Video"); - } - } - - private static class GetOutputMediaFile { - - public static File getOutputMediaFile(boolean isVideo,Activity activity) { - activity.getComponentName(); - return getOutputMediaFile(isVideo, activity, activity.getTitle()); - } - - public static File getOutputMediaFile(boolean isVideo, Context activity, CharSequence title) { - - - File mediaStorageDir = new File(new File(getContext().getCacheDir(), "intent_files"), ""+title); - - // Create the storage directory if it does not exist - if (!mediaStorageDir.exists()) { - if (!mediaStorageDir.mkdirs()) { - Log.d(Display.getInstance().getProperty("AppName", "CodenameOne"), "failed to create directory"); - return null; - } - } - - // Create a media file name - String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date()); - File mediaFile = null; - if (!isVideo) { - mediaFile = new File(mediaStorageDir.getPath() + File.separator - + "IMG_" + timeStamp + ".jpg"); - } else { - mediaFile = new File(mediaStorageDir.getPath() + File.separator - + "VID_" + timeStamp + ".mp4"); - } - - return mediaFile; - } - } - - @Override - 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; - } - - @Override - public void registerPush(Hashtable metaData, boolean noFallback) { - if (getActivity() == null) { - return; - } - - if (android.os.Build.VERSION.SDK_INT >= 33) { - if(!checkForPermission("android.permission.POST_NOTIFICATIONS", "This is required to receive push notifications")){ - return; - } - } - - 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!"); - return; - } - String id = ""; - if (!huawei) { - id = (String)metaData.get(com.codename1.push.Push.GOOGLE_PUSH_KEY); - if (id == null) { - id = Display.getInstance().getProperty("gcm.sender_id", null); - } - } - Log.d("Codename One", "Sending async push request for id: " + id); - ((CodenameOneActivity) getActivity()).registerForPush(id); - } - - public static void stopPollingLoop() { - stopPolling(); - } - - public static void registerPolling() { - registerPollingFallback(); - } - - @Override - public void deregisterPush() { - boolean has = hasAndroidMarket() - || "huawei".equals(Display.getInstance().getProperty("cn1.push.transport", "")); - if (has) { - ((CodenameOneActivity) getActivity()).stopReceivingPush(); - deregisterPushFromServer(); - } else { - super.deregisterPush(); - } - } - - private static String convertImageUriToFilePath(Uri imageUri, Context activity) { - Cursor cursor = null; - String[] proj = {MediaStore.Images.Media.DATA}; - cursor = activity.getContentResolver().query(imageUri, proj, null, null, null); - int column_index = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA); - cursor.moveToFirst(); - String path = cursor.getString(column_index); - cursor.close(); - return path; - } - - class CN1MediaController extends MediaController { - - public CN1MediaController() { - super(getActivity()); - } - - @Override - public boolean dispatchKeyEvent(KeyEvent event) { - int keycode = event.getKeyCode(); - keycode = CodenameOneView.internalKeyCodeTranslate(keycode); - if (keycode == AndroidImplementation.DROID_IMPL_KEY_BACK) { - // Claim the gesture so the activity's OnBackInvokedCallback - // stands down; on Android 16 the platform can deliver both for - // one press. See PredictiveBackBridge. The claim brackets the - // DOWN and the UP even though this path answers each of them - // with a whole press/release pair of its own. - switch (event.getAction()) { - case KeyEvent.ACTION_DOWN: - PredictiveBackBridge.keyEventBackStarted(); - break; - case KeyEvent.ACTION_UP: - PredictiveBackBridge.keyEventBackFinished(); - break; - default: - break; - } - Display.getInstance().keyPressed(keycode); - Display.getInstance().keyReleased(keycode); - return true; - } else { - return super.dispatchKeyEvent(event); - } - } - } - private L10NManager l10n; - - /** - * @inheritDoc - */ - public L10NManager getLocalizationManager() { - if (l10n == null) { - final Locale l = Locale.getDefault(); - l10n = new L10NManager(l.getLanguage(), l.getCountry()) { - public double parseDouble(String localeFormattedDecimal) { - try { - return NumberFormat.getNumberInstance().parse(localeFormattedDecimal).doubleValue(); - } catch (ParseException err) { - return Double.parseDouble(localeFormattedDecimal); - } - } - - @Override - public String getLongMonthName(Date date) { - java.text.SimpleDateFormat fmt = new java.text.SimpleDateFormat("MMMM", l); - return fmt.format(date); - } - - @Override - public String getShortMonthName(Date date) { - java.text.SimpleDateFormat fmt = new java.text.SimpleDateFormat("MMM", l); - return fmt.format(date); - } - - - - public String format(int number) { - return NumberFormat.getNumberInstance().format(number); - } - - public String format(double number) { - return NumberFormat.getNumberInstance().format(number); - } - - public String formatCurrency(double currency) { - return NumberFormat.getCurrencyInstance().format(currency); - } - - public String formatDateLongStyle(Date d) { - return DateFormat.getDateInstance(DateFormat.LONG).format(d); - } - - public String formatDateShortStyle(Date d) { - return DateFormat.getDateInstance(DateFormat.SHORT).format(d); - } - - public String formatDateTime(Date d) { - return DateFormat.getDateTimeInstance().format(d); - } - - public String formatDateTimeMedium(Date d) { - DateFormat dd = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM); - return dd.format(d); - } - - public String formatDateTimeShort(Date d) { - DateFormat dd = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT); - return dd.format(d); - } - - public String getCurrencySymbol() { - return NumberFormat.getInstance().getCurrency().getSymbol(); - } - - public void setLocale(String locale, String language) { - super.setLocale(locale, language); - Locale l = new Locale(language, locale); - Locale.setDefault(l); - } - }; - } - return l10n; - } - private com.codename1.ui.util.ImageIO imIO; - - private com.codename1.media.VideoIO videoIO; - private boolean videoIOResolved; - - @Override - public com.codename1.media.VideoIO getVideoIO() { - if (!videoIOResolved) { - videoIOResolved = true; - if (android.os.Build.VERSION.SDK_INT >= 21) { - videoIO = new AndroidVideoIO(); - } - } - return videoIO; - } - - @Override - public com.codename1.ui.util.ImageIO getImageIO() { - if (imIO == null) { - imIO = new com.codename1.ui.util.ImageIO() { - @Override - public Dimension getImageSize(String imageFilePath) throws IOException { - BitmapFactory.Options o = new BitmapFactory.Options(); - o.inJustDecodeBounds = true; - o.inPreferredConfig = Bitmap.Config.ARGB_8888; - - InputStream fis = createFileInputStream(imageFilePath); - BitmapFactory.decodeStream(fis, null, o); - fis.close(); - - ExifInterface exif = new ExifInterface(removeFilePrefix(imageFilePath)); - - // if the image is in portrait mode - int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); - if(orientation == ExifInterface.ORIENTATION_ROTATE_90 || orientation == ExifInterface.ORIENTATION_ROTATE_270) { - return new Dimension(o.outHeight, o.outWidth); - } - return new Dimension(o.outWidth, o.outHeight); - } - - private Dimension getImageSizeNoRotation(String imageFilePath) throws IOException { - BitmapFactory.Options o = new BitmapFactory.Options(); - o.inJustDecodeBounds = true; - o.inPreferredConfig = Bitmap.Config.ARGB_8888; - - InputStream fis = createFileInputStream(imageFilePath); - BitmapFactory.decodeStream(fis, null, o); - fis.close(); - - return new Dimension(o.outWidth, o.outHeight); - } - - @Override - public void save(InputStream image, OutputStream response, String format, int width, int height, float quality) throws IOException { - Bitmap.CompressFormat f = Bitmap.CompressFormat.PNG; - if (FORMAT_JPEG.equals(format)) { - f = Bitmap.CompressFormat.JPEG; - } - Image img = Image.createImage(image).scaled(width, height); - Bitmap b = (Bitmap) img.getImage(); - b.compress(f, (int) (quality * 100), response); - } - - @Override - public String saveAndKeepAspect(String imageFilePath, String preferredOutputPath, String format, int width, int height, float quality, boolean onlyDownscale, boolean scaleToFill) throws IOException{ - ExifInterface exif = new ExifInterface(removeFilePrefix(imageFilePath)); - Dimension d = getImageSizeNoRotation(imageFilePath); - if(onlyDownscale) { - if(scaleToFill) { - if(d.getHeight() <= height || d.getWidth() <= width) { - return imageFilePath; - } - } else { - if(d.getHeight() <= height && d.getWidth() <= width) { - return imageFilePath; - } - } - } - - float ratio = ((float)d.getWidth()) / ((float)d.getHeight()); - int heightBasedOnWidth = (int)(((float)width) / ratio); - int widthBasedOnHeight = (int)(((float)height) * ratio); - if(scaleToFill) { - if(heightBasedOnWidth >= width) { - height = heightBasedOnWidth; - } else { - width = widthBasedOnHeight; - } - } else { - if(heightBasedOnWidth > width) { - width = widthBasedOnHeight; - } else { - height = heightBasedOnWidth; - } - } - sampleSizeOverride = Math.max(d.getWidth()/width, d.getHeight()/height); - OutputStream im = FileSystemStorage.getInstance().openOutputStream(preferredOutputPath); - Image i = Image.createImage(imageFilePath); - Image newImage = i.scaled(width, height); - int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); - - int angle = 0; - switch (orientation) { - case ExifInterface.ORIENTATION_ROTATE_90: - angle = 90; - break; - case ExifInterface.ORIENTATION_ROTATE_180: - angle = 180; - break; - case ExifInterface.ORIENTATION_ROTATE_270: - angle = 270; - break; - } - if (angle != 0) { - Matrix mat = new Matrix(); - mat.postRotate(angle); - Bitmap b = (Bitmap)newImage.getImage(); - Bitmap correctBmp = Bitmap.createBitmap(b, 0, 0, b.getWidth(), b.getHeight(), mat, true); - b.recycle(); - newImage.dispose(); - Image tmp = Image.createImage(correctBmp); - newImage = tmp; - save(tmp, im, format, quality); - } else { - save(imageFilePath, im, format, width, height, quality); - } - sampleSizeOverride = -1; - return preferredOutputPath; - } - - @Override - public void save(String imageFilePath, OutputStream response, String format, int width, int height, float quality) throws IOException { - Image i = Image.createImage(imageFilePath); - Image newImage = i.scaled(width, height); - save(newImage, response, format, quality); - newImage.dispose(); - i.dispose(); - } - - @Override - protected void saveImage(Image img, OutputStream response, String format, float quality) throws IOException { - Bitmap.CompressFormat f = Bitmap.CompressFormat.PNG; - if (FORMAT_JPEG.equals(format)) { - f = Bitmap.CompressFormat.JPEG; - } - Bitmap b = (Bitmap) img.getImage(); - b.compress(f, (int) (quality * 100), response); - } - - @Override - public boolean isFormatSupported(String format) { - return FORMAT_JPEG.equals(format) || FORMAT_PNG.equals(format); - } - }; - } - return imIO; - } - - @Override - public Database openOrCreateDB(String databaseName) throws IOException { - // Reserved first, and recovery run inside the reservation. The slot has to be taken - // before the engine opens anything, or a conversion reading the count during the open - // starts replacing the file this is about to hand back -- and recovery has to be inside - // it too, because a conversion that has just installed its converted file leaves the live - // file and the backup both present, which recovery would otherwise read as a completed - // conversion and act on by deleting the backup. - String nativePath = resolveNativeDatabasePath(databaseName); - reserveDatabaseConnection(nativePath); - SQLiteDatabase db; - try { - // A plaintext open of a database mid-conversion would create an empty one over the - // top of the real data, which nothing afterwards could undo. - // - // One connection is allowed to be open here, and it is the reservation taken above. - // Anything beyond that is somebody else's handle -- including one taken through the - // constructor that wraps an already-open connection -- and recovery moves the file - // out from under it. When that is the case and a conversion is waiting to be - // finished, this open is refused rather than handing back a file recovery is going - // to replace; with nothing waiting there is nothing to recover and the open goes - // ahead as before. - recoverIfSoleConnection(nativePath); - if (databaseName.startsWith("file://")) { - db = SQLiteDatabase.openOrCreateDatabase( - FileSystemStorage.getInstance().toNativePath(databaseName), null, - KEEP_ON_CORRUPTION); - } else { - db = getContext().openOrCreateDatabase(databaseName, getContext().MODE_PRIVATE, - null, KEEP_ON_CORRUPTION); - } - } catch (RuntimeException didNotOpen) { - databaseConnectionClosed(nativePath); - // The engine reports a file it cannot read by throwing an unchecked - // SQLiteDatabaseCorruptException, and an encrypted database opened without its key is - // exactly that to the plain engine. This API promises every failure as an IOException, - // so the caller can catch one thing rather than an unchecked type per platform. - throw new IOException("The database " + databaseName + " could not be opened: " - + didNotOpen.getMessage(), didNotOpen); - } catch (IOException didNotRecover) { - databaseConnectionClosed(nativePath); - throw didNotRecover; - } - return new AndroidDB(db, nativePath); - } - - @Override - public Database openOrCreateDB(String databaseName, com.codename1.db.DatabaseConfig config) throws IOException { - if (config == null || !config.isEncrypted()) { - return openOrCreateDB(databaseName); - } - // The slot is taken before the engine opens anything, for the reason given in - // openOrCreateDB. AndroidCipherFactory hands back a connection that already holds it. - String nativePath = resolveNativeDatabasePath(databaseName); - reserveDatabaseConnection(nativePath); - // The SQLCipher-backed package is deleted at build time for apps that never touch - // DatabaseConfig, so it has to be reached reflectively - the same arrangement the - // ARCore-backed AR implementation uses. - Object opened; - try { - Class c = Class.forName("com.codename1.impl.android.cipher.AndroidCipherFactory"); - java.lang.reflect.Method open = c.getMethod("open", String.class, String.class, - String.class); - // Cast outside the try, below: inside a block that catches Throwable, a wrong type - // from the reflective call would be swallowed and reported as the package being - // absent. The resolved file, not the name it was asked for: a managed key with no explicit - // alias is stored under whatever is passed here, so two accepted spellings of one - // database would derive two different keys and the second open would report a wrong - // key against data that is perfectly intact. - opened = open.invoke(null, - resolveNativeDatabasePath(databaseName), databaseName, - config.resolveKeyMaterial(databaseKey(nativePath))); - } catch (java.lang.reflect.InvocationTargetException err) { - releaseUnusedDatabaseConnection(nativePath); - Throwable cause = err.getCause(); - if (cause instanceof IOException) { - throw (IOException) cause; - } - throw new IOException(cause == null ? err.toString() : cause.getMessage(), cause); - } catch (IOException err) { - releaseUnusedDatabaseConnection(nativePath); - throw err; - } catch (ClassNotFoundException notBundled) { - // The only benign reason to land here: the build pruned the package because the - // application never referenced DatabaseConfig. - releaseUnusedDatabaseConnection(nativePath); - throw new com.codename1.db.DatabaseEncryptionException( - com.codename1.db.DatabaseEncryptionException.NOT_SUPPORTED, - "This build does not include encrypted database support", notBundled); - } catch (NoSuchMethodException broken) { - // The package is present but does not expose the entry point this reaches through. - // That is a broken build, not an unsupported platform, and reporting it as - // NOT_SUPPORTED would hide it: every caller would be told encryption is unavailable - // on a device that ships the engine. This is the failure mode a compiler would have - // caught if the seam were not reflective, so it has to be loud. - releaseUnusedDatabaseConnection(nativePath); - throw new IOException("The encrypted database implementation is present but does not " - + "expose the expected entry point. This build is inconsistent: " - + broken.getMessage(), broken); - } catch (Throwable err) { - releaseUnusedDatabaseConnection(nativePath); - throw new com.codename1.db.DatabaseEncryptionException( - com.codename1.db.DatabaseEncryptionException.NOT_SUPPORTED, - "This build does not include encrypted database support", err); - } - if (!(opened instanceof Database)) { - releaseUnusedDatabaseConnection(nativePath); - throw new IOException("The encrypted database implementation returned " - + (opened == null ? "nothing" : opened.getClass().getName()) - + " rather than a Database. This build is inconsistent."); - } - return (Database) opened; - } - - /// The file an implicit managed key is stored under; see the open path, which resolves the - /// same way so two spellings of one database derive one key. - @Override - public String databaseManagedKeyIdentity(String databaseName) { - // Canonical, like the connection registry: resolveNativeDatabasePath leaves a custom - // spelling as it was given, so "/data/app/./db.sqlite" and "/data/app/db.sqlite" would - // otherwise pick different stored keys for one file and report the second open as wrong. - return databaseKey(resolveNativeDatabasePath(databaseName)); - } - - @Override - public boolean isDatabaseEncryptionSupported() { - Object available; - try { - Class c = Class.forName("com.codename1.impl.android.cipher.AndroidCipherFactory"); - available = c.getMethod("isAvailable").invoke(null); - } catch (Throwable notPresent) { - return false; - } - // Tested rather than cast inside the try: the reflective answer is untyped, and - // anything but a Boolean means the feature is unavailable rather than absent. - return available instanceof Boolean && ((Boolean) available).booleanValue(); - } - - @Override - public boolean isDatabaseManagedKeyHardwareBacked() { - // Ask the key itself. An API level says only that the API exists: emulators, and plenty of - // real devices, back AndroidKeyStore keys in software. Applications are told they may use - // this to refuse to store sensitive data, so it has to describe the actual key. - return AndroidSecureStorage.isPlainKeyInsideSecureHardware(); - } - - /** - * Absolute filesystem path for a database name, converting a custom file:// URL. - * - * getDatabasePath() deliberately echoes a file:// URL back unchanged, which is right for - * callers that hand it to FileSystemStorage but wrong for anything constructing a java.io.File - * from it. - */ - /// Directory holding the encrypted-database migration's working files. - /// - /// A directory beside the database, so the rename that installs the converted file stays - /// within one filesystem and is therefore atomic. - /// - /// The location alone does not make these files ours. Custom paths mean an application can - /// point a database anywhere, including inside here, so ownership is established by the - /// marker's contents rather than by where a file sits or what it is called. Nothing is - /// deleted, renamed over or truncated without that proof. - public static final String DATABASE_MIGRATION_DIR = ".cn1migration"; - - /// Marker name for a database. Deterministic so recovery can find it; its contents, not its - /// name, are what establish that a conversion wrote it. - public static final String MIGRATION_MARKER = ".marker"; - - /// Fourth line of a marker whose installed file was never shown to open. - private static final String MIGRATION_UNVALIDATED = "unvalidated"; - - /// First line of a marker written by this port. - private static final String MIGRATION_MARKER_MAGIC = "codename1-database-migration-1"; - - /// The migration directory for a database, or null if the path has no parent. - public static File databaseMigrationDir(String path) { - File parent = new File(path).getParentFile(); - return parent == null ? null : new File(parent, DATABASE_MIGRATION_DIR); - } - - public static File databaseMigrationMarker(String path) { - File dir = databaseMigrationDir(path); - return dir == null ? null : new File(dir, new File(path).getName() + MIGRATION_MARKER); - } - - /// Reads a marker written by this port, or null when the file is not one of ours. - /// - /// A marker is trusted only if it opens with the magic line. Anything else - including an - /// application database that happens to live at this path - is left alone. - /// - /// The two entries after it are the file holding the original and the export being built, - /// either of which may be absent: the marker is written before the export is filled in and - /// rewritten once the original has been moved aside, so which files exist depends on how far - /// the conversion got. - /// - /// What this does NOT defend against, deliberately: an actor who can write in the migration - /// directory can still write a marker naming files inside it. The magic line is in the - /// source, so it authenticates nothing -- and there is no secret this port could sign a - /// marker with that the same actor could not read out of the application. The damage is - /// bounded to that one directory, which that actor can already write to and delete from - /// directly, so the check earns its keep by keeping the names inside it rather than by - /// pretending the file is trusted. - /// - /// A rejected marker is treated as somebody else's file: recovery leaves it alone and a - /// conversion refuses to start rather than overwriting it, with a message naming the file. A - /// crafted marker therefore stops conversions of that one database until it is removed, which - /// is the outcome to prefer over acting on it. - /// - /// @return the two names, either element null, or null if this is not our marker - private static String[] readDatabaseMigrationMarker(String path) { - File marker = databaseMigrationMarker(path); - if (marker == null || !marker.isFile()) { - return null; - } - BufferedReader reader = null; - try { - reader = new BufferedReader(new InputStreamReader(new FileInputStream(marker), - "UTF-8")); - if (!MIGRATION_MARKER_MAGIC.equals(reader.readLine())) { - return null; - } - String backup = reader.readLine(); - String target = reader.readLine(); - String state = reader.readLine(); - String backupName = backup == null || backup.length() == 0 ? null : backup; - String targetName = target == null || target.length() == 0 ? null : target; - // The names this port writes are basenames createTempFile produced in the migration - // directory, and they are read back as files to truncate, delete and rename over. A - // marker is a plain text file beside the database, so where the database sits - // somewhere another actor can write -- which a custom path can -- an entry like - // "../../../files/secret" would be resolved against that directory and handed to the - // cleanup, which truncates and deletes what it is given. Anything that is not a - // simple name inside this directory means the file is not one of ours, which is the - // answer that stops every caller: recovery leaves it alone and a conversion refuses - // to overwrite it rather than starting. - File dir = databaseMigrationDir(path); - if ((backupName != null && !isMigrationEntryName(backupName, dir)) - || (targetName != null && !isMigrationEntryName(targetName, dir))) { - return null; - } - return new String[] { - backupName, - targetName, - state == null || state.length() == 0 ? null : state, - }; - } catch (IOException unreadable) { - return null; - } finally { - if (reader != null) { - try { - reader.close(); - } catch (IOException ignored) { - // Nothing useful to do. - } - } - } - } - - /// Whether a name a marker carries is one this port could have written there. - /// - /// A generated basename, and a file that really is a direct child of the migration directory: - /// the first rejects a path that climbs out of it, the second rejects a name inside it that - /// is a link to somewhere else. Both are checked because either alone can be walked around -- - /// a name with no separator can still be a symlink, and a canonical check on its own would - /// accept "sub/dir/../file". - /// - /// #### Parameters - /// - /// - `name`: the entry read from the marker - /// - `directory`: the migration directory the marker lives in - /// - /// #### Returns - /// - /// true if the name is safe to resolve against that directory - private static boolean isMigrationEntryName(String name, File directory) { - if (directory == null || name.length() == 0 || ".".equals(name) || "..".equals(name)) { - return false; - } - if (name.indexOf('/') >= 0 || name.indexOf('\\') >= 0 || name.indexOf('\u0000') >= 0) { - return false; - } - try { - File resolved = new File(directory, name).getCanonicalFile(); - File parent = resolved.getParentFile(); - return parent != null && parent.equals(directory.getCanonicalFile()); - } catch (IOException cannotResolve) { - // A name that cannot be resolved is not one that gets acted on. - return false; - } - } - - /// Whether the marker for this database was written by this port. - /// - /// Distinct from having a backup: a marker written before the export was filled in names no - /// backup yet, and is still ours to rewrite. - private static boolean ownsDatabaseMigrationMarker(String path) { - return readDatabaseMigrationMarker(path) != null; - } - - /// Reads the backup a marker claims, or null when there is none. - public static File readDatabaseMigrationBackup(String path) { - String[] entry = readDatabaseMigrationMarker(path); - if (entry == null || entry[0] == null) { - return null; - } - return new File(databaseMigrationMarker(path).getParentFile(), entry[0]); - } - - /// Whether the marker says its installed file was never shown to open. - private static boolean isDatabaseMigrationUnvalidated(String path) { - String[] entry = readDatabaseMigrationMarker(path); - return entry != null && entry.length > 2 && MIGRATION_UNVALIDATED.equals(entry[2]); - } - - /// Reads the export a marker claims, or null when there is none. - /// - /// The export is a second complete copy of the data, and a plaintext one when the conversion - /// was a decryption, so it is recorded before anything is written into it. Otherwise a process - /// death between creating it and finishing the conversion would leave readable data behind - /// under a name nothing knows to look for. - public static File readDatabaseMigrationTarget(String path) { - String[] entry = readDatabaseMigrationMarker(path); - if (entry == null || entry[1] == null) { - return null; - } - return new File(databaseMigrationMarker(path).getParentFile(), entry[1]); - } - - /// Every database connection this port has open, by the file it is open on. - /// - /// Shared by both implementations on purpose. Only a conversion needs it, and a conversion is - /// not a statement: it renames a new file over the database while the process is running, and - /// Android lets that succeed while another connection holds the old one. That connection goes - /// on writing to a file that is no longer the database, is told each write succeeded, and - /// loses all of it when the backup is deleted. - /// - /// The connection it collides with is usually not another encrypted one -- the ordinary case - /// is an application holding `Database.openOrCreate(name)` open, which is a plaintext - /// connection, and then calling `Database.encrypt(name, ...)`. Counting only the encrypted - /// ones would miss exactly the case that happens. - private static final java.util.Map OPEN_DATABASE_CONNECTIONS = - new java.util.HashMap(); - - /// The key a database file is tracked under. - /// - /// Canonical, because two spellings of one file must not be two entries: a connection opened - /// as `/data/app/db.sqlite` has to be visible to a conversion started as - /// `/data/app/./db.sqlite`, or the file is replaced underneath it and its later writes -- each - /// one reported as successful -- disappear with the old inode. `toNativePath` only strips the - /// `file://` prefix, so a custom path arrives however the caller spelled it. - /// - /// Falls back to the absolute path when the file system cannot answer, which still collapses - /// the relative spellings; a canonical path that cannot be resolved is not a reason to refuse - /// to open a database. - /// The canonical identity of a database file, for callers outside this class. - /// - /// The cipher package resolves a managed key against it, so that its key change and the next - /// open agree on which file they are talking about. - public static String canonicalDatabaseKey(String path) { - return databaseKey(path); - } - - private static String databaseKey(String path) { - if (path == null) { - return null; - } - try { - return new File(path).getCanonicalPath(); - } catch (IOException cannotResolve) { - return new File(path).getAbsolutePath(); - } - } - - /// Records a connection opened on a database file. - public static synchronized void databaseConnectionOpened(String rawPath) { - String path = databaseKey(rawPath); - if (path == null) { - return; - } - Integer count = OPEN_DATABASE_CONNECTIONS.get(path); - OPEN_DATABASE_CONNECTIONS.put(path, - Integer.valueOf(count == null ? 1 : count.intValue() + 1)); - } - - /// Records a connection closed on a database file. - public static synchronized void databaseConnectionClosed(String rawPath) { - String path = databaseKey(rawPath); - if (path == null) { - return; - } - Integer count = OPEN_DATABASE_CONNECTIONS.get(path); - if (count == null) { - return; - } - if (count.intValue() <= 1) { - OPEN_DATABASE_CONNECTIONS.remove(path); - } else { - OPEN_DATABASE_CONNECTIONS.put(path, Integer.valueOf(count.intValue() - 1)); - } - } - - /// Database files a conversion currently owns exclusively. - private static final java.util.Set MIGRATING_DATABASES = - new java.util.HashSet(); - - /// Claims a database for a conversion, or refuses. - /// - /// Counting the connections and then converting are one decision, not two. Between a count - /// read on its own and the rename that ends the conversion, another thread can open the - /// database, and that connection then holds the file the rename replaces: its writes are - /// accepted and disappear when the backup goes. So the count is read and the claim taken - /// under the same lock the opens take, and an open that arrives afterwards is refused for as - /// long as the conversion runs. - /// - /// #### Parameters - /// - /// - `path`: the database file - /// - /// #### Throws - /// - /// - `IOException`: if the database is open elsewhere, or already being converted - public static synchronized void beginDatabaseMigration(String rawPath) throws IOException { - String path = databaseKey(rawPath); - if (MIGRATING_DATABASES.contains(path)) { - throw new IOException("The database " + path + " is already being converted."); - } - Integer count = OPEN_DATABASE_CONNECTIONS.get(path); - if (count != null && count.intValue() > 1) { - throw new IOException("The database " + path + " is open more than once, and " - + "converting it replaces the file underneath every connection to it. Close " - + "the other connections first; writes made through them during the " - + "conversion would be accepted and then lost."); - } - MIGRATING_DATABASES.add(path); - } - - /// Recovers an interrupted conversion, but only for an open that has the file to itself. - /// - /// Called from the open paths, plaintext and encrypted, each of which has already reserved - /// its own connection -- so one open connection is this caller and anything beyond it is - /// somebody else's handle, including one taken through the constructor that wraps an - /// already-open connection. Recovery renames the live file aside and puts a backup back, and - /// a connection attached to the displaced file keeps accepting writes that go nowhere, so it - /// is left for the next open that has the file alone. - /// - /// #### Parameters - /// - /// - `rawPath`: the database file - /// - /// #### Throws - /// - /// - `IOException`: if the recovery itself fails - public static void recoverIfSoleConnection(String rawPath) throws IOException { - if (claimDatabaseForRecovery(rawPath, 1)) { - try { - recoverInterruptedDatabaseMigration(rawPath); - } finally { - endDatabaseMigration(rawPath); - } - return; - } - if (hasInterruptedDatabaseMigration(rawPath)) { - // Recovery could not run and there is work waiting for it, which means the file this - // open would hand back is one recovery is going to replace. Two handles writing to it - // in the meantime would both be told their writes succeeded, and the next open with - // the file to itself would restore the backup over the top of them. Refusing is the - // only answer that does not accept writes it cannot keep. - throw new IOException("The database " + rawPath + " has a conversion that was " - + "interrupted, and it cannot be finished while another connection holds the " - + "file. Close the other connections and open it again; the data is intact " - + "and will be put back then."); - } - } - - /// Whether a conversion of this database was interrupted and still has work waiting. - /// - /// A marker this port wrote is the record of that. One written by something else is not ours - /// to read, and recovery leaves it alone for the same reason. - /// - /// #### Parameters - /// - /// - `rawPath`: the database file - /// - /// #### Returns - /// - /// true when recovery has something to do - private static boolean hasInterruptedDatabaseMigration(String rawPath) { - File marker = databaseMigrationMarker(rawPath); - return marker != null && marker.isFile() && ownsDatabaseMigrationMarker(rawPath); - } - - /// Takes the conversion claim for a recovery, or reports that a conversion already holds it. - /// - /// Recovery moves the same three files a conversion does, so the two must not overlap. The - /// claim is the conversion's own, so a conversion starting while recovery runs is refused by - /// `#beginDatabaseMigration(String)` exactly as a second conversion would be. - /// - /// #### Parameters - /// - /// - `rawPath`: the database file - /// - /// #### Returns - /// - /// true when the claim was taken and must be given back - private static synchronized boolean claimDatabaseForRecovery(String rawPath, - int connectionsOfOurOwn) { - String path = databaseKey(rawPath); - if (path == null || MIGRATING_DATABASES.contains(path)) { - return false; - } - Integer count = OPEN_DATABASE_CONNECTIONS.get(path); - if (count != null && count.intValue() > connectionsOfOurOwn) { - // Somebody else holds the file. Recovery renames the live file aside and puts a - // backup back, and a connection already attached to the displaced file keeps - // accepting writes that go nowhere -- worst of all for a conversion whose converted - // file was never validated, where the backup is what recovery installs. Refusing - // leaves the marker in place for the next open that has the file to itself. - return false; - } - MIGRATING_DATABASES.add(path); - return true; - } - - /// Whether a conversion currently owns a database file. - public static synchronized boolean isDatabaseBeingConverted(String rawPath) { - return MIGRATING_DATABASES.contains(databaseKey(rawPath)); - } - - /// Releases a database claimed by `#beginDatabaseMigration(String)`. - public static synchronized void endDatabaseMigration(String rawPath) { - MIGRATING_DATABASES.remove(databaseKey(rawPath)); - } - - /// Gives back a slot taken by `#reserveDatabaseConnection(String)` when no connection was - /// handed to the caller after all. - public static void releaseUnusedDatabaseConnection(String path) { - databaseConnectionClosed(path); - } - - /// Takes a connection slot on a database, or refuses because a conversion owns it. - /// - /// The check and the count are one step. Checking that no conversion is running and then - /// registering afterwards leaves a gap: the engine's open sits between them, and a conversion - /// that reads the count during it sees only its own connection, takes its claim, and starts - /// replacing the file the open is about to return a connection to. Taking the slot inside the - /// same lock as the check closes that -- a conversion either sees the slot and refuses, or - /// holds the claim and the open refuses. - /// - /// The caller releases the slot with `#databaseConnectionClosed(String)` if the open itself - /// then fails, and the connection releases it on close. - /// - /// #### Throws - /// - /// - `IOException`: if a conversion currently owns the file - public static synchronized void reserveDatabaseConnection(String rawPath) throws IOException { - String path = databaseKey(rawPath); - if (path != null && com.codename1.db.Database.isDatabaseBeingDeleted(path)) { - // The claim the delete holds, not one of this port's: it is taken before the count - // this method increments is read, so an open arriving mid-delete is refused here and - // an open that got in first is seen by that count. A claim of our own, taken when - // the delete reached this port, would have been too late -- the count had already - // been read by then, and an open landing in between would have been handed a file - // about to lose its name. - throw new IOException("The database " + path + " is being deleted and cannot be " - + "opened."); - } - if (path != null && MIGRATING_DATABASES.contains(path)) { - throw new IOException("The database " + path + " is being converted and cannot be " - + "opened until that finishes."); - } - databaseConnectionOpened(path); - } - - /// How many connections are open on a database file, encrypted or not. - public static synchronized int connectionsOpenOn(String rawPath) { - Integer count = OPEN_DATABASE_CONNECTIONS.get(databaseKey(rawPath)); - return count == null ? 0 : count.intValue(); - } - - /// Disposes of an export, and reports anything that survived. - /// - /// If the file cannot be unlinked it is truncated instead, which removes the contents even - /// where the directory entry survives. - /// - /// @return a sentence to append to a failure message, empty when nothing survived - public static String discardDatabaseMigrationExport(File target) { - if (target == null) { - return ""; - } - // The sidecars before anything else, and through the platform's own deletion, which knows - // the whole set: -wal, -shm, -journal and the master journals. A database written here - // leaves rows in those, so removing the file alone left the data behind under a name - // nobody was looking at -- which is the one thing this method exists to prevent. It is - // also the case that matters most, since the export is a complete copy of the database, - // in plaintext whenever the conversion was a decrypt. - android.database.sqlite.SQLiteDatabase.deleteDatabase(target); - String survivingSidecars = discardDatabaseSidecars(target); - if (!target.exists() || target.delete()) { - return survivingSidecars; - } - if (isSymbolicLink(target)) { - // Emptying follows the link, and what it would empty is whatever the link points at. - // The name was checked before any of this began, but a directory another actor can - // write to can have that name replaced afterwards, and unlinking a link that cannot - // be unlinked leaves this holding a name that now means somebody else's file. - // Reported instead: the export could not be removed, and nothing else is touched. - return " A complete copy of the data was left at " + target.getPath() - + ", which is now a link and was left alone; delete it." + survivingSidecars; - } - try { - new FileOutputStream(target).close(); - } catch (IOException cannotEmptyIt) { - return " A complete copy of the data was left at " + target.getPath() - + " and could not be removed; delete it." + survivingSidecars; - } - if (!target.exists() || target.delete()) { - return survivingSidecars; - } - return " An emptied file was left at " + target.getPath() + "." + survivingSidecars; - } - - /// Whether a name now resolves to something other than itself. - /// - /// Everything under the migration directory was checked to be a plain name inside it before - /// any of it was acted on. That check happens once, and a directory another actor can write to - /// can have an entry replaced between then and the cleanup -- so anything that opens a file - /// rather than unlinking it asks again, immediately before it opens it. - /// - /// Unlinking needs no such question: removing a link removes the link. Emptying does, because - /// a stream follows it and empties whatever it points at. - /// - /// Compares the canonical path with the absolute one rather than using a no-follow open, which - /// this port cannot reach at the API levels it supports. It does not close the window between - /// the question and the open, and cannot from Java; it does stop the case that makes the - /// window worth anything, which is a link that has been left in place because it could not be - /// unlinked. - /// - /// #### Parameters - /// - /// - `f`: the entry about to be opened - /// - /// #### Returns - /// - /// true if it is a link, or if that could not be determined - private static boolean isSymbolicLink(File f) { - try { - return !f.getCanonicalFile().equals(f.getAbsoluteFile()); - } catch (IOException cannotResolve) { - // Unresolvable is treated as a link: this only decides whether to open something, and - // not opening it costs a message where opening it could truncate another file. - return true; - } - } - - /// Disposes of the files SQLite keeps beside a database, and reports anything that survived. - /// - /// Called after the platform's own deletion rather than instead of it: that removes them in - /// the ordinary case, and this is what happens when one could not be unlinked. Emptying is - /// the fallback for the same reason it is for the database itself -- a file that cannot be - /// removed can still be stripped of what it holds. - /// - /// @param target the database file whose companions these are - /// @return a sentence to append to a failure message, empty when nothing survived - private static String discardDatabaseSidecars(File target) { - String[] suffixes = {"-wal", "-shm", "-journal"}; - StringBuilder left = new StringBuilder(); - for (int iter = 0; iter < suffixes.length; iter++) { - File sidecar = new File(target.getPath() + suffixes[iter]); - if (!sidecar.exists() || sidecar.delete()) { - continue; - } - if (isSymbolicLink(sidecar)) { - // As above: emptying a link empties its target, and the target is not ours. - left.append(" A working file was left at ").append(sidecar.getPath()) - .append(", which is now a link and was left alone."); - continue; - } - try { - new FileOutputStream(sidecar).close(); - } catch (IOException cannotEmptyIt) { - left.append(" Part of the data was left at ").append(sidecar.getPath()) - .append(" and could not be removed; delete it."); - continue; - } - if (sidecar.exists() && !sidecar.delete()) { - left.append(" An emptied file was left at ").append(sidecar.getPath()).append("."); - } - } - return left.toString(); - } - - /// Records that a conversion is under way and which file holds the original. - /// - /// The marker is the one file here whose name has to be predictable, because recovery has to - /// find it without being told. So it is the one place something could already be sitting - - /// an application may point a database at this exact path - and writing over it would - /// destroy that database. Anything already there that this port did not write means the - /// conversion does not start. - /// Marks a conversion whose installed file was never shown to open. - /// - /// Recovery reads a live file and a backup both being present as a completed conversion and - /// removes the backup. That is right when the converted file opened, and catastrophic when it - /// did not and could not be taken back out either: the last readable copy would go. This - /// records the difference, and recovery puts the backup back instead. - public static void markDatabaseMigrationUnvalidated(String path, File backup) - throws IOException { - writeMarker(path, backup, null, true); - } - - /// The same, for a conversion whose export has not been installed yet. - /// - /// The export has to stay named while it still exists under its own name, or recovery cannot - /// find it to clean it up -- and a conversion interrupted here leaves a complete copy of the - /// database in the migration directory, which after a decryption is a plaintext one. - /// - /// #### Parameters - /// - /// - `path`: the live database - /// - `backup`: the file the original was moved to - /// - `target`: the export, while it is still under its own name - /// - /// #### Throws - /// - /// - `IOException`: if the record cannot be written - public static void markDatabaseMigrationUnvalidated(String path, File backup, File target) - throws IOException { - writeMarker(path, backup, target, true); - } - - public static void writeDatabaseMigrationMarker(String path, File backup, File target) - throws IOException { - writeMarker(path, backup, target, false); - } - - private static void writeMarker(String path, File backup, File target, boolean unvalidated) - throws IOException { - File marker = databaseMigrationMarker(path); - if (marker == null) { - throw new IOException("The database " + path + " has no directory to convert it in"); - } - if (marker.exists() && !ownsDatabaseMigrationMarker(path)) { - throw new IOException("There is already a file at " + marker + " that this port did " - + "not write, so the conversion was not started rather than overwriting it. " - + "Move it aside if it is not a database you need."); - } - // Written beside the marker and renamed over it, never written into it. The second call - // updates a marker that is already valid and already naming a file holding data, and - // opening it for writing truncates it first: a process death in that window leaves a - // marker that recovery cannot recognise, so it acts on nothing and the export it named is - // orphaned. A rename is atomic, so the marker is only ever the old contents or the new. - // The marker's own name already carries the ".marker" suffix, so it is never short - // enough for createTempFile to reject the prefix. - File pending = File.createTempFile(marker.getName() + ".", ".pending", - marker.getParentFile()); - Writer writer = new OutputStreamWriter(new FileOutputStream(pending), "UTF-8"); - try { - writer.write(MIGRATION_MARKER_MAGIC); - writer.write("\n"); - writer.write(backup == null ? "" : backup.getName()); - writer.write("\n"); - writer.write(target == null ? "" : target.getName()); - writer.write("\n"); - writer.write(unvalidated ? MIGRATION_UNVALIDATED : ""); - writer.write("\n"); - } finally { - writer.close(); - } - // renameTo replaces an existing destination on the filesystems Android puts databases on. - // Deleting first would reopen exactly the window this is here to close. - if (!pending.renameTo(marker)) { - pending.delete(); - throw new IOException("The record of the conversion at " + marker + " could not be " - + "written, so the conversion was not started."); - } - } - - /// Restores a database whose conversion was interrupted between the two renames. - /// - /// Called before every open, encrypted or not. Encrypt and decrypt move the original aside - /// and install the converted file in its place, so a process death in that gap leaves a - /// complete database in the migration directory and nothing under the live name. Putting it - /// back is what makes that window recoverable rather than a silent empty database. - /// - /// Acts only on a marker this port wrote, and only on the backup that marker names. - public static void recoverInterruptedDatabaseMigration(String path) throws IOException { - if (path == null) { - return; - } - File marker = databaseMigrationMarker(path); - if (marker == null || !marker.isFile() || !ownsDatabaseMigrationMarker(path)) { - // Nothing of ours is here, and nothing of anybody else's gets touched. A file at this - // name that this port did not write belongs to someone -- a custom database path can - // legitimately put another database here -- and this runs before every open, so acting - // on it would mean that opening one database destroys an unrelated one. - return; - } - // The export first, whatever else is true. It is a second complete copy of the data, and - // a plaintext one when the conversion was a decryption, so an interrupted conversion must - // not leave it lying in the migration directory. It is only ever installed by being - // renamed over the live database, so anything still under its own name is an orphan. - File orphanedExport = readDatabaseMigrationTarget(path); - if (orphanedExport != null && orphanedExport.exists()) { - String surviving = discardDatabaseMigrationExport(orphanedExport); - if (surviving.length() > 0) { - throw new IOException("The database " + path + " has an interrupted conversion " - + "whose working copy could not be cleaned up." + surviving); - } - } - File backup = readDatabaseMigrationBackup(path); - if (backup == null) { - // No original was moved aside, so the conversion never reached the swap. Only the - // export existed, and it is gone. - marker.delete(); - return; - } - File live = new File(path); - if (!backup.isFile()) { - // The marker outlived its backup, so there is nothing to put back or clean up. - marker.delete(); - return; - } - if (!live.exists()) { - // Died between the two renames: the backup is the only copy. Put it back, and refuse - // to continue if that fails - opening would create an empty database over the top and - // the next conversion would remove the backup as stale, losing the data for good. - if (!backup.renameTo(live)) { - throw new IOException("The database " + path + " is mid-conversion and the copy " - + "holding its contents, at " + backup + ", could not be moved back. The " - + "data is intact in that file; the database was not opened rather than " - + "replacing it with an empty one."); - } - marker.delete(); - return; - } - if (isDatabaseMigrationUnvalidated(path)) { - // The converted file is in place but was never shown to open, and the conversion could - // not take it back out. Both files existing is not evidence of success here, so the - // backup goes back rather than away: deleting it would drop the last readable copy. - File displaced = unusedSibling(path + ".unvalidated"); - if (displaced == null) { - throw new IOException("The database " + path + " holds a converted file that was " - + "never shown to open, and there is nowhere to move it aside to. The " - + "original is intact at " + backup + "; nothing was overwritten."); - } - // Named in the marker before the first rename, in the slot an export is named in. - // The two renames below are not one step: a process dying between them leaves the - // converted file under a name nothing knows about, and the recovery after that takes - // the branch above -- restores the backup, deletes the marker, and leaves that file - // beside the database for good. After a failed decryption it is a plaintext copy. - // Recorded first, the next recovery finds it exactly where it finds an abandoned - // export, and discards it the same way. - try { - markDatabaseMigrationUnvalidated(path, backup, displaced); - } catch (IOException cannotRecord) { - throw new IOException("The database " + path + " holds a converted file that was " - + "never shown to open, and where it is about to be moved could not be " - + "recorded. The original is intact at " + backup + "; nothing was moved.", - cannotRecord); - } - if (!live.renameTo(displaced) || !backup.renameTo(live)) { - throw new IOException("The database " + path + " holds a converted file that was " - + "never shown to open, and the original at " + backup + " could not be " - + "put back. The data is in that file; it was left there rather than " - + "removed."); - } - // The same cleanup an abandoned export gets, and for the same reason: this file is a - // complete copy of the database, and after a failed decryption it is the plaintext - // one. A delete() whose result nobody reads would leave it beside the restored - // database under a predictable name while recovery reported success. - String surviving = discardDatabaseMigrationExport(displaced); - if (surviving.length() > 0) { - throw new IOException("The database " + path + " was restored from its backup, but" - + " the converted copy could not be removed." + surviving); - } - marker.delete(); - return; - } - // Both exist, so the swap completed and only the cleanup was lost. The backup is the - // database in its previous form, which after an encrypt is a plaintext copy of an - // encrypted database - the encryption-at-rest hole in slow motion. - if (!backup.delete() && backup.exists()) { - throw new IOException("The database " + path + " was converted, but the copy of its " - + "previous form at " + backup + " could not be removed. Delete it before " - + "relying on this database being encrypted."); - } - marker.delete(); - } - - /// A path near `preferred` that no file occupies, or null if too many are taken. - /// - /// The recovery moves the rejected file aside before putting the original back, and on these - /// filesystems a rename replaces whatever is at the destination. A custom database path can put - /// that destination anywhere the application also keeps files, so writing to it blind would let - /// a failed conversion destroy an unrelated file of the application's while reporting that it - /// recovered cleanly. - private static File unusedSibling(String preferred) { - File candidate = new File(preferred); - if (!candidate.exists()) { - return candidate; - } - for (int iter = 1; iter < 100; iter++) { - candidate = new File(preferred + "." + iter); - if (!candidate.exists()) { - return candidate; - } - } - return null; - } - - /// Removes the working files for a database, reporting anything it could not remove. - /// - /// Used by delete, where the caller's intent is that the data goes away. A failure here has - /// to stop the deletion: continuing would report success while a complete copy of the - /// database survives, and a later open would restore it. - static void discardDatabaseMigrationArtifacts(String path) throws IOException { - if (path == null) { - return; - } - File export = readDatabaseMigrationTarget(path); - if (export != null && export.exists()) { - String surviving = discardDatabaseMigrationExport(export); - if (surviving.length() > 0) { - throw new IOException("The database " + path + " was not deleted, because the " - + "working copy of its interrupted conversion could not be removed." - + surviving); - } - } - File backup = readDatabaseMigrationBackup(path); - if (backup == null) { - File onlyMarker = databaseMigrationMarker(path); - if (onlyMarker != null && onlyMarker.isFile() && ownsDatabaseMigrationMarker(path) - && !onlyMarker.delete() && onlyMarker.exists()) { - throw new IOException("The database " + path + " was not deleted, because the " - + "record of its interrupted conversion at " + onlyMarker + " could not " - + "be removed."); - } - return; - } - if (backup.exists() && !backup.delete() && backup.exists()) { - throw new IOException("The database " + path + " was not deleted, because the copy of " - + "it at " + backup + " could not be removed and a later open would restore " - + "it."); - } - File marker = databaseMigrationMarker(path); - if (marker.exists() && !marker.delete() && marker.exists()) { - throw new IOException("The database " + path + " was not deleted, because the record " - + "of its interrupted conversion at " + marker + " could not be removed."); - } - } - - /// Whether a marked migration backup is holding a database's contents. - static boolean hasRecoverableDatabaseBackup(String path) { - File backup = readDatabaseMigrationBackup(path); - return backup != null && backup.isFile(); - } - - /// Leaves a database that will not open where it is. - /// - /// The platform default answers corruption by deleting the file. An encrypted database opened - /// without its key is ciphertext to the plain engine, which is indistinguishable from - /// corruption -- so a single accidental openOrCreate(name) against an encrypted database - /// destroyed it, and destroyed it in the one case where the data was perfectly intact and one - /// correct-key open away from being readable. - /// - /// Keeping the file turns that into a failed open, which is what a wrong key should be. A - /// genuinely corrupt database is kept too, which is the answer every other port gives: - /// reporting the failure and leaving the bytes for a backup or a repair tool beats deleting - /// them on the application's behalf. - private static final class KeepDatabaseOnCorruption - implements android.database.DatabaseErrorHandler { - @Override - public void onCorruption(SQLiteDatabase databaseObject) { - com.codename1.io.Log.p("Database " + databaseObject.getPath() + " could not be read. " - + "It was left in place rather than deleted: an encrypted database opened " - + "without its key looks exactly like this."); - } - } - - private static final android.database.DatabaseErrorHandler KEEP_ON_CORRUPTION = - new KeepDatabaseOnCorruption(); - - private String resolveNativeDatabasePath(String databaseName) { - if (databaseName.startsWith("file://")) { - return FileSystemStorage.getInstance().toNativePath(databaseName); - } - return getDatabasePath(databaseName); - } - - @Override - public Database openOrCreateDBForRekey(String databaseName) throws IOException { - // The stock android.database.sqlite engine has no cipher, so a plaintext database opened - // through it can never be encrypted in place. Route the migration through SQLCipher, which - // opens an unencrypted file when given an empty key and can then rekey it. - if (!isDatabaseEncryptionSupported()) { - return openOrCreateDB(databaseName); - } - // The slot is taken before the engine opens anything, for the reason given in - // openOrCreateDB. AndroidCipherFactory hands back a connection that already holds it. - String nativePath = resolveNativeDatabasePath(databaseName); - reserveDatabaseConnection(nativePath); - Object opened; - try { - Class c = Class.forName("com.codename1.impl.android.cipher.AndroidCipherFactory"); - java.lang.reflect.Method open = c.getMethod("open", String.class, String.class, String.class); - // Cast below, outside the try, for the reason given in openOrCreateDB. - opened = open.invoke(null, - resolveNativeDatabasePath(databaseName), databaseName, ""); - } catch (java.lang.reflect.InvocationTargetException err) { - // The open threw, so no connection exists to release the slot later. A rekey open of - // a file that turns out to be encrypted lands here, and leaving the slot behind would - // make every later conversion of that database see a connection that is not there. - releaseUnusedDatabaseConnection(nativePath); - Throwable cause = err.getCause(); - if (cause instanceof IOException) { - throw (IOException) cause; - } - throw new IOException(cause == null ? err.toString() : cause.getMessage(), cause); - } catch (NoSuchMethodException broken) { - // Same reasoning as openOrCreateDB: falling back to the plaintext engine here would - // silently turn a re-key into a no-op on a build that does ship the cipher. - releaseUnusedDatabaseConnection(nativePath); - throw new IOException("The encrypted database implementation is present but does not " - + "expose the expected entry point. This build is inconsistent: " - + broken.getMessage(), broken); - } catch (Throwable err) { - releaseUnusedDatabaseConnection(nativePath); - return openOrCreateDB(databaseName); - } - if (!(opened instanceof Database)) { - releaseUnusedDatabaseConnection(nativePath); - throw new IOException("The encrypted database implementation returned " - + (opened == null ? "nothing" : opened.getClass().getName()) - + " rather than a Database. This build is inconsistent."); - } - return (Database) opened; - } - - @Override - public boolean isBlobQueryParameterSupported() { - return true; - } - - @Override - public boolean isDatabaseCustomPathSupported() { - return true; - } - - - - /// How many connections this port has open on a database, for the delete guard in core. - /// - /// This port counts connections in its own registry rather than the base class's, because the - /// conversion that consults them runs here. Answering from it is what makes - /// `Database.delete(String)` refuse on Android as it does everywhere else. - @Override - public int openDatabaseConnections(String databaseName) { - try { - return connectionsOpenOn(resolveNativeDatabasePath(databaseName)); - } catch (RuntimeException cannotResolve) { - // An unresolvable name cannot be matched against the registry. Reporting none leaves - // the delete to the checks below rather than refusing something that may be fine. - return 0; - } - } - - @Override - public void deleteDB(String databaseName) throws IOException { - String deletePath = resolveNativeDatabasePath(databaseName); - if (isDatabaseBeingConverted(deletePath)) { - // A conversion owns the file and its working copies. Deleting either underneath it - // would strand the data in whichever one the conversion has not installed yet. - throw new IOException("The database " + deletePath + " is being converted and cannot " - + "be deleted until that finishes."); - } - // The working files first. They survive deleting the live file, and the next open runs - // recovery and puts the backup back - so a database the caller was told had been deleted - // reappears, and after an interrupted encryption what reappears is the plaintext copy. - discardDatabaseMigrationArtifacts(deletePath); - if (databaseName.startsWith("file://")) { - // Through the platform's own deletion rather than by removing the file, which is what - // this used to do. A SQLite database is more than its file: a crash or a kill leaves - // -wal, -shm and -journal beside it, holding rows that were written, and for an - // encrypted database those rows are as readable as the pages they came from. Removing - // the file alone reported a successful delete and left them there, and the next open - // on the same name would read them back. deleteDatabase takes the sidecars and the - // master journals with it, which is exactly what the non-custom branch below has been - // getting from Context.deleteDatabase all along. - android.database.sqlite.SQLiteDatabase.deleteDatabase(new File(deletePath)); - } else { - getContext().deleteDatabase(databaseName); - } - requireDatabaseGone(deletePath); - } - - /// Reports anything the platform left behind, rather than trusting that it deleted it. - /// - /// Both calls above answer with a boolean and neither says what it could not remove -- - /// deleteDatabase ORs the results of deleting the file, the journal, the shared-memory index, - /// the write-ahead log and any master journals, so it answers true when the database file went - /// and a read-only or busy -wal stayed. Reading that boolean would therefore report success - /// over surviving pages just as ignoring it did, so this looks at the files instead. - /// - /// It matters most for the case this was added for: those files hold rows that were written, - /// and for an encrypted database they are as readable as the pages they came from. A caller - /// told the database was deleted has no reason to look, so the only chance to say so is here. - /// - /// #### Parameters - /// - /// - `path`: the database file, whose companions share its name - /// - /// #### Throws - /// - /// - `IOException`: naming whatever is still on disk - private void requireDatabaseGone(String path) throws IOException { - File database = new File(path); - StringBuilder left = new StringBuilder(); - if (database.exists()) { - left.append(' ').append(database.getPath()); - } - String[] sidecars = databaseSidecarPaths(path); - for (int iter = 0; iter < sidecars.length; iter++) { - File sidecar = new File(sidecars[iter]); - if (sidecar.exists()) { - left.append(' ').append(sidecar.getPath()); - } - } - // The master journals as well, which is why this lists the directory rather than checking - // three fixed names: SQLite names them -mj and there can be more than one. - File directory = database.getParentFile(); - if (directory != null) { - final String prefix = database.getName() + "-mj"; - File[] journals = directory.listFiles(); - if (journals != null) { - for (int iter = 0; iter < journals.length; iter++) { - if (journals[iter].getName().startsWith(prefix)) { - left.append(' ').append(journals[iter].getPath()); - } - } - } - } - if (left.length() > 0) { - throw new IOException("The database was not fully deleted. These files are still on " - + "disk and hold its data:" + left + ". Close every connection to it and try " - + "again, or remove them."); - } - } - - @Override - public boolean existsDB(String databaseName) { - // Recover first. A conversion interrupted between its two renames leaves the live name - // missing while the database itself sits complete in the migration directory, and - // reporting "does not exist" there would refuse a retry of encrypt or decrypt - the one - // operation that could put it right. - String path = resolveNativeDatabasePath(databaseName); - // The claim, not a look at it. Asking whether a conversion is running and then recovering - // are two steps, and a conversion starting in between would find recovery already moving - // its marker, target and backup around: depending on how far it had got, recovery would - // delete the export it was writing, restore the backup during the swap, or -- the worst - // of the three -- remove the backup before the converted file had been validated, which - // is the copy the conversion falls back to when the reopen fails. - if (!claimDatabaseForRecovery(path, 0)) { - // A conversion is mid-flight and owns both the live file and its working copies. - // Recovering underneath it would act on a half-installed state, so this answers from - // what the conversion has not yet consumed instead. - return hasRecoverableDatabaseBackup(path) || new File(path).exists(); - } - try { - recoverInterruptedDatabaseMigration(path); - } catch (IOException cannotRecover) { - // The data is still in the migration directory, so the database does exist even - // though it could not be moved back. Say so; the open will report the real problem. - return hasRecoverableDatabaseBackup(path); - } finally { - endDatabaseMigration(path); - } - if (databaseName.startsWith("file://")) { - return exists(databaseName); - } - File db = new File(getContext().getApplicationInfo().dataDir + "/databases/" + databaseName); - return db.exists(); - } - - public String getDatabasePath(String databaseName) { - if (databaseName.startsWith("file://")) { - return databaseName; - } - File db = new File(getContext().getApplicationInfo().dataDir + "/databases/" + databaseName); - return db.getAbsolutePath(); - } - - public boolean isNativeTitle() { - if(com.codename1.ui.Toolbar.isGlobalToolbar()) { - return false; - } - Form f = getCurrentForm(); - boolean nativeCommand; - if(f != null){ - nativeCommand = f.getMenuBar().getCommandBehavior() == Display.COMMAND_BEHAVIOR_NATIVE; - }else{ - nativeCommand = getCommandBehavior() == Display.COMMAND_BEHAVIOR_NATIVE; - } - return hasActionBar() && nativeCommand; - } - - public void refreshNativeTitle(){ - if (getActivity() == null || com.codename1.ui.Toolbar.isGlobalToolbar()) { - return; - } - Form f = getCurrentForm(); - if (f != null && isNativeTitle() && !(f instanceof Dialog)) { - getActivity().runOnUiThread(new SetCurrentFormImpl(getActivity(), f)); - } - } - - public void setCurrentForm(final Form f) { - if (getActivity() == null) { - return; - } - if(getCurrentForm() == null){ - flushGraphics(); - } - if(editInProgress()) { - stopEditing(true); - } - super.setCurrentForm(f); - if (isNativeTitle() && !(f instanceof Dialog)) { - getActivity().runOnUiThread(new SetCurrentFormImpl(getActivity(), f)); - } - } - - @Override - public void setNativeCommands(Vector commands) { - refreshNativeTitle(); - } - - @Override - public boolean isScreenLockSupported() { - return true; - } - - @Override - public void lockScreen(){ - ((CodenameOneActivity)getContext()).lockScreen(); - } - - @Override - public void unlockScreen(){ - ((CodenameOneActivity)getContext()).unlockScreen(); - } - - private static class SetCurrentFormImpl implements Runnable { - private Activity activity; - private Form f; - - public SetCurrentFormImpl(Activity activity, Form f) { - this.activity = activity; - this.f = f; - } - - @Override - public void run() { - if(com.codename1.ui.Toolbar.isGlobalToolbar()) { - return; - } - ActionBar ab = activity.getActionBar(); - String title = f.getTitle(); - boolean hasMenuBtn = false; - if(android.os.Build.VERSION.SDK_INT >= 14){ - try { - ViewConfiguration vc = ViewConfiguration.get(activity); - Method m = vc.getClass().getMethod("hasPermanentMenuKey", (Class[])null); - hasMenuBtn = ((Boolean)m.invoke(vc, (Object[])null)).booleanValue(); - } catch(Throwable t) { - t.printStackTrace(); - } - } - if((title != null && title.length() > 0) || (f.getCommandCount() > 0 && !hasMenuBtn)){ - activity.runOnUiThread(new NotifyActionBar(activity, true)); - }else{ - activity.runOnUiThread(new NotifyActionBar(activity, false)); - return; - } - - ab.setTitle(title); - ab.setDisplayHomeAsUpEnabled(f.getBackCommand() != null); - if(android.os.Build.VERSION.SDK_INT >= 14){ - Image icon = f.getTitleComponent().getIcon(); - try { - if(icon != null){ - ab.getClass().getMethod("setIcon", Drawable.class).invoke(ab, new BitmapDrawable(activity.getResources(), (Bitmap)icon.getImage())); - }else{ - if(activity.getApplicationInfo().icon != 0){ - ab.getClass().getMethod("setIcon", Integer.TYPE).invoke(ab, activity.getApplicationInfo().icon); - } - } - activity.runOnUiThread(new InvalidateOptionsMenuImpl(activity)); - } catch(Throwable t) { - t.printStackTrace(); - } - } - return; - } - - } - - private Purchase pur; - - @Override - public Purchase getInAppPurchase() { - try { - pur = ZoozPurchase.class.newInstance(); - return pur; - } catch(Throwable t) { - return super.getInAppPurchase(); - } - } - - @Override - public boolean isTimeoutSupported() { - return true; - } - - @Override - public void setTimeout(int t) { - timeout = t; - } - - @Override - public CodeScanner getCodeScanner() { - if(scannerInstance == null) { - scannerInstance = new CodeScannerImpl(); - } - return scannerInstance; - } - - public void addCookie(Cookie c, boolean addToWebViewCookieManager, boolean sync) { - if(addToWebViewCookieManager) { - CookieManager mgr; - CookieSyncManager syncer; - try { - syncer = CookieSyncManager.getInstance(); - mgr = getCookieManager(); - } catch(IllegalStateException ex) { - syncer = CookieSyncManager.createInstance(this.getContext()); - mgr = getCookieManager(); - } - java.text.SimpleDateFormat format = new java.text.SimpleDateFormat("EEE, dd-MMM-yyyy HH:mm:ss z"); - format.setTimeZone(TimeZone.getTimeZone("GMT")); - addCookie(c, mgr, format); - if(sync) { - syncer.sync(); - } - } - super.addCookie(c); - - - - } - - private void addCookie(Cookie c, CookieManager mgr, java.text.SimpleDateFormat format) { - - String d = c.getDomain(); - String port = ""; - if (d.contains(":")) { - // For some reason, the port must be stripped and stored separately - // or it won't retrieve it properly. - // https://github.com/codenameone/CodenameOne/issues/2804 - port = "; Port=" + d.substring(d.indexOf(":")+1); - d = d.substring(0, d.indexOf(":")); - } - String cookieString = c.getName() + "=" + c.getValue() + - "; Domain=" + d + - port + - "; Path=" + c.getPath() + - "; " + (c.isSecure() ? "Secure;" : "") - + (c.getExpires() != 0 ? (" Expires="+format.format(new Date(c.getExpires()))+";") : "") - + (c.isHttpOnly() ? "httpOnly;" : ""); - String cookieUrl = "http" + - (c.isSecure() ? "s" : "") + "://" + - d + - c.getPath(); - mgr.setCookie(cookieUrl, cookieString); - } - - public void addCookie(Cookie[] cs, boolean addToWebViewCookieManager, boolean sync) { - if(addToWebViewCookieManager) { - CookieManager mgr; - CookieSyncManager syncer; - try { - syncer = CookieSyncManager.getInstance(); - mgr = getCookieManager(); - } catch(IllegalStateException ex) { - syncer = CookieSyncManager.createInstance(this.getContext()); - mgr = getCookieManager(); - } - java.text.SimpleDateFormat format = new java.text.SimpleDateFormat("EEE, dd-MMM-yyyy HH:mm:ss z"); - format.setTimeZone(TimeZone.getTimeZone("GMT")); - - for (Cookie c : cs) { - addCookie(c, mgr, format); - - } - - if(sync) { - syncer.sync(); - } - } - super.addCookie(cs); - - - - } - - @Override - public void addCookie(Cookie c) { - if(isUseNativeCookieStore()) { - this.addCookie(c, true, true); - } else { - super.addCookie(c); - } - } - - - - @Override - public void addCookie(Cookie[] cookiesArray) { - if(isUseNativeCookieStore()) { - this.addCookie(cookiesArray, true); - } else { - super.addCookie(cookiesArray); - } - } - - public void addCookie(Cookie[] cookiesArray, boolean addToWebViewCookieManager){ - addCookie(cookiesArray, addToWebViewCookieManager, false); - - } - - - - class CodeScannerImpl extends CodeScanner implements IntentResultListener { - private ScanResult callback; - - @Override - public void scanQRCode(ScanResult callback) { - if (getActivity() == null) { - return; - } - if (getActivity() instanceof CodenameOneActivity) { - ((CodenameOneActivity) getActivity()).setIntentResultListener(this); - } - this.callback = callback; - IntentIntegrator in = new IntentIntegrator(getActivity()); - if(!in.initiateScan(IntentIntegrator.QR_CODE_TYPES, "QR_CODE_MODE")){ - // restore old activity handling - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - if(CodeScannerImpl.this != null && CodeScannerImpl.this.callback != null) { - CodeScannerImpl.this.callback.scanError(-1, "no scan app"); - CodeScannerImpl.this.callback = null; - } - } - }); - - if (getActivity() instanceof CodenameOneActivity) { - ((CodenameOneActivity) getActivity()).restoreIntentResultListener(); - } - } - } - - @Override - public void scanBarCode(ScanResult callback) { - if (getActivity() == null) { - return; - } - if (getActivity() instanceof CodenameOneActivity) { - ((CodenameOneActivity) getActivity()).setIntentResultListener(this); - } - this.callback = callback; - IntentIntegrator in = new IntentIntegrator(getActivity()); - Collection types = IntentIntegrator.PRODUCT_CODE_TYPES; - if(Display.getInstance().getProperty("scanAllCodeTypes", "false").equals("true")) { - types = IntentIntegrator.ALL_CODE_TYPES; - } - if(Display.getInstance().getProperty("android.scanTypes", null) != null) { - String[] arr = Display.getInstance().getProperty("android.scanTypes", null).split(";"); - types = Arrays.asList(arr); - } - - if(!in.initiateScan(types, "ONE_D_MODE")){ - // restore old activity handling - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - CodeScannerImpl.this.callback.scanError(-1, "no scan app"); - CodeScannerImpl.this.callback = null; - } - }); - - if (getActivity() instanceof CodenameOneActivity) { - ((CodenameOneActivity) getActivity()).restoreIntentResultListener(); - } - } - } - - public void onActivityResult(int requestCode, final int resultCode, Intent data) { - if (requestCode == IntentIntegrator.REQUEST_CODE && callback != null) { - final ScanResult sr = callback; - if (resultCode == Activity.RESULT_OK) { - final String contents = data.getStringExtra("SCAN_RESULT"); - final String formatName = data.getStringExtra("SCAN_RESULT_FORMAT"); - final byte[] rawBytes = data.getByteArrayExtra("SCAN_RESULT_BYTES"); - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - sr.scanCompleted(contents, formatName, rawBytes); - } - }); - } else if(resultCode == Activity.RESULT_CANCELED) { - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - sr.scanCanceled(); - } - }); - - } else { - Display.getInstance().callSerially(new Runnable() { - @Override - public void run() { - sr.scanError(resultCode, null); - } - }); - } - callback = null; - } - - // restore old activity handling - if (getActivity() instanceof CodenameOneActivity) { - ((CodenameOneActivity) getActivity()).restoreIntentResultListener(); - } - } - } - - public boolean hasCamera() { - try { - int numCameras = Camera.getNumberOfCameras(); - return numCameras > 0; - } catch(Throwable t) { - return true; - } - } - - @Override - public com.codename1.impl.CameraImpl createCameraImpl() { - Activity act = getActivity(); - if (act == null) return null; - return new AndroidCameraImpl(act); - } - - @Override - public com.codename1.impl.ARImpl createARImpl() { - Activity act = getActivity(); - if (act == null) { - return null; - } - // The ARCore-backed impl lives in a package the build deletes for - // apps that never reference com.codename1.ar (it compiles against - // com.google.ar.core which only exists when the AR gradle dependency - // was injected), so it must be reached reflectively. - try { - Class clazz = Class.forName("com.codename1.impl.android.ar.AndroidARImpl"); - return (com.codename1.impl.ARImpl) clazz - .getConstructor(Activity.class).newInstance(act); - } catch (Throwable t) { - return null; - } - } - - private AndroidNearbyBridge nearbyBridge; - - /// The nearby bridge, which finds its own implementation. - /// - /// Always returned rather than conditionally null: the shell answers every - /// capability query honestly whether or not the optional backend was - /// bundled, so the public API reports NOT_SUPPORTED without this getter - /// having to know how the app was built. - @Override - public synchronized com.codename1.nearby.spi.NearbyBridge - getNearbyBridge() { - // Synchronized, because two threads reaching nearby for the first - // time both saw null and both built a backend. Only one was kept, - // and the loser could already have prepared a UWB session or taken - // the companion chooser slot in state nothing could reach again -- - // so a later start or stop could not find its session, and the radio - // it had opened stayed open. - if (nearbyBridge == null) { - nearbyBridge = new AndroidNearbyBridge(getActivity()); - } - return nearbyBridge; - } - - private com.codename1.impl.android.call.AndroidCallBridge callBridge; - - private com.codename1.impl.android.vpn.AndroidVpnBridge vpnBridge; - - /// The call bridge, on Telecom. - /// - /// Always returned rather than conditionally null: the bridge answers - /// every capability query honestly, including reporting no support at all - /// below API 26 where a self-managed ConnectionService does not exist, so - /// the public API degrades without this getter having to know the OS - /// version. - /// - /// Synchronized for the reason the nearby getter is: the bridge holds the - /// registered PhoneAccount, and two threads racing this would each build - /// one, with the loser's registration unreachable. - @Override - public synchronized com.codename1.call.spi.CallBridge getCallBridge() { - if (callBridge == null) { - callBridge = new com.codename1.impl.android.call.AndroidCallBridge( - callServiceContext()); - } - return callBridge; - } - - /// The context the call and VPN bridges do their system work through. - /// - /// NOT getActivity(): Codename One can be initialised from a Service -- - /// which is what happens when a push wakes the app to report an incoming - /// call -- and getActivity() is null there. The bridge cached that null - /// for the life of the process, so even isSupported() threw on the - /// TelecomManager lookup, and foregrounding later did not repair it. - /// - /// An activity is only needed to SHOW something, and the two places that - /// need one look for it when they get there. - private Context callServiceContext() { - Context any = getActivity(); - if (any == null) { - any = getContext(); - } - if (any == null) { - return null; - } - // The APPLICATION context, never the Activity. Both bridges keep - // what they are given in a final field and are never cleared, so - // caching an Activity here held that Activity and its whole view - // hierarchy reachable for the rest of the process -- a leak renewed - // by every rotation. Nothing the bridges do with it needs an - // Activity: they look up system services, the package manager and - // the application label, and the two places that must SHOW - // something ask getActivity() at the point of showing, which is - // what the comment above already promised and what - // currentActivity() implements. - Context app = any.getApplicationContext(); - return app != null ? app : any; - } - - /// The VPN bridge, on the platform's managed IKEv2 client. - /// - /// Reports no support below API 30, where `VpnManager` does not exist. - @Override - public synchronized com.codename1.vpn.spi.VpnBridge getVpnBridge() { - if (vpnBridge == null) { - vpnBridge = new com.codename1.impl.android.vpn.AndroidVpnBridge( - callServiceContext()); - } - return vpnBridge; - } - - @Override - public com.codename1.impl.VisionImpl createVisionImpl() { - return (com.codename1.impl.VisionImpl) createOptionalAiBackend( - "com.codename1.impl.android.ai.AndroidVisionImpl"); - } - - @Override - public com.codename1.impl.InferenceImpl createInferenceImpl() { - return (com.codename1.impl.InferenceImpl) createOptionalAiBackend( - "com.codename1.impl.android.ai.AndroidInferenceImpl"); - } - - @Override - public com.codename1.impl.LanguageImpl createLanguageImpl() { - return (com.codename1.impl.LanguageImpl) createOptionalAiBackend( - "com.codename1.impl.android.ai.AndroidLanguageImpl"); - } - - private Object createOptionalAiBackend(String className) { - try { - return Class.forName(className).newInstance(); - } catch (Throwable t) { - return null; - } - } - - // Deeper-network connectivity platform factories. Each returns a small - // platform-specific class living under - // com.codename1.impl.android.connectivity. Those classes are loaded - // lazily on first call so apps that never reference WiFi / Bonjour / - // USB / NetworkTypeListener never pay the loading cost. - - @Override - protected com.codename1.io.wifi.WifiPlatform createWifiPlatform() { - return new com.codename1.impl.android.connectivity.AndroidWifiPlatform(); - } - - @Override - protected com.codename1.io.wifi.WifiDirectPlatform createWifiDirectPlatform() { - return new com.codename1.impl.android.connectivity.AndroidWifiDirectPlatform(); - } - - @Override - protected com.codename1.io.bonjour.BonjourPlatform createBonjourPlatform() { - return new com.codename1.impl.android.connectivity.AndroidBonjourPlatform(); - } - - @Override - protected com.codename1.io.usb.UsbPlatform createUsbPlatform() { - return new com.codename1.impl.android.connectivity.AndroidUsbPlatform(); - } - - @Override - protected com.codename1.io.NetworkTypePlatform createNetworkTypePlatform() { - return new com.codename1.impl.android.connectivity.AndroidNetworkTypePlatform(); - } - - public String getCurrentAccessPoint() { - - ConnectivityManager cm = (ConnectivityManager) getContext().getSystemService(Context.CONNECTIVITY_SERVICE); - NetworkInfo info = cm.getActiveNetworkInfo(); - if (info == null) { - return null; - } - String apName = info.getTypeName() + "_" + info.getSubtypeName(); - if (info.getExtraInfo() != null) { - apName += "_" + info.getExtraInfo(); - } - return apName; - } - - @Override - public boolean isVPNDetectionSupported() { - return true; - } - - @Override - public boolean isVPNActive() { - try { - ConnectivityManager cm = (ConnectivityManager) getContext().getSystemService(Context.CONNECTIVITY_SERVICE); - if (cm != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - android.net.Network network = cm.getActiveNetwork(); - if (network != null) { - android.net.NetworkCapabilities capabilities = cm.getNetworkCapabilities(network); - if (capabilities != null && capabilities.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN)) { - return true; - } - } - } - - Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); - while (interfaces != null && interfaces.hasMoreElements()) { - NetworkInterface current = interfaces.nextElement(); - if (!current.isUp() || current.isLoopback()) { - continue; - } - String name = current.getName(); - if (name == null) { - continue; - } - name = name.toLowerCase(Locale.US); - if (name.startsWith("tun") || name.startsWith("ppp") || name.startsWith("tap") || name.startsWith("ipsec")) { - return true; - } - } - } catch (Throwable t) { - Log.d("Codename One", "VPN detection failed", t); - } - return false; - } - - /** - * @inheritDoc - */ - public String[] getAPIds() { - if (apIds == null) { - apIds = new HashMap(); - NetworkInfo[] aps = ((ConnectivityManager) getContext().getSystemService(Context.CONNECTIVITY_SERVICE)).getAllNetworkInfo(); - for (int i = 0; i < aps.length; i++) { - String apName = aps[i].getTypeName() + "_" + aps[i].getSubtypeName(); - if (aps[i].getExtraInfo() != null) { - apName += "_" + aps[i].getExtraInfo(); - } - apIds.put(apName, aps[i]); - } - } - if (apIds.isEmpty()) { - return null; - } - String[] ret = new String[apIds.size()]; - Iterator iter = apIds.keySet().iterator(); - for (int i = 0; iter.hasNext(); i++) { - ret[i] = iter.next().toString(); - } - return ret; - - } - - /** - * @inheritDoc - */ - public int getAPType(String id) { - if (apIds == null) { - getAPIds(); - } - NetworkInfo info = (NetworkInfo) apIds.get(id); - if (info == null) { - return NetworkManager.ACCESS_POINT_TYPE_UNKNOWN; - } - int type = info.getType(); - int subType = info.getSubtype(); - if (type == ConnectivityManager.TYPE_WIFI) { - return NetworkManager.ACCESS_POINT_TYPE_WLAN; - } else if (type == ConnectivityManager.TYPE_MOBILE) { - switch (subType) { - case TelephonyManager.NETWORK_TYPE_1xRTT: - return NetworkManager.ACCESS_POINT_TYPE_NETWORK2G; // ~ 50-100 kbps - case TelephonyManager.NETWORK_TYPE_CDMA: - return NetworkManager.ACCESS_POINT_TYPE_NETWORK2G; // ~ 14-64 kbps - case TelephonyManager.NETWORK_TYPE_EDGE: - return NetworkManager.ACCESS_POINT_TYPE_NETWORK2G; // ~ 50-100 kbps - case TelephonyManager.NETWORK_TYPE_EVDO_0: - return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 400-1000 kbps - case TelephonyManager.NETWORK_TYPE_EVDO_A: - return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 600-1400 kbps - case TelephonyManager.NETWORK_TYPE_GPRS: - return NetworkManager.ACCESS_POINT_TYPE_NETWORK2G; // ~ 100 kbps - case TelephonyManager.NETWORK_TYPE_HSDPA: - return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 2-14 Mbps - case TelephonyManager.NETWORK_TYPE_HSPA: - return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 700-1700 kbps - case TelephonyManager.NETWORK_TYPE_HSUPA: - return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 1-23 Mbps - case TelephonyManager.NETWORK_TYPE_UMTS: - return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 400-7000 kbps - /* - * Above API level 7, make sure to set android:targetSdkVersion - * to appropriate level to use these - */ - case TelephonyManager.NETWORK_TYPE_EHRPD: // API level 11 - return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 1-2 Mbps - case TelephonyManager.NETWORK_TYPE_EVDO_B: // API level 9 - return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 5 Mbps - case TelephonyManager.NETWORK_TYPE_HSPAP: // API level 13 - return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 10-20 Mbps - case TelephonyManager.NETWORK_TYPE_IDEN: // API level 8 - return NetworkManager.ACCESS_POINT_TYPE_NETWORK2G; // ~25 kbps - case TelephonyManager.NETWORK_TYPE_LTE: // API level 11 - return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 10+ Mbps - // Unknown - case TelephonyManager.NETWORK_TYPE_UNKNOWN: - default: - return NetworkManager.ACCESS_POINT_TYPE_NETWORK2G; - } - } else { - return NetworkManager.ACCESS_POINT_TYPE_UNKNOWN; - } - } - - /** - * @inheritDoc - */ - public void setCurrentAccessPoint(String id) { - - if (apIds == null) { - getAPIds(); - } - NetworkInfo info = (NetworkInfo) apIds.get(id); - if (info == null || info.isConnectedOrConnecting()) { - return; - - } - ConnectivityManager cm = (ConnectivityManager) getContext().getSystemService(Context.CONNECTIVITY_SERVICE); - cm.setNetworkPreference(info.getType()); - } - - private void scanMedia(File file) { - Uri uri = Uri.fromFile(file); - Intent scanFileIntent = new Intent( - Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, uri); - getActivity().sendBroadcast(scanFileIntent); - } - - /** - * Gets the last image id from the media store - * - * @return - */ - private String getLastImageId() { - int idVal = 0;; - final String[] imageColumns = {MediaStore.Images.Media._ID}; - final String imageOrderBy = MediaStore.Images.Media._ID + " DESC"; - final String imageWhere = null; - final String[] imageArguments = null; - Cursor imageCursor = getContext().getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, imageColumns, imageWhere, imageArguments, imageOrderBy); - if (imageCursor.moveToFirst()) { - int id = imageCursor.getInt(imageCursor.getColumnIndex(MediaStore.Images.Media._ID)); - imageCursor.close(); - idVal = id; - } - return "" + idVal; - } - - private void clearMediaDB(String lastId, String capturePath) { - final String[] imageColumns = {MediaStore.Images.Media.DATA, MediaStore.Images.Media.DATE_TAKEN, MediaStore.Images.Media.SIZE, MediaStore.Images.Media._ID}; - final String imageOrderBy = MediaStore.Images.Media._ID + " DESC"; - final String imageWhere = MediaStore.Images.Media._ID + ">?"; - final String[] imageArguments = {lastId}; - Cursor imageCursor = getContext().getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, imageColumns, imageWhere, imageArguments, imageOrderBy); - if (imageCursor.getCount() > 1) { - while (imageCursor.moveToNext()) { - int id = imageCursor.getInt(imageCursor.getColumnIndex(MediaStore.Images.Media._ID)); - String path = imageCursor.getString(imageCursor.getColumnIndex(MediaStore.Images.Media.DATA)); - Long takenTimeStamp = imageCursor.getLong(imageCursor.getColumnIndex(MediaStore.Images.Media.DATE_TAKEN)); - Long size = imageCursor.getLong(imageCursor.getColumnIndex(MediaStore.Images.Media.SIZE)); - if (path.contentEquals(capturePath)) { - // Remove it - ContentResolver cr = getContext().getContentResolver(); - cr.delete(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaStore.Images.Media._ID + "=?", new String[]{Long.toString(id)}); - break; - } - } - } - imageCursor.close(); - } - - - @Override - public boolean isNativePickerTypeSupported(int pickerType) { - if(android.os.Build.VERSION.SDK_INT >= 11) { - return pickerType == Display.PICKER_TYPE_DATE || pickerType == Display.PICKER_TYPE_TIME || pickerType == Display.PICKER_TYPE_STRINGS; - } - return pickerType == Display.PICKER_TYPE_DATE || pickerType == Display.PICKER_TYPE_TIME; - } - - @Override - public Object showNativePicker(final int type, final Component source, final Object currentValue, final Object data) { - if (getActivity() == null) { - return null; - } - final boolean [] canceled = new boolean[1]; - final boolean [] dismissed = new boolean[1]; - - if(editInProgress()) { - stopEditing(true); - } - if(type == Display.PICKER_TYPE_TIME) { - - class TimePick implements TimePickerDialog.OnTimeSetListener, TimePickerDialog.OnCancelListener, Runnable { - int result = ((Integer)currentValue).intValue(); - public void onTimeSet(TimePicker tp, int hour, int minute) { - result = hour * 60 + minute; - dismissed[0] = true; - synchronized(this) { - notify(); - } - } - - public void run() { - while(!dismissed[0]) { - synchronized(this) { - try { - wait(50); - } catch(InterruptedException er) {} - } - } - } - - @Override - public void onCancel(DialogInterface di) { - dismissed[0] = true; - canceled[0] = true; - synchronized (this) { - notify(); - } - } - } - final TimePick pickInstance = new TimePick(); - getActivity().runOnUiThread(new Runnable() { - public void run() { - int hour = ((Integer)currentValue).intValue() / 60; - int minute = ((Integer)currentValue).intValue() % 60; - TimePickerDialog tp = new TimePickerDialog(getActivity(), pickInstance, hour, minute, true){ - - @Override - public void cancel() { - super.cancel(); - dismissed[0] = true; - canceled[0] = true; - } - - @Override - public void dismiss() { - super.dismiss(); - dismissed[0] = true; - } - - }; - tp.setOnCancelListener(pickInstance); - //DateFormat.is24HourFormat(activity)); - tp.show(); - } - }); - Display.getInstance().invokeAndBlock(pickInstance); - if(canceled[0]) { - return null; - } - return new Integer(pickInstance.result); - } - if(type == Display.PICKER_TYPE_DATE) { - final java.util.Calendar cl = java.util.Calendar.getInstance(); - if(currentValue != null) { - cl.setTime((Date)currentValue); - } - class DatePick implements DatePickerDialog.OnDateSetListener,DatePickerDialog.OnCancelListener, Runnable { - Date result = (Date)currentValue; - - public void onDateSet(DatePicker dp, int year, int month, int day) { - java.util.Calendar c = java.util.Calendar.getInstance(); - c.set(java.util.Calendar.YEAR, year); - c.set(java.util.Calendar.MONTH, month); - c.set(java.util.Calendar.DAY_OF_MONTH, day); - result = c.getTime(); - dismissed[0] = true; - synchronized(this) { - notify(); - } - } - - public void run() { - while(!dismissed[0]) { - synchronized(this) { - try { - wait(50); - } catch(InterruptedException er) {} - } - } - } - - public void onCancel(DialogInterface di) { - result = null; - dismissed[0] = true; - canceled[0] = true; - synchronized(this) { - notify(); - } - } - } - final DatePick pickInstance = new DatePick(); - getActivity().runOnUiThread(new Runnable() { - public void run() { - DatePickerDialog tp = new DatePickerDialog(getActivity(), pickInstance, cl.get(java.util.Calendar.YEAR), cl.get(java.util.Calendar.MONTH), cl.get(java.util.Calendar.DAY_OF_MONTH)){ - - @Override - public void cancel() { - super.cancel(); - dismissed[0] = true; - canceled[0] = true; - } - - @Override - public void dismiss() { - super.dismiss(); - dismissed[0] = true; - } - - }; - tp.setOnCancelListener(pickInstance); - tp.show(); - } - }); - Display.getInstance().invokeAndBlock(pickInstance); - return pickInstance.result; - } - if(type == Display.PICKER_TYPE_STRINGS) { - final String[] values = (String[])data; - class StringPick implements Runnable, NumberPicker.OnValueChangeListener { - int result = -1; - - StringPick() { - } - - public void run() { - while(!dismissed[0]) { - synchronized(this) { - try { - wait(50); - } catch(InterruptedException er) {} - } - } - } - - public void cancel() { - dismissed[0] = true; - canceled[0] = true; - synchronized(this) { - notify(); - } - } - - public void ok() { - canceled[0] = false; - dismissed[0] = true; - synchronized(this) { - notify(); - } - } - - @Override - public void onValueChange(NumberPicker np, int oldVal, int newVal) { - result = newVal; - } - } - - final StringPick pickInstance = new StringPick(); - for(int iter = 0 ; iter < values.length ; iter++) { - if(values[iter].equals(currentValue)) { - pickInstance.result = iter; - break; - } - } - if (pickInstance.result == -1 && values.length > 0) { - // The picker will default to showing the first element anyways - // If we don't set the result to 0, then the user has to first - // scroll to a different number, then back to the first option - // to pick the first option. - pickInstance.result = 0; - } - - getActivity().runOnUiThread(new Runnable() { - public void run() { - NumberPicker picker = new NumberPicker(getActivity()); - if(source.getClientProperty("showKeyboard") == null) { - picker.setDescendantFocusability(NumberPicker.FOCUS_BLOCK_DESCENDANTS); - } - picker.setMinValue(0); - picker.setMaxValue(values.length - 1); - picker.setDisplayedValues(values); - picker.setOnValueChangedListener(pickInstance); - if(pickInstance.result > -1) { - picker.setValue(pickInstance.result); - } - RelativeLayout linearLayout = new RelativeLayout(getActivity()); - RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(50, 50); - RelativeLayout.LayoutParams numPicerParams = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT); - numPicerParams.addRule(RelativeLayout.CENTER_HORIZONTAL); - - linearLayout.setLayoutParams(params); - linearLayout.addView(picker,numPicerParams); - - AlertDialog.Builder alertDialogBuilder = new AlertDialog.Builder(getActivity()); - alertDialogBuilder.setView(linearLayout); - alertDialogBuilder - .setCancelable(false) - .setPositiveButton("Ok", - new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, - int id) { - pickInstance.ok(); - } - }) - .setNegativeButton("Cancel", - new DialogInterface.OnClickListener() { - public void onClick(DialogInterface dialog, - int id) { - dialog.cancel(); - pickInstance.cancel(); - } - }); - AlertDialog alertDialog = alertDialogBuilder.create(); - alertDialog.show(); - } - }); - Display.getInstance().invokeAndBlock(pickInstance); - if(canceled[0]) { - return null; - } - if(pickInstance.result < 0) { - return null; - } - return values[pickInstance.result]; - } - return null; - } - - private ServerSockets serverSockets; - private synchronized ServerSockets getServerSockets() { - if (serverSockets == null) { - serverSockets = new ServerSockets(); - } - return serverSockets; - } - - class ServerSockets { - Map socks = new HashMap(); - Map loopbackSocks = new HashMap(); - - public synchronized ServerSocket get(int port) throws IOException { - return get(port, false); - } - - /** - * When loopbackOnly is set the socket binds 127.0.0.1 rather than the wildcard - * address, so the channel isn't published on every network interface. The two - * are cached in SEPARATE maps: a port that is already bound to the wildcard - * address must never be handed back to a caller that asked for loopback. - * Distinguishing them by sign within one map would collide on port 0, the - * ephemeral-port request, where -0 == 0. - * - * The IPv4 loopback is named explicitly rather than taken from - * InetAddress.getLoopbackAddress(), which answers ::1 when the runtime - * prefers IPv6. A client that then connects to 127.0.0.1 - which is what - * adb forward and attaching agents do, and what the iOS port binds - would - * find nothing listening, with the server reporting that it had started. - */ - public synchronized ServerSocket get(int port, boolean loopbackOnly) throws IOException { - Map cache = loopbackOnly ? loopbackSocks : socks; - Integer key = Integer.valueOf(port); - ServerSocket sock = cache.get(key); - if (sock == null || sock.isClosed()) { - sock = loopbackOnly - ? new ServerSocket(port, 50, InetAddress.getByName("127.0.0.1")) - : new ServerSocket(port); - cache.put(key, sock); - } - return sock; - } - - /** - * Closes and forgets the socket, so a thread blocked in accept returns and a - * later listener on this port binds a fresh one rather than sharing this. - */ - public synchronized void close(int port, boolean loopbackOnly) { - Map cache = loopbackOnly ? loopbackSocks : socks; - ServerSocket sock = cache.remove(Integer.valueOf(port)); - if (sock != null) { - try { - sock.close(); - } catch (IOException ignored) { - // best effort: the point is to unblock accept, and a socket that - // cannot be closed is already unusable - } - } - } - - - } - - class SocketImpl { - java.net.Socket socketInstance; - int errorCode = -1; - String errorMessage = null; - InputStream is; - OutputStream os; - - public boolean connect(String param, int param1, int connectTimeout) { - try { - socketInstance = new java.net.Socket(); - socketInstance.connect(new InetSocketAddress(param, param1), connectTimeout); - return true; - } catch(Exception err) { - err.printStackTrace(); - errorMessage = err.toString(); - return false; - } - } - - private InputStream getInput() throws IOException { - if(is == null) { - if(socketInstance != null) { - is = socketInstance.getInputStream(); - } else { - - } - } - return is; - } - - private OutputStream getOutput() throws IOException { - if(os == null) { - os = socketInstance.getOutputStream(); - } - return os; - } - - public int getAvailableInput() { - try { - return getInput().available(); - } catch(IOException err) { - errorMessage = err.toString(); - err.printStackTrace(); - } - return 0; - } - - public String getErrorMessage() { - return errorMessage; - } - - public byte[] readFromStream() { - try { - int av = getAvailableInput(); - if(av > 0) { - byte[] arr = new byte[av]; - int size = getInput().read(arr); - if(size == arr.length) { - return arr; - } - return shrink(arr, size); - } - byte[] arr = new byte[8192]; - int size = getInput().read(arr); - if(size == arr.length) { - return arr; - } - return shrink(arr, size); - } catch(IOException err) { - err.printStackTrace(); - errorMessage = err.toString(); - return null; - } - } - - private byte[] shrink(byte[] arr, int size) { - if(size == -1) { - return null; - } - byte[] n = new byte[size]; - System.arraycopy(arr, 0, n, 0, size); - return n; - } - - public void writeToStream(byte[] param) { - writeToStream(param, 0, param.length); - } - - public void writeToStream(byte[] param, int offset, int len) { - try { - OutputStream os = getOutput(); - os.write(param, offset, len); - os.flush(); - } catch(IOException err) { - errorMessage = err.toString(); - err.printStackTrace(); - } - } - - public void disconnect() { - try { - if(socketInstance != null) { - if(is != null) { - try { - is.close(); - } catch(IOException err) {} - } - if(os != null) { - try { - os.close(); - } catch(IOException err) {} - } - socketInstance.close(); - socketInstance = null; - } - } catch(IOException err) { - errorMessage = err.toString(); - err.printStackTrace(); - } - } - - public Object listen(int param) { - return listen(param, false); - } - - public Object listen(int param, boolean loopbackOnly) { - ServerSocket serverSocketInstance = null; - try { - serverSocketInstance = getServerSockets().get(param, loopbackOnly); - socketInstance = serverSocketInstance.accept(); - SocketImpl si = new SocketImpl(); - si.socketInstance = socketInstance; - return si; - } catch(Exception err) { - errorMessage = err.toString(); - // A closed socket here is the deliberate stop path: stopping a - // listener closes it precisely to bring this accept back. Printing a - // stack trace for that would put an alarming fake failure in the log - // every time a listener is stopped. - if(serverSocketInstance == null || !serverSocketInstance.isClosed()) { - err.printStackTrace(); - } - return null; - } - } - - public boolean isConnected() { - return socketInstance != null; - } - - public int getErrorCode() { - return errorCode; - } - } - - @Override - public Object connectSocket(String host, int port) { - return connectSocket(host, port, 0); - } - - - - @Override - public Object connectSocket(String host, int port, int connectTimeout) { - SocketImpl i = new SocketImpl(); - if(i.connect(host, port, connectTimeout)) { - return i; - } - return null; - } - - @Override - public Object listenSocket(int port) { - return new SocketImpl().listen(port); - } - - @Override - public boolean isLoopbackServerSocketAvailable() { - return true; - } - - @Override - public Object listenSocketLoopback(int port) { - return new SocketImpl().listen(port, true); - } - - @Override - public void stopListeningSocket(int port, boolean loopbackOnly) { - getServerSockets().close(port, loopbackOnly); - } - - /** - * A debuggable package is one built for development: the flag is set by the - * build for a debug variant and cleared for a release variant, so this reads the - * distinction straight off the installed application rather than guessing. - */ - @Override - public boolean isDebuggableBuild() { - Context ctx = getContext(); - if (ctx == null) { - return false; - } - ApplicationInfo info = ctx.getApplicationInfo(); - return info != null && (info.flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0; - } - - @Override - public String getHostOrIP() { - try { - InetAddress i = java.net.InetAddress.getLocalHost(); - if(i.isLoopbackAddress()) { - Enumeration nie = NetworkInterface.getNetworkInterfaces(); - while(nie.hasMoreElements()) { - NetworkInterface current = nie.nextElement(); - if(!current.isLoopback()) { - Enumeration iae = current.getInetAddresses(); - while(iae.hasMoreElements()) { - InetAddress currentI = iae.nextElement(); - if(!currentI.isLoopbackAddress()) { - return currentI.getHostAddress(); - } - } - } - } - } - return i.getHostAddress(); - } catch(Throwable t) { - com.codename1.io.Log.e(t); - return null; - } - } - - @Override - public void disconnectSocket(Object socket) { - ((SocketImpl)socket).disconnect(); - } - - @Override - public boolean isSocketConnected(Object socket) { - return ((SocketImpl)socket).isConnected(); - } - - - - @Override - public boolean isServerSocketAvailable() { - return true; - } - - @Override - public boolean isSocketAvailable() { - return true; - } - - @Override - public String getSocketErrorMessage(Object socket) { - return ((SocketImpl)socket).getErrorMessage(); - } - - @Override - public int getSocketErrorCode(Object socket) { - return ((SocketImpl)socket).getErrorCode(); - } - - @Override - public int getSocketAvailableInput(Object socket) { - return ((SocketImpl)socket).getAvailableInput(); - } - - @Override - public byte[] readFromSocketStream(Object socket) { - return ((SocketImpl)socket).readFromStream(); - } - - @Override - public void writeToSocketStream(Object socket, byte[] data) { - ((SocketImpl)socket).writeToStream(data); - } - - @Override - public boolean isWebSocketSupported() { - return true; - } - - @Override - public com.codename1.impl.WebSocketImpl createWebSocketImpl(String url) { - return new AndroidWebSocketImpl(url); - } - - @Override - public void writeToSocketStream(Object socket, byte[] data, int offset, int len) { - ((SocketImpl)socket).writeToStream(data, offset, len); - } - - //Begin new Graphics Work - @Override - public boolean isShapeSupported(Object graphics) { - return true; - } - - @Override - public boolean isTransformSupported(Object graphics) { - return true; - } - - @Override - public boolean isPerspectiveTransformSupported(Object graphics){ - return android.os.Build.VERSION.SDK_INT >= 14; - } - - @Override - public void fillShape(Object graphics, com.codename1.ui.geom.Shape shape) { - AndroidGraphics ag = (AndroidGraphics)graphics; - Path p = cn1ShapeToAndroidPath(shape); - ag.fillPath(p); - } - - @Override - public void fillShapeShadow(Object graphics, com.codename1.ui.geom.Shape shape, int fillColor, - int fillAlpha, int shadowColor, float shadowOpacity, int blurRadius, int offsetX, int offsetY) { - AndroidGraphics ag = (AndroidGraphics)graphics; - Path p = cn1ShapeToAndroidPath(shape); - ag.fillPathShadow(p, fillColor, fillAlpha, shadowColor, shadowOpacity, blurRadius, offsetX, offsetY); - } - - @Override - public boolean isShapeShadowSupported(Object graphics) { - // Android's Canvas has no cheap GPU shadow for arbitrary shapes: BlurMaskFilter is ignored on - // the hardware canvas, and Paint.setShadowLayer collapses the whole view to software rendering - // (severe jank/ANR). Fall back to the cached-image path; the RAM cost is bounded by keeping the - // number of live shadowed components small (windowed lists) or disabling the per-border cache. - return false; - } - - @Override - public void drawShape(Object graphics, com.codename1.ui.geom.Shape shape, com.codename1.ui.Stroke stroke) { - AndroidGraphics ag = (AndroidGraphics)graphics; - Path p = cn1ShapeToAndroidPath(shape); - ag.drawPath(p, stroke); - - } - - @Override - public void drawShadow(Object graphics, Object image, int x, int y, int offsetX, int offsetY, int blurRadius, int spreadRadius, int color, float opacity) { - AndroidGraphics ag = (AndroidGraphics)graphics; - - ag.drawShadow(image, x, y, offsetX, offsetY, blurRadius, spreadRadius, color, opacity); - } - - @Override - public boolean isDrawShadowSupported() { - return true; - } - - @Override - public boolean isDrawShadowFast() { - return false; - } - // BEGIN TRANSFORMATION METHODS--------------------------------------------------------- - - - - @Override - public boolean transformEqualsImpl(Transform t1, Transform t2) { - Object o1 = null; - if(t1 != null) { - o1 = t1.getNativeTransform(); - } - Object o2 = null; - if(t2 != null) { - o2 = t2.getNativeTransform(); - } - return transformNativeEqualsImpl(o1, o2); - } - - @Override - public boolean transformNativeEqualsImpl(Object t1, Object t2) { - if ( t1 != null ){ - CN1Matrix4f m1 = (CN1Matrix4f)t1; - CN1Matrix4f m2 = (CN1Matrix4f)t2; - return m1.equals(m2); - } else { - return t2 == null; - } - } - - - @Override - public boolean isTransformSupported() { - return true; - } - - @Override - public boolean isPerspectiveTransformSupported() { - - return true; - } - - @Override - public Object makeTransformAffine(double m00, double m10, double m01, double m11, double m02, double m12) { - CN1Matrix4f t = CN1Matrix4f.make(new float[]{ - (float)m00, (float)m10, 0, 0, - (float)m01, (float)m11, 0, 0, - 0, 0, 1, 0, - (float)m02, (float)m12, 0, 1 - }); - return t; - } - - @Override - public void setTransformAffine(Object nativeTransform, double m00, double m10, double m01, double m11, double m02, double m12) { - ((CN1Matrix4f)nativeTransform).setData(new float[]{ - (float)m00, (float)m10, 0, 0, - (float)m01, (float)m11, 0, 0, - 0, 0, 1, 0, - (float)m02, (float)m12, 0, 1 - }); - } - - - @Override - public Object makeTransformTranslation(float translateX, float translateY, float translateZ) { - return CN1Matrix4f.makeTranslation(translateX, translateY, translateZ); - } - - @Override - public void setTransformTranslation(Object nativeTransform, float translateX, float translateY, float translateZ) { - CN1Matrix4f m = (CN1Matrix4f)nativeTransform; - m.reset(); - m.translate(translateX, translateY, translateZ); - } - - @Override - public Object makeTransformScale(float scaleX, float scaleY, float scaleZ) { - CN1Matrix4f t = CN1Matrix4f.makeIdentity(); - t.scale(scaleX, scaleY, scaleZ); - return t; - } - - @Override - public void setTransformScale(Object nativeTransform, float scaleX, float scaleY, float scaleZ) { - CN1Matrix4f t = (CN1Matrix4f)nativeTransform; - t.reset(); - t.scale(scaleX, scaleY, scaleZ); - } - - @Override - public Object makeTransformRotation(float angle, float x, float y, float z) { - return CN1Matrix4f.makeRotation(angle, x, y, z); - } - - @Override - public void setTransformRotation(Object nativeTransform, float angle, float x, float y, float z) { - CN1Matrix4f m = (CN1Matrix4f)nativeTransform; - m.reset(); - m.rotate(angle, x, y, z); - } - - @Override - public Object makeTransformPerspective(float fovy, float aspect, float zNear, float zFar) { - return CN1Matrix4f.makePerspective(fovy, aspect, zNear, zFar); - } - - @Override - public void setTransformPerspective(Object nativeGraphics, float fovy, float aspect, float zNear, float zFar) { - CN1Matrix4f m = (CN1Matrix4f)nativeGraphics; - m.setPerspective(fovy, aspect, zNear, zFar); - } - - @Override - public Object makeTransformOrtho(float left, float right, float bottom, float top, float near, float far) { - return CN1Matrix4f.makeOrtho(left, right, bottom, top, near, far); - } - - @Override - public void setTransformOrtho(Object nativeGraphics, float left, float right, float bottom, float top, float near, float far) { - CN1Matrix4f m = (CN1Matrix4f)nativeGraphics; - m.setOrtho(left, right, bottom, top, near, far); - } - - @Override - public Object makeTransformCamera(float eyeX, float eyeY, float eyeZ, float centerX, float centerY, float centerZ, float upX, float upY, float upZ) { - return CN1Matrix4f.makeCamera(eyeX, eyeY, eyeZ, centerX, centerY, centerZ, upX, upY, upZ); - } - - @Override - public void setTransformCamera(Object nativeGraphics, float eyeX, float eyeY, float eyeZ, float centerX, float centerY, float centerZ, float upX, float upY, float upZ) { - CN1Matrix4f m = (CN1Matrix4f)nativeGraphics; - m.setCamera(eyeX, eyeY, eyeZ, centerX, centerY, centerZ, upX, upY, upZ); - } - - - @Override - public void transformRotate(Object nativeTransform, float angle, float x, float y, float z) { - ((CN1Matrix4f)nativeTransform).rotate(angle, x, y, z); - } - - @Override - public void transformTranslate(Object nativeTransform, float x, float y, float z) { - //((Matrix) nativeTransform).preTranslate(x, y); - ((CN1Matrix4f)nativeTransform).translate(x, y, z); - } - - @Override - public void transformScale(Object nativeTransform, float x, float y, float z) { - //((Matrix) nativeTransform).preScale(x, y); - ((CN1Matrix4f)nativeTransform).scale(x, y, z); - } - - @Override - public Object makeTransformInverse(Object nativeTransform) { - - CN1Matrix4f inverted = CN1Matrix4f.makeIdentity(); - inverted.setData(((CN1Matrix4f)nativeTransform).getData()); - if( inverted.invert()){ - return inverted; - } - return null; - - //Matrix inverted = new Matrix(); - //if(((Matrix) nativeTransform).invert(inverted)){ - // return inverted; - //} - //return null; - } - - @Override - public void setTransformInverse(Object nativeTransform) throws com.codename1.ui.Transform.NotInvertibleException { - - CN1Matrix4f m = (CN1Matrix4f)nativeTransform; - if (!m.invert()) { - throw new com.codename1.ui.Transform.NotInvertibleException(); - } - } - - @Override - public void setTransformIdentity(Object transform) { - CN1Matrix4f m = (CN1Matrix4f)transform; - m.setIdentity(); - } - - @Override - public Object makeTransformIdentity() { - return CN1Matrix4f.makeIdentity(); - } - - @Override - public void copyTransform(Object src, Object dest) { - CN1Matrix4f t1 = (CN1Matrix4f) src; - CN1Matrix4f t2 = (CN1Matrix4f) dest; - t2.setData(t1.getData()); - } - - @Override - public void concatenateTransform(Object t1, Object t2) { - //((Matrix) t1).preConcat((Matrix) t2); - ((CN1Matrix4f)t1).concatenate((CN1Matrix4f)t2); - } - - @Override - public void transformPoint(Object nativeTransform, float[] in, float[] out) { - //Matrix t = (Matrix) nativeTransform; - //t.mapPoints(in, 0, out, 0, 2); - ((CN1Matrix4f)nativeTransform).transformCoord(in, out); - } - - @Override - public void setTransform(Object graphics, Transform transform) { - AndroidGraphics ag = (AndroidGraphics) graphics; - Transform existing = ag.getTransform(); - if (existing == null) { - existing = transform == null ? Transform.makeIdentity() : transform.copy(); - ag.setTransform(existing); - } else { - if (transform == null) { - existing.setIdentity(); - } else { - existing.setTransform(transform); - } - ag.setTransform(existing); // sets dirty flag for transform - } - - } - - @Override - public com.codename1.ui.Transform getTransform(Object graphics) { - com.codename1.ui.Transform t = ((AndroidGraphics) graphics).getTransform(); - if (t == null) { - return Transform.makeIdentity(); - } - Transform t2 = Transform.makeIdentity(); - t2.setTransform(t); - return t2; - } - - @Override - public void getTransform(Object graphics, Transform transform) { - com.codename1.ui.Transform t = ((AndroidGraphics) graphics).getTransform(); - if (t == null) { - transform.setIdentity(); - } else { - transform.setTransform(t); - } - } - - - // END TRANSFORM STUFF - - - static Path cn1ShapeToAndroidPath(com.codename1.ui.geom.Shape shape, Path p) { - //Path p = new Path(); - p.rewind(); - - com.codename1.ui.geom.PathIterator it = shape.getPathIterator(); - switch (it.getWindingRule()) { - case GeneralPath.WIND_EVEN_ODD: - p.setFillType(Path.FillType.EVEN_ODD); - break; - case GeneralPath.WIND_NON_ZERO: - p.setFillType(Path.FillType.WINDING); - break; - } - //p.setWindingRule(it.getWindingRule() == com.codename1.ui.geom.PathIterator.WIND_EVEN_ODD ? GeneralPath.WIND_EVEN_ODD : GeneralPath.WIND_NON_ZERO); - float[] buf = new float[6]; - while (!it.isDone()) { - int type = it.currentSegment(buf); - switch (type) { - case com.codename1.ui.geom.PathIterator.SEG_MOVETO: - p.moveTo(buf[0], buf[1]); - break; - case com.codename1.ui.geom.PathIterator.SEG_LINETO: - p.lineTo(buf[0], buf[1]); - break; - case com.codename1.ui.geom.PathIterator.SEG_QUADTO: - p.quadTo(buf[0], buf[1], buf[2], buf[3]); - break; - case com.codename1.ui.geom.PathIterator.SEG_CUBICTO: - p.cubicTo(buf[0], buf[1], buf[2], buf[3], buf[4], buf[5]); - break; - case com.codename1.ui.geom.PathIterator.SEG_CLOSE: - p.close(); - break; - - } - it.next(); - } - - return p; - } - - static Path cn1ShapeToAndroidPath(com.codename1.ui.geom.Shape shape) { - return cn1ShapeToAndroidPath(shape, new Path()); - } - - /** - * The ID used for a local notification that should actually trigger a background - * fetch. This type of notification is handled specially by the {@link LocalNotificationPublisher}. It - * doesn't display a notification to the user, but instead just calls the {@link #performBackgroundFetch() } - * method. - */ - static final String BACKGROUND_FETCH_NOTIFICATION_ID="$$$CN1_BACKGROUND_FETCH$$$"; - - - /** - * Calls the background fetch callback. If the app is in teh background, this will - * check to see if the lifecycle class implements the {@link com.codename1.background.BackgroundFetch} - * interface. If it does, it will execute its {@link com.codename1.background.BackgroundFetch#performBackgroundFetch(long, com.codename1.util.Callback) } - * method. - * @param blocking True if this should block until it is complete. - */ - public static void performBackgroundFetch(boolean blocking) { - - if (Display.getInstance().isMinimized()) { - // By definition, background fetch should only occur if the app is minimized. - // This keeps it consistent with the iOS implementation that doesn't have a - // choice - final boolean[] complete = new boolean[1]; - final Object lock = new Object(); - final BackgroundFetch bgFetchListener = instance.getBackgroundFetchListener(); - final long timeout = System.currentTimeMillis()+25000; - if (bgFetchListener != null) { - Display.getInstance().callSerially(new Runnable() { - public void run() { - bgFetchListener.performBackgroundFetch(timeout, new Callback() { - - @Override - public void onSucess(Boolean value) { - // On Android the OS doesn't care whether it worked or not - // So we'll just consume this. - synchronized (lock) { - complete[0] = true; - lock.notify(); - } - } - - @Override - public void onError(Object sender, Throwable err, int errorCode, String errorMessage) { - com.codename1.io.Log.e(err); - synchronized (lock) { - complete[0] = true; - lock.notify(); - } - } - - }); - } - }); - - } - - while (blocking && !complete[0]) { - Util.wait(lock, 1000); - if (!complete[0]) { - System.out.println("Waiting for background fetch to complete. Make sure your background fetch handler calls onSuccess() or onError() in the callback when complete"); - - } - if (System.currentTimeMillis() > timeout) { - System.out.println("Background fetch exceeded time alotted. Not waiting for its completion"); - break; - } - - } - - - } - } - - /** - * Starts the background fetch service. - */ - public void startBackgroundFetchService() { - LocalNotification n = new LocalNotification(); - n.setId(BACKGROUND_FETCH_NOTIFICATION_ID); - cancelLocalNotification(BACKGROUND_FETCH_NOTIFICATION_ID); - // We schedule a local notification - // First callback will be at the repeat interval - // We don't specify a repeat interval because the scheduleLocalNotification will - // set that for us using the getPreferredBackgroundFetchInterval method. - scheduleLocalNotification(n, System.currentTimeMillis() + getPreferredBackgroundFetchInterval() * 1000, 0); - } - - public void stopBackgroundFetchService() { - cancelLocalNotification(BACKGROUND_FETCH_NOTIFICATION_ID); - } - - - private boolean backgroundFetchInitialized; - - @Override - public void setPreferredBackgroundFetchInterval(int seconds) { - int oldInterval = getPreferredBackgroundFetchInterval(); - super.setPreferredBackgroundFetchInterval(seconds); - - if (!backgroundFetchInitialized || oldInterval != seconds) { - backgroundFetchInitialized = true; - if (seconds > 0) { - startBackgroundFetchService(); - } else { - stopBackgroundFetchService(); - } - } - } - - - - @Override - public boolean isBackgroundFetchSupported() { - return true; - } - public static BackgroundFetch backgroundFetchListener; - - BackgroundFetch getBackgroundFetchListener() { - if (getActivity() != null && getActivity().getApp() instanceof BackgroundFetch) { - return (BackgroundFetch)getActivity().getApp(); - } else if (backgroundFetchListener != null) { - return backgroundFetchListener; - } else { - return null; - } - } - - /** - * Returns the fully qualified class name of the app's background fetch listener, or null - * when the app does not implement {@link com.codename1.background.BackgroundFetch}. The - * surfaces plumbing persists this name on publish so a home screen widget that rendered an - * exhausted timeline can start {@link BackgroundFetchHandler} and let the app republish - * fresh content while no activity exists. - * - * @return the listener class name or null - */ - public static String getBackgroundFetchListenerClassName() { - if (instance == null) { - return null; - } - BackgroundFetch listener = instance.getBackgroundFetchListener(); - return listener == null ? null : listener.getClass().getName(); - } - - public void scheduleLocalNotification(LocalNotification notif, long firstTime, int repeat) { - if (android.os.Build.VERSION.SDK_INT >= 33) { - if(!checkForPermission("android.permission.POST_NOTIFICATIONS", "This is required to receive notifications")){ - com.codename1.io.Log.e(new RuntimeException("Local notification was prevented the POST_NOTIFICATIONS permission was not granted by the user.")); - return; - } - } - final Intent notificationIntent = new Intent(getContext(), LocalNotificationPublisher.class); - notificationIntent.setAction(getContext().getApplicationInfo().packageName + "." + notif.getId()); - notificationIntent.putExtra(LocalNotificationPublisher.NOTIFICATION, createBundleFromNotification(notif)); - - Intent contentIntent = new Intent(); - if (activityComponentName != null) { - contentIntent.setComponent(activityComponentName); - } else { - try { - contentIntent.setComponent(getContext().getPackageManager().getLaunchIntentForPackage(getContext().getApplicationInfo().packageName).getComponent()); - } catch (Exception ex) { - System.err.println("Failed to get the component name for local notification. Local notification may not work."); - ex.printStackTrace(); - } - } - contentIntent.putExtra("LocalNotificationID", notif.getId()); - - if (BACKGROUND_FETCH_NOTIFICATION_ID.equals(notif.getId()) && getBackgroundFetchListener() != null) { - Context context = AndroidNativeUtil.getContext(); - - Intent intent = new Intent(context, BackgroundFetchHandler.class); - //there is an bug that causes this to not to workhttps://code.google.com/p/android/issues/detail?id=81812 - //intent.putExtra("backgroundClass", getBackgroundLocationListener().getName()); - //an ugly workaround to the putExtra bug - intent.setData(Uri.parse("http://codenameone.com/a?" + getBackgroundFetchListener().getClass().getName())); - PendingIntent pendingIntent = getPendingIntent(context, 0, - intent); - notificationIntent.putExtra(LocalNotificationPublisher.BACKGROUND_FETCH_INTENT, pendingIntent); - - } else { - contentIntent.setData(Uri.parse("http://codenameone.com/a?LocalNotificationID="+Uri.encode(notif.getId()))); - } - PendingIntent pendingContentIntent = createPendingIntent(getContext(), 0, contentIntent); - - notificationIntent.putExtra(LocalNotificationPublisher.NOTIFICATION_INTENT, pendingContentIntent); - // carry the configured content intent as a template so the publisher can build - // a distinct per-action PendingIntent (with the action id and any remote input) - if (!notif.getActions().isEmpty()) { - notificationIntent.putExtra(LocalNotificationPublisher.NOTIFICATION_CONTENT_TEMPLATE, contentIntent); - } - - - PendingIntent pendingIntent = getBroadcastPendingIntent(getContext(), 0, notificationIntent); - - AlarmManager alarmManager = (AlarmManager) getContext().getSystemService(Context.ALARM_SERVICE); - if (BACKGROUND_FETCH_NOTIFICATION_ID.equals(notif.getId())) { - alarmManager.setRepeating(AlarmManager.RTC_WAKEUP, firstTime, getPreferredBackgroundFetchInterval() * 1000, pendingIntent); - } else { - if(repeat == LocalNotification.REPEAT_NONE){ - alarmManager.set(AlarmManager.RTC_WAKEUP, firstTime, pendingIntent); - - }else if(repeat == LocalNotification.REPEAT_MINUTE){ - - alarmManager.setRepeating(AlarmManager.RTC_WAKEUP, firstTime, 60*1000, pendingIntent); - - }else if(repeat == LocalNotification.REPEAT_HOUR){ - - alarmManager.setInexactRepeating(AlarmManager.RTC_WAKEUP, firstTime, AlarmManager.INTERVAL_HALF_HOUR, pendingIntent); - - }else if(repeat == LocalNotification.REPEAT_DAY){ - - alarmManager.setInexactRepeating(AlarmManager.RTC_WAKEUP, firstTime, AlarmManager.INTERVAL_DAY, pendingIntent); - - }else if(repeat == LocalNotification.REPEAT_WEEK){ - - alarmManager.setRepeating(AlarmManager.RTC_WAKEUP, firstTime, AlarmManager.INTERVAL_DAY * 7, pendingIntent); - - } - } - } - - public void cancelLocalNotification(String notificationId) { - Intent notificationIntent = new Intent(getContext(), LocalNotificationPublisher.class); - notificationIntent.setAction(getContext().getApplicationInfo().packageName + "." + notificationId); - - PendingIntent pendingIntent = getBroadcastPendingIntent(getContext(), 0, notificationIntent); - AlarmManager alarmManager = (AlarmManager) getContext().getSystemService(Context.ALARM_SERVICE); - alarmManager.cancel(pendingIntent); - } - - static Bundle createBundleFromNotification(LocalNotification notif){ - Bundle b = new Bundle(); - b.putString("NOTIF_ID", notif.getId()); - b.putString("NOTIF_TITLE", notif.getAlertTitle()); - b.putString("NOTIF_BODY", notif.getAlertBody()); - b.putString("NOTIF_SOUND", notif.getAlertSound()); - b.putString("NOTIF_IMAGE", notif.getAlertImage()); - b.putInt("NOTIF_NUMBER", notif.getBadgeNumber()); - b.putString("NOTIF_CHANNEL", notif.getChannelId()); - b.putString("NOTIF_GROUP", notif.getGroupId()); - b.putBoolean("NOTIF_GROUP_SUMMARY", notif.isGroupSummary()); - b.putBoolean("NOTIF_FULLSCREEN", notif.isFullScreenIntent()); - b.putBoolean("NOTIF_TIME_SENSITIVE", notif.isTimeSensitive()); - b.putBoolean("NOTIF_ONGOING", notif.isOngoing()); - b.putInt("NOTIF_PROGRESS_MAX", notif.getProgressMax()); - b.putInt("NOTIF_PROGRESS", notif.getProgress()); - b.putBoolean("NOTIF_PROGRESS_INDETERMINATE", notif.isProgressIndeterminate()); - b.putString("NOTIF_CUSTOM_VIEW", notif.getCustomView()); - java.util.List actions = notif.getActions(); - if (!actions.isEmpty()) { - ArrayList ids = new ArrayList(); - ArrayList titles = new ArrayList(); - ArrayList icons = new ArrayList(); - ArrayList placeholders = new ArrayList(); - ArrayList buttons = new ArrayList(); - for (LocalNotification.Action a : actions) { - ids.add(a.getId()); - titles.add(a.getTitle() == null ? "" : a.getTitle()); - icons.add(a.getIcon() == null ? "" : a.getIcon()); - placeholders.add(a.getTextInputPlaceholder() == null ? "" : a.getTextInputPlaceholder()); - buttons.add(a.getTextInputButtonText() == null ? "" : a.getTextInputButtonText()); - } - b.putStringArrayList("NOTIF_ACTION_IDS", ids); - b.putStringArrayList("NOTIF_ACTION_TITLES", titles); - b.putStringArrayList("NOTIF_ACTION_ICONS", icons); - b.putStringArrayList("NOTIF_ACTION_PLACEHOLDERS", placeholders); - b.putStringArrayList("NOTIF_ACTION_BUTTONS", buttons); - } - LocalNotification.MessagingStyle ms = notif.getMessagingStyle(); - if (ms != null) { - b.putString("NOTIF_MSG_SELF", ms.getSelfDisplayName()); - b.putString("NOTIF_MSG_TITLE", ms.getConversationTitle()); - b.putBoolean("NOTIF_MSG_GROUP", ms.isGroupConversation()); - ArrayList texts = new ArrayList(); - ArrayList senders = new ArrayList(); - long[] times = new long[ms.getMessages().size()]; - int i = 0; - for (LocalNotification.MessagingStyle.Message m : ms.getMessages()) { - texts.add(m.getText() == null ? "" : m.getText()); - senders.add(m.getSenderName() == null ? "" : m.getSenderName()); - times[i++] = m.getTimestamp(); - } - b.putStringArrayList("NOTIF_MSG_TEXTS", texts); - b.putStringArrayList("NOTIF_MSG_SENDERS", senders); - b.putLongArray("NOTIF_MSG_TIMES", times); - } - return b; - } - - static LocalNotification createNotificationFromBundle(Bundle b){ - LocalNotification n = new LocalNotification(); - n.setId(b.getString("NOTIF_ID")); - n.setAlertTitle(b.getString("NOTIF_TITLE")); - n.setAlertBody(b.getString("NOTIF_BODY")); - n.setAlertSound(b.getString("NOTIF_SOUND")); - n.setAlertImage(b.getString("NOTIF_IMAGE")); - n.setBadgeNumber(b.getInt("NOTIF_NUMBER")); - // new fields are guarded so bundles serialized by older builds still parse - if (b.containsKey("NOTIF_CHANNEL")) { - n.setChannelId(b.getString("NOTIF_CHANNEL")); - } - if (b.containsKey("NOTIF_GROUP")) { - n.setGroup(b.getString("NOTIF_GROUP")); - } - n.setGroupSummary(b.getBoolean("NOTIF_GROUP_SUMMARY", false)); - n.setFullScreenIntent(b.getBoolean("NOTIF_FULLSCREEN", false)); - n.setTimeSensitive(b.getBoolean("NOTIF_TIME_SENSITIVE", false)); - n.setOngoing(b.getBoolean("NOTIF_ONGOING", false)); - int progressMax = b.getInt("NOTIF_PROGRESS_MAX", 0); - if (progressMax > 0) { - n.setProgress(progressMax, b.getInt("NOTIF_PROGRESS", 0)); - } - n.setIndeterminateProgress(b.getBoolean("NOTIF_PROGRESS_INDETERMINATE", false)); - if (b.containsKey("NOTIF_CUSTOM_VIEW")) { - n.setCustomView(b.getString("NOTIF_CUSTOM_VIEW")); - } - ArrayList ids = b.getStringArrayList("NOTIF_ACTION_IDS"); - if (ids != null) { - ArrayList titles = b.getStringArrayList("NOTIF_ACTION_TITLES"); - ArrayList icons = b.getStringArrayList("NOTIF_ACTION_ICONS"); - ArrayList placeholders = b.getStringArrayList("NOTIF_ACTION_PLACEHOLDERS"); - ArrayList buttons = b.getStringArrayList("NOTIF_ACTION_BUTTONS"); - for (int i = 0; i < ids.size(); i++) { - String placeholder = placeholders != null ? emptyToNull(placeholders.get(i)) : null; - String button = buttons != null ? emptyToNull(buttons.get(i)) : null; - if (placeholder != null || button != null) { - n.addInputAction(ids.get(i), titles.get(i), placeholder, button); - } else { - String icon = icons != null ? emptyToNull(icons.get(i)) : null; - n.addAction(new LocalNotification.Action(ids.get(i), titles.get(i), icon)); - } - } - } - if (b.containsKey("NOTIF_MSG_SELF")) { - LocalNotification.MessagingStyle ms = n.asMessagingStyle(b.getString("NOTIF_MSG_SELF")); - ms.conversationTitle(b.getString("NOTIF_MSG_TITLE")); - ms.groupConversation(b.getBoolean("NOTIF_MSG_GROUP", false)); - ArrayList texts = b.getStringArrayList("NOTIF_MSG_TEXTS"); - ArrayList senders = b.getStringArrayList("NOTIF_MSG_SENDERS"); - long[] times = b.getLongArray("NOTIF_MSG_TIMES"); - if (texts != null) { - for (int i = 0; i < texts.size(); i++) { - ms.addMessage(texts.get(i), - times != null && i < times.length ? times[i] : 0, - senders != null ? emptyToNull(senders.get(i)) : null); - } - } - } - return n; - } - - private static String emptyToNull(String s) { - return s == null || s.length() == 0 ? null : s; - } - - @Override - public void requestNotificationPermission(final NotificationPermissionRequest request, final NotificationPermissionCallback callback) { - if (callback == null) { - return; - } - final boolean granted; - if (android.os.Build.VERSION.SDK_INT >= 33) { - granted = checkForPermission("android.permission.POST_NOTIFICATIONS", "This is required to receive notifications", true); - } else { - // notifications are allowed by default below Android 13 - granted = true; - } - Display.getInstance().callSerially(new Runnable() { - public void run() { - callback.notificationPermissionResult(new NotificationPermissionResult(granted - ? NotificationPermissionResult.AuthorizationLevel.AUTHORIZED - : NotificationPermissionResult.AuthorizationLevel.DENIED)); - } - }); - } - - @Override - public void registerNotificationChannel(NotificationChannelBuilder builder) { - if (builder == null || android.os.Build.VERSION.SDK_INT < 26) { - return; - } - try { - NotificationManager nm = (NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE); - Class clsChannel = Class.forName("android.app.NotificationChannel"); - Constructor ctor = clsChannel.getConstructor(String.class, CharSequence.class, int.class); - // map our 0..5 importance onto the platform IMPORTANCE_* (NONE=0 .. MAX=5) - Object channel = ctor.newInstance(builder.getId(), builder.getName(), builder.getImportance()); - if (builder.getDescription() != null) { - clsChannel.getMethod("setDescription", String.class).invoke(channel, builder.getDescription()); - } - clsChannel.getMethod("enableLights", boolean.class).invoke(channel, builder.isLightsEnabled()); - if (builder.isLightsEnabled()) { - clsChannel.getMethod("setLightColor", int.class).invoke(channel, builder.getLightColor()); - } - clsChannel.getMethod("enableVibration", boolean.class).invoke(channel, builder.isVibrationEnabled()); - if (builder.getVibrationPattern() != null) { - clsChannel.getMethod("setVibrationPattern", long[].class).invoke(channel, (Object) builder.getVibrationPattern()); - } - clsChannel.getMethod("setLockscreenVisibility", int.class).invoke(channel, builder.getLockscreenVisibility()); - clsChannel.getMethod("setShowBadge", boolean.class).invoke(channel, builder.isShowBadge()); - if (builder.getGroup() != null) { - clsChannel.getMethod("setGroup", String.class).invoke(channel, builder.getGroup()); - } - String sound = builder.getSound(); - if (sound != null && sound.length() > 0) { - sound = sound.toLowerCase(); - Uri uri = Uri.parse("android.resource://" + getContext().getApplicationInfo().packageName + "/raw" - + sound.substring(0, sound.indexOf("."))); - android.media.AudioAttributes attrs = new android.media.AudioAttributes.Builder() - .setContentType(android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION) - .setUsage(android.media.AudioAttributes.USAGE_NOTIFICATION) - .build(); - clsChannel.getMethod("setSound", Uri.class, android.media.AudioAttributes.class).invoke(channel, uri, attrs); - } - nm.getClass().getMethod("createNotificationChannel", clsChannel).invoke(nm, channel); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - } - - @Override - public void deleteNotificationChannel(String channelId) { - if (channelId == null || android.os.Build.VERSION.SDK_INT < 26) { - return; - } - try { - NotificationManager nm = (NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE); - nm.getClass().getMethod("deleteNotificationChannel", String.class).invoke(nm, channelId); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - } - - @Override - public void createNotificationChannelGroup(String groupId, String groupName) { - if (groupId == null || android.os.Build.VERSION.SDK_INT < 26) { - return; - } - try { - NotificationManager nm = (NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE); - Class clsGroup = Class.forName("android.app.NotificationChannelGroup"); - Constructor ctor = clsGroup.getConstructor(String.class, CharSequence.class); - Object group = ctor.newInstance(groupId, groupName); - nm.getClass().getMethod("createNotificationChannelGroup", clsGroup).invoke(nm, group); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - } - - @Override - public void subscribeToPushTopic(final String topic) { - invokeFirebaseTopic("subscribeToTopic", topic); - } - - @Override - public void unsubscribeFromPushTopic(final String topic) { - invokeFirebaseTopic("unsubscribeFromTopic", 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); - } - } - - @Override - public boolean isReceiveSharedContentSupported() { - return true; - } - - private static SharedContent pendingSharedContent; - - /// Delivers shared content received from another app. If the CN1 app instance is - /// running it is dispatched immediately on the EDT; otherwise it is held until the app - /// finishes starting and `#deliverPendingSharedContent()` is invoked. - static void deliverSharedContent(SharedContent content) { - if (content == null) { - return; - } - Object app = CodenameOneImplementation.getCurrentApplicationInstance(); - if (app != null && Display.isInitialized()) { - dispatchSharedContent(app, content); - } else { - pendingSharedContent = content; - } - } - - /// Invoked once the app has started to flush any shared content that arrived before the - /// app instance existed. - public static void deliverPendingSharedContent() { - SharedContent c = pendingSharedContent; - pendingSharedContent = null; - Object app = CodenameOneImplementation.getCurrentApplicationInstance(); - if (c != null && app != null) { - dispatchSharedContent(app, c); - } - } - - private static void dispatchSharedContent(final Object app, final SharedContent content) { - if (!(app instanceof com.codename1.system.Lifecycle)) { - return; - } - Display.getInstance().callSerially(new Runnable() { - public void run() { - ((com.codename1.system.Lifecycle) app).onReceivedSharedContent(content); - } - }); - } - - // ---- Constraint-aware background work (JobScheduler) ---- - - @Override - public boolean isBackgroundWorkSupported() { - return android.os.Build.VERSION.SDK_INT >= 21; - } - - private static int jobIdFor(String id) { - return (id.hashCode() & 0x7fffffff) % 1000000 + 1000; - } - - @Override - public void scheduleBackgroundWork(WorkRequest request) { - if (android.os.Build.VERSION.SDK_INT < 21) { - return; - } - try { - android.app.job.JobScheduler scheduler = - (android.app.job.JobScheduler) getContext().getSystemService(Context.JOB_SCHEDULER_SERVICE); - android.content.ComponentName component = - new android.content.ComponentName(getContext(), CodenameOneJobService.class); - android.app.job.JobInfo.Builder builder = - new android.app.job.JobInfo.Builder(jobIdFor(request.getId()), component); - - if (request.isRequiresUnmeteredNetwork()) { - builder.setRequiredNetworkType(android.app.job.JobInfo.NETWORK_TYPE_UNMETERED); - } else if (request.isRequiresNetwork()) { - builder.setRequiredNetworkType(android.app.job.JobInfo.NETWORK_TYPE_ANY); - } - builder.setRequiresCharging(request.isRequiresCharging()); - if (android.os.Build.VERSION.SDK_INT >= 23) { - builder.setRequiresDeviceIdle(request.isRequiresIdle()); - } - if (android.os.Build.VERSION.SDK_INT >= 26) { - builder.setRequiresBatteryNotLow(request.isRequiresBatteryNotLow()); - } - if (request.isPeriodic()) { - builder.setPeriodic(Math.max(15 * 60 * 1000L, request.getMinIntervalMillis())); - } else { - if (request.getInitialDelayMillis() > 0) { - builder.setMinimumLatency(request.getInitialDelayMillis()); - } - builder.setOverrideDeadline(Math.max(request.getInitialDelayMillis(), 0) + 60 * 60 * 1000L); - } - - PersistableBundle extras = new PersistableBundle(); - extras.putString(CodenameOneJobService.EXTRA_WORKER_CLASS, request.getWorkerClass()); - extras.putString(CodenameOneJobService.EXTRA_WORK_ID, request.getId()); - for (java.util.Map.Entry e : request.getInputData().entrySet()) { - extras.putString(CodenameOneJobService.INPUT_PREFIX + e.getKey(), e.getValue()); - } - builder.setExtras(extras); - scheduler.schedule(builder.build()); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - } - - @Override - public void cancelBackgroundWork(String workId) { - if (android.os.Build.VERSION.SDK_INT < 21) { - return; - } - try { - android.app.job.JobScheduler scheduler = - (android.app.job.JobScheduler) getContext().getSystemService(Context.JOB_SCHEDULER_SERVICE); - scheduler.cancel(jobIdFor(workId)); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - } - - @Override - public boolean isBackgroundProcessingSupported() { - return android.os.Build.VERSION.SDK_INT >= 21; - } - - @Override - public void scheduleBackgroundProcessing(String id, long earliestBeginEpochMs, boolean requiresNetwork, boolean requiresPower, Runnable task) { - if (android.os.Build.VERSION.SDK_INT < 21 || task == null) { - return; - } - try { - CodenameOneJobService.registerProcessingRunnable(id, task); - android.app.job.JobScheduler scheduler = - (android.app.job.JobScheduler) getContext().getSystemService(Context.JOB_SCHEDULER_SERVICE); - android.content.ComponentName component = - new android.content.ComponentName(getContext(), CodenameOneJobService.class); - android.app.job.JobInfo.Builder builder = - new android.app.job.JobInfo.Builder(jobIdFor("proc-" + id), component); - if (requiresNetwork) { - builder.setRequiredNetworkType(android.app.job.JobInfo.NETWORK_TYPE_ANY); - } - builder.setRequiresCharging(requiresPower); - long delay = earliestBeginEpochMs <= 0 ? 0 : Math.max(0, earliestBeginEpochMs - System.currentTimeMillis()); - if (delay > 0) { - builder.setMinimumLatency(delay); - } - builder.setOverrideDeadline(delay + 60 * 60 * 1000L); - PersistableBundle extras = new PersistableBundle(); - extras.putString(CodenameOneJobService.EXTRA_PROCESSING_ID, id); - builder.setExtras(extras); - scheduler.schedule(builder.build()); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - } - - @Override - public void cancelBackgroundProcessing(String id) { - CodenameOneJobService.unregisterProcessingRunnable(id); - if (android.os.Build.VERSION.SDK_INT < 21) { - return; - } - try { - android.app.job.JobScheduler scheduler = - (android.app.job.JobScheduler) getContext().getSystemService(Context.JOB_SCHEDULER_SERVICE); - scheduler.cancel(jobIdFor("proc-" + id)); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - } - - // ---- Foreground service ---- - - @Override - public boolean isForegroundServiceSupported() { - return true; - } - - @Override - public Object startForegroundService(String channelId, String title, String body, String iconName, ForegroundService.Task task, ForegroundService handle) { - int token = CodenameOneForegroundService.registerTask(task, handle, channelId, title, body, iconName); - try { - Intent intent = new Intent(getContext(), CodenameOneForegroundService.class); - intent.setAction(CodenameOneForegroundService.ACTION_START); - intent.putExtra(CodenameOneForegroundService.EXTRA_TOKEN, token); - intent.putExtra(CodenameOneForegroundService.EXTRA_CHANNEL, channelId); - intent.putExtra(CodenameOneForegroundService.EXTRA_TITLE, title); - intent.putExtra(CodenameOneForegroundService.EXTRA_BODY, body); - intent.putExtra(CodenameOneForegroundService.EXTRA_ICON, iconName); - if (android.os.Build.VERSION.SDK_INT >= 26) { - getContext().startForegroundService(intent); - } else { - getContext().startService(intent); - } - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - return Integer.valueOf(token); - } - - @Override - public void updateForegroundServiceNotification(Object nativeHandle, String title, String body) { - try { - Intent intent = new Intent(getContext(), CodenameOneForegroundService.class); - intent.setAction(CodenameOneForegroundService.ACTION_UPDATE); - if (nativeHandle instanceof Integer) { - intent.putExtra(CodenameOneForegroundService.EXTRA_TOKEN, ((Integer) nativeHandle).intValue()); - } - intent.putExtra(CodenameOneForegroundService.EXTRA_TITLE, title); - intent.putExtra(CodenameOneForegroundService.EXTRA_BODY, body); - getContext().startService(intent); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - } - - @Override - public void stopForegroundService(Object nativeHandle) { - try { - Intent intent = new Intent(getContext(), CodenameOneForegroundService.class); - intent.setAction(CodenameOneForegroundService.ACTION_STOP); - if (nativeHandle instanceof Integer) { - intent.putExtra(CodenameOneForegroundService.EXTRA_TOKEN, ((Integer) nativeHandle).intValue()); - } - getContext().startService(intent); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - } - - boolean brokenGaussian; - public Image gaussianBlurImage(Image image, float radius) { - try { - Bitmap outputBitmap = Bitmap.createBitmap((Bitmap)image.getImage()); - - RenderScript rs = RenderScript.create(getContext()); - try { - ScriptIntrinsicBlur theIntrinsic = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs)); - Allocation tmpIn = Allocation.createFromBitmap(rs, (Bitmap)image.getImage()); - Allocation tmpOut = Allocation.createFromBitmap(rs, outputBitmap); - theIntrinsic.setRadius(radius); - theIntrinsic.setInput(tmpIn); - theIntrinsic.forEach(tmpOut); - tmpOut.copyTo(outputBitmap); - tmpIn.destroy(); - tmpOut.destroy(); - theIntrinsic.destroy(); - } finally { - rs.destroy(); - } - - return new NativeImage(outputBitmap); - } catch(Throwable t) { - brokenGaussian = true; - return image; - } - } - - public boolean isGaussianBlurSupported() { - return (!brokenGaussian) && android.os.Build.VERSION.SDK_INT >= 11; - } - - @Override - public boolean blurRegion(Object graphics, int x, int y, int width, int height, float radius) { - if (radius <= 0f || width <= 0 || height <= 0 || !isGaussianBlurSupported()) { - return radius <= 0f || width <= 0 || height <= 0; - } - // In-place CSS backdrop-filter:blur on a mutable-image target. Read/write the - // backing Bitmap directly at absolute coordinates (bypassing the canvas - // transform), Gaussian-blur the region via RenderScript. The live screen - // canvas has no backing Bitmap here -> returns false (component paints - // without the blur). - if (!(graphics instanceof AndroidGraphics)) { - return false; - } - Bitmap dest = ((AndroidGraphics) graphics).underlyingBitmap; - if (dest == null || !dest.isMutable()) { - return false; - } - try { - int rx = Math.max(0, x), ry = Math.max(0, y); - int rw = Math.min(width, dest.getWidth() - rx); - int rh = Math.min(height, dest.getHeight() - ry); - if (rw <= 0 || rh <= 0) { - return true; - } - int[] pix = new int[rw * rh]; - dest.getPixels(pix, 0, rw, rx, ry, rw, rh); - Bitmap region = Bitmap.createBitmap(pix, rw, rh, Bitmap.Config.ARGB_8888); - Bitmap blurred = Bitmap.createBitmap(region); - RenderScript rs = RenderScript.create(getContext()); - try { - ScriptIntrinsicBlur theIntrinsic = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs)); - Allocation tmpIn = Allocation.createFromBitmap(rs, region); - Allocation tmpOut = Allocation.createFromBitmap(rs, blurred); - // RenderScript blur radius is capped at 25. - theIntrinsic.setRadius(Math.min(25f, radius)); - theIntrinsic.setInput(tmpIn); - theIntrinsic.forEach(tmpOut); - tmpOut.copyTo(blurred); - tmpIn.destroy(); - tmpOut.destroy(); - theIntrinsic.destroy(); - } finally { - rs.destroy(); - } - blurred.getPixels(pix, 0, rw, 0, 0, rw, rh); - dest.setPixels(pix, 0, rw, rx, ry, rw, rh); - return true; - } catch (Throwable t) { - brokenGaussian = true; - return false; - } - } - - public static boolean checkForPermission(String permission, String description){ - return checkForPermission(permission, description, false); - } - - public static void setPermissionPromptCallback(PermissionPromptCallback callback) { - permissionPromptCallback = callback; - } - - public static PermissionPromptCallback getPermissionPromptCallback() { - return permissionPromptCallback; - } - - private static String getPermissionText(String key, String defaultValue) { - return UIManager.getInstance().localize(key, Display.getInstance().getProperty(key, defaultValue)); - } - - private static boolean showPermissionPrompt(String permission, String title, String body, String positiveButtonText, String negativeButtonText) { - if (permissionPromptCallback != null) { - return permissionPromptCallback.showPermissionPrompt(permission, title, body, positiveButtonText, negativeButtonText); - } - return Dialog.show(title, body, positiveButtonText, negativeButtonText); - } - - private static void showPermissionMessage(String permission, String title, String body, String okButtonText) { - if (permissionPromptCallback != null) { - permissionPromptCallback.showPermissionMessage(permission, title, body, okButtonText); - return; - } - Dialog.show(title, body, okButtonText, null); - } - - /** - * Return a list of all of the permissions that have been requested by the app (granted or no). - * This can be used to see which permissions are included in the manifest file. - * @return - */ - public static List getRequestedPermissions() { - PackageManager pm = getContext().getPackageManager(); - try - { - PackageInfo packageInfo = pm.getPackageInfo(getContext().getPackageName(), PackageManager.GET_PERMISSIONS); - String[] requestedPermissions = null; - if (packageInfo != null) { - requestedPermissions = packageInfo.requestedPermissions; - return Arrays.asList(requestedPermissions); - } - return new ArrayList(); - } - catch (PackageManager.NameNotFoundException e) - { - com.codename1.io.Log.e(e); - return new ArrayList(); - } - } - - public static boolean checkForPermission(String permission, String description, boolean forceAsk){ - //before sdk 23 no need to ask for permission - if(android.os.Build.VERSION.SDK_INT < 23){ - return true; - } - - if (android.os.Build.VERSION.SDK_INT >= 30 && "android.permission.ACCESS_BACKGROUND_LOCATION".equals(permission)) { - if (android.support.v4.content.ContextCompat.checkSelfPermission(getContext(), permission) == PackageManager.PERMISSION_GRANTED) { - return true; - } - if (getActivity() == null) { - return false; - } - - String prompt = getPermissionText(permission, description); - String title = getPermissionText("android.permission.ACCESS_BACKGROUND_LOCATION.title", "Requires permission"); - String settingsBtn = getPermissionText("android.permission.ACCESS_BACKGROUND_LOCATION.settings", "Settings"); - String cancelBtn = getPermissionText("android.permission.ACCESS_BACKGROUND_LOCATION.cancel", "Cancel"); - - if(showPermissionPrompt(permission, title, prompt, settingsBtn, cancelBtn)){ - Intent intent = new Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS); - Uri uri = Uri.fromParts("package", getContext().getPackageName(), null); - intent.setData(uri); - getActivity().startActivity(intent); - - String explanationTitle = getPermissionText("android.permission.ACCESS_BACKGROUND_LOCATION.explanation_title", "Permission Required"); - String explanationBody = getPermissionText("android.permission.ACCESS_BACKGROUND_LOCATION.explanation_body", "Please enable 'Allow all the time' in the settings, then press OK."); - String okBtn = getPermissionText("android.permission.ACCESS_BACKGROUND_LOCATION.ok", "OK"); - - showPermissionMessage(permission, explanationTitle, explanationBody, okBtn); - return android.support.v4.content.ContextCompat.checkSelfPermission(getActivity(), permission) == PackageManager.PERMISSION_GRANTED; - } else { - return false; - } - } - - String prompt = getPermissionText(permission, description); - - if (android.support.v4.content.ContextCompat.checkSelfPermission(getContext(), - permission) - != PackageManager.PERMISSION_GRANTED) { - - if (getActivity() == null) { - return false; - } - - // Should we show an explanation? - if (!forceAsk && android.support.v4.app.ActivityCompat.shouldShowRequestPermissionRationale(getActivity(), - permission)) { - - // Show an expanation to the user *asynchronously* -- don't block - String title = getPermissionText(permission + ".title", "Requires permission"); - String askAgain = getPermissionText(permission + ".askAgain", "Ask again"); - String dontAsk = getPermissionText(permission + ".dontAsk", "Don't Ask"); - if(showPermissionPrompt(permission, title, prompt, askAgain, dontAsk)){ - return checkForPermission(permission, description, true); - }else { - return false; - } - } else { - - // No explanation needed, we can request the permission. - ((CodenameOneActivity)getActivity()).setRequestForPermission(true); - ((CodenameOneActivity)getActivity()).setWaitingForPermissionResult(true); - android.support.v4.app.ActivityCompat.requestPermissions(getActivity(), - new String[]{permission}, - 1); - //wait for a response - Display.getInstance().invokeAndBlock(new Runnable() { - @Override - public void run() { - while(((CodenameOneActivity)getActivity()).isRequestForPermission()) { - try { - Thread.sleep(50); - } catch (InterruptedException e) { - e.printStackTrace(); - } - } - } - }); - //check again if the permission is given after the dialog was displayed - return android.support.v4.content.ContextCompat.checkSelfPermission(getActivity(), - permission) == PackageManager.PERMISSION_GRANTED; - - } - } - return true; - } - - public boolean isJailbrokenDevice() { - try { - Runtime.getRuntime().exec("su"); - return true; - } catch(Throwable t) { - com.codename1.io.Log.e(t); - } - return false; - } - - @Override - public boolean isAttestationSupported() { - try { - Class.forName("com.google.android.play.core.integrity.IntegrityManagerFactory"); - return true; - } catch(Throwable t) { - return false; - } - } - - @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); - } - return result; - } - - @Override - public boolean isDeviceCompromised() { - return getCompromiseReasons().length > 0; - } - - /** - * Base64 SHA-256 digests of the certificates this APK is actually signed with. - * - *

Uses the v2/v3 signing-block API on API 28 and up, which reports the full - * signing lineage after a key rotation; below that only the legacy v1 signature - * is available. Note that under Play App Signing the digest seen here is - * Google's app signing key, not the developer's upload key -- comparing - * against the upload key is the classic way to make every production install - * report itself as repackaged.

- */ - @Override - public String[] getAppSignerDigests() { - try { - Context ctx = getContext(); - if (ctx == null) { - return new String[0]; - } - PackageManager pm = ctx.getPackageManager(); - String pkg = ctx.getPackageName(); - Signature[] signatures = null; - if (android.os.Build.VERSION.SDK_INT >= 28) { - // Reflection because the port compiles against an older android.jar - // than the devices it runs on, the same reason the Play Integrity - // call in this file is reflective. - signatures = signingCertificatesViaReflection(pm, pkg); - } - if (signatures == null) { - PackageInfo info = pm.getPackageInfo(pkg, PackageManager.GET_SIGNATURES); - signatures = info.signatures; - } - if (signatures == null) { - return new String[0]; - } - java.util.ArrayList out = new java.util.ArrayList(); - for (int i = 0; i < signatures.length; i++) { - MessageDigest md = MessageDigest.getInstance("SHA-256"); - md.update(signatures[i].toByteArray()); - out.add(com.codename1.util.Base64.encodeNoNewline(md.digest())); - } - return out.toArray(new String[out.size()]); - } catch (Throwable t) { - // Reporting nothing is better than failing a request over a - // package-manager quirk on some OEM build. - com.codename1.io.Log.e(t); - return new String[0]; - } - } - - /** - * PackageManager.GET_SIGNING_CERTIFICATES. Inlined because the port compiles - * against an android.jar that predates it. - */ - private static final int FLAG_GET_SIGNING_CERTIFICATES = 0x08000000; - - /** - * Reads the v2/v3 signing certificates on API 28+, or null when unavailable so - * the caller falls back to the legacy v1 signatures. - */ - private static Signature[] signingCertificatesViaReflection(PackageManager pm, String pkg) { - try { - PackageInfo info = pm.getPackageInfo(pkg, FLAG_GET_SIGNING_CERTIFICATES); - java.lang.reflect.Field signingInfoField = - PackageInfo.class.getField("signingInfo"); - Object signingInfo = signingInfoField.get(info); - if (signingInfo == null) { - return null; - } - Class signingInfoClass = signingInfo.getClass(); - boolean multipleSigners = ((Boolean) signingInfoClass - .getMethod("hasMultipleSigners").invoke(signingInfo)).booleanValue(); - // With one signer the history includes the pre-rotation certificates, - // which a server comparing against an older build still needs to accept. - String method = multipleSigners - ? "getApkContentsSigners" - : "getSigningCertificateHistory"; - return (Signature[]) signingInfoClass.getMethod(method).invoke(signingInfo); - } catch (Throwable t) { - return null; - } - } - - @Override - public String[] getCompromiseReasons() { - java.util.ArrayList reasons = new java.util.ArrayList(); - if(isRootedViaRootBeer() || isJailbrokenDevice()) { - reasons.add("root"); - } - try { - if(FridaDetectionUtil.isFridaDetected()) { - reasons.add("frida"); - } - } catch(Throwable t) { - // detection must never crash the host app - } - if(isProbablyEmulator()) { - reasons.add("emulator"); - } - return reasons.toArray(new String[reasons.size()]); - } - - private boolean isRootedViaRootBeer() { - try { - Class rootBeerClass = Class.forName("com.scottyab.rootbeer.RootBeer"); - Object rootBeer = rootBeerClass.getConstructor(Context.class).newInstance(getContext()); - Object rooted = rootBeerClass.getMethod("isRooted").invoke(rootBeer); - return Boolean.TRUE.equals(rooted); - } catch(Throwable t) { - // RootBeer not bundled (android.rootCheck off) - caller falls back to the su probe - return false; - } - } - - private boolean isProbablyEmulator() { - try { - String fingerprint = Build.FINGERPRINT; - if(fingerprint != null && (fingerprint.startsWith("generic") || fingerprint.startsWith("unknown") - || fingerprint.contains("emulator"))) { - return true; - } - String model = Build.MODEL; - if(model != null && (model.contains("google_sdk") || model.contains("Emulator") - || model.contains("Android SDK built for"))) { - return true; - } - String manufacturer = Build.MANUFACTURER; - if(manufacturer != null && manufacturer.contains("Genymotion")) { - return true; - } - String product = Build.PRODUCT; - if(product != null && (product.contains("sdk_gphone") || product.equals("google_sdk") - || product.contains("emulator") || product.contains("simulator"))) { - return true; - } - String hardware = Build.HARDWARE; - if(hardware != null && (hardware.contains("goldfish") || hardware.contains("ranchu"))) { - return true; - } - } catch(Throwable t) { - // ignore - } - return false; - } - - @Override - public String[] getEnabledAccessibilityServices() { - Context context = getContext(); - if(context == null) { - return new String[0]; - } - try { - AccessibilityManager am = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); - if(am != null) { - java.util.List list = - am.getEnabledAccessibilityServiceList( - android.accessibilityservice.AccessibilityServiceInfo.FEEDBACK_ALL_MASK); - if(list != null && !list.isEmpty()) { - java.util.ArrayList ids = new java.util.ArrayList(); - for(android.accessibilityservice.AccessibilityServiceInfo info : list) { - String id = info.getId(); - if(id != null && id.length() > 0) { - ids.add(id); - } - } - return ids.toArray(new String[ids.size()]); - } - } - } catch(Throwable t) { - // fall through to the Settings.Secure based lookup below - } - try { - String enabled = Settings.Secure.getString(context.getContentResolver(), - Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES); - if(enabled != null && enabled.length() > 0) { - return enabled.split(":"); - } - } catch(Throwable t) { - com.codename1.io.Log.e(t); - } - return new String[0]; - } - - @Override - public void setSecureScreen(final boolean secure) { - final Activity act = getActivity(); - if(act == null) { - return; - } - act.runOnUiThread(new Runnable() { - public void run() { - try { - if(secure) { - act.getWindow().addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE); - } else { - act.getWindow().clearFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE); - } - } catch(Throwable t) { - com.codename1.io.Log.e(t); - } - } - }); - } - - @Override - public boolean isHideOverlayWindowsSupported() { - // The permission half matters as much as the API level. Window.setHideOverlayWindows - // throws SecurityException without HIDE_OVERLAY_WINDOWS; reflection wraps it and the - // catch below only logs it, so reporting support on the API level alone would tell an - // app its native peers were protected when in fact nothing happened. It is a normal - // permission, granted at install once the manifest declares it, which the - // android.tapjackingGuard / android.hideOverlayWindows build hints arrange. - return Build.VERSION.SDK_INT >= 31 && hasHideOverlayWindowsPermission(); - } - - /** The last value passed to setHideOverlayWindows, replayed onto a recreated window. */ - private boolean hideOverlayWindowsRequested; - - private boolean hasHideOverlayWindowsPermission() { - try { - Context ctx = getContext(); - if (ctx == null) { - return false; - } - return ctx.checkSelfPermission("android.permission.HIDE_OVERLAY_WINDOWS") - == android.content.pm.PackageManager.PERMISSION_GRANTED; - } catch (Throwable t) { - return false; - } - } - - @Override - public void setHideOverlayWindows(final boolean hide) { - // Recorded before the guards below because it is a request, not a result: the flag - // lives on the Window, and a configuration change destroys and recreates the activity - // without touching this implementation instance. initSurface() replays it onto the new - // window, otherwise an app that hid overlays on a sensitive screen would come back from - // a rotation with them allowed again and no way to notice. - hideOverlayWindowsRequested = hide; - if (Build.VERSION.SDK_INT < 31) { - return; - } - if (!hasHideOverlayWindowsPermission()) { - // Said out loud rather than left to the swallowed SecurityException below: an app - // that calls this without the build hint would otherwise see no effect and no - // explanation for why its overlays were never hidden. - com.codename1.io.Log.p("Codename One: setHideOverlayWindows ignored, the app does " - + "not hold android.permission.HIDE_OVERLAY_WINDOWS. Enable the " - + "android.tapjackingGuard or android.hideOverlayWindows build hint."); - return; - } - final Activity act = getActivity(); - if (act == null) { - return; - } - act.runOnUiThread(new Runnable() { - public void run() { - try { - // Window.setHideOverlayWindows(boolean) is API 31 and absent from the - // android.jar this port compiles against, so it is reached reflectively -- - // the same approach the port uses for the Play Integrity API. - android.view.Window w = act.getWindow(); - if (w == null) { - return; - } - java.lang.reflect.Method m = android.view.Window.class.getMethod( - "setHideOverlayWindows", boolean.class); - m.invoke(w, Boolean.valueOf(hide)); - } catch (Throwable t) { - com.codename1.io.Log.e(t); - } - } - }); - } - - @Override - public void announceForAccessibility(final Component cmp, final String text) { - final Activity act = getActivity(); - if (act == null) { - return; - } - act.runOnUiThread(new Runnable() { - @Override - public void run() { - View view = null; - if (cmp instanceof PeerComponent) { - Object peer = ((PeerComponent) cmp).getNativePeer(); - if (peer instanceof View) { - view = (View) peer; - } - } - if (view == null) { - view = act.getWindow().getDecorView(); - } - if (view == null) { - return; - } - if (Build.VERSION.SDK_INT >= 16) { - view.announceForAccessibility(text); - } else { - AccessibilityManager manager = (AccessibilityManager) act.getSystemService(Context.ACCESSIBILITY_SERVICE); - if (manager != null && manager.isEnabled()) { - AccessibilityEvent event = AccessibilityEvent.obtain(AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED); - event.getText().add(text); - event.setSource(view); - manager.sendAccessibilityEvent(event); - } - } - } - }); - } - - @Override - public boolean isHighContrastEnabled() { - try { - AccessibilityManager manager = (AccessibilityManager)getContext() - .getSystemService(Context.ACCESSIBILITY_SERVICE); - if (android.os.Build.VERSION.SDK_INT >= 21 && manager != null) { - Object enabled = AccessibilityManager.class.getMethod("isHighTextContrastEnabled") - .invoke(manager); - return enabled instanceof Boolean && ((Boolean)enabled).booleanValue(); - } - } catch (Throwable t) { - // Fall through to the secure settings used by older Android stubs. - } - return secureSettingEnabled("high_text_contrast_enabled") - || secureSettingEnabled("accessibility_display_high_text_contrast_enabled"); - } - - @Override - public boolean isDifferentiateWithoutColorEnabled() { - return secureSettingEnabled("accessibility_display_daltonizer_enabled"); - } - - @Override - public AccessibilityColorVisionDeficiency getColorVisionDeficiency() { - if (!secureSettingEnabled("accessibility_display_daltonizer_enabled")) { - return AccessibilityColorVisionDeficiency.NONE; - } - try { - int mode = Settings.Secure.getInt(getContext().getContentResolver(), - "accessibility_display_daltonizer"); - switch (mode) { - case 0: return AccessibilityColorVisionDeficiency.MONOCHROMACY; - case 11: return AccessibilityColorVisionDeficiency.PROTANOPIA; - case 12: return AccessibilityColorVisionDeficiency.DEUTERANOPIA; - case 13: return AccessibilityColorVisionDeficiency.TRITANOPIA; - default: return AccessibilityColorVisionDeficiency.UNKNOWN; - } - } catch (Throwable t) { - return AccessibilityColorVisionDeficiency.UNKNOWN; - } - } - - @Override - public boolean isReduceMotionEnabled() { - try { - return Settings.Global.getFloat(getContext().getContentResolver(), - Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f; - } catch (Throwable t) { - return false; - } - } - - @Override - public boolean isBoldTextEnabled() { - try { - Object value = Configuration.class.getField("fontWeightAdjustment") - .get(getContext().getResources().getConfiguration()); - return value instanceof Integer && ((Integer)value).intValue() >= 300; - } catch (Throwable t) { - return false; - } - } - - @Override - public boolean isInvertColorsEnabled() { - return secureSettingEnabled("accessibility_display_inversion_enabled"); - } - - @Override - public boolean isGrayscaleEnabled() { - return getColorVisionDeficiency() == AccessibilityColorVisionDeficiency.MONOCHROMACY; - } - - @Override - public boolean isScreenReaderEnabled() { - try { - AccessibilityManager manager = (AccessibilityManager)getContext() - .getSystemService(Context.ACCESSIBILITY_SERVICE); - return manager != null && manager.isEnabled() && manager.isTouchExplorationEnabled(); - } catch (Throwable t) { - return false; - } - } - - private boolean secureSettingEnabled(String key) { - try { - return Settings.Secure.getInt(getContext().getContentResolver(), key, 0) == 1; - } catch (Throwable t) { - return false; - } - } - - @Override - public void accessibilityTreeChanged(final int changeType) { - final Activity act = getActivity(); - if (act == null || accessibilityProvider == null) return; - act.runOnUiThread(new Runnable() { - public void run() { - if (accessibilityProvider != null) accessibilityProvider.invalidate(changeType); - } - }); - } - - @Override - public boolean isAccessibilityTreeSupported() { - return Build.VERSION.SDK_INT >= 16; - } - - @Override - public boolean isAccessibilityTreeUpdateRequired() { - return accessibilityTreeUpdateRequired; - } - - void setAccessibilityTreeUpdateRequired(boolean required) { - accessibilityTreeUpdateRequired = required; - } - - // ================================================================ - // Crypto bridge -- routes com.codename1.security onto the standard - // Android JCE provider. - - private static java.security.SecureRandom androidSecureRandom; - private static final Object androidSecureRandomSync = new Object(); - - private static java.security.SecureRandom androidSecureRandom() { - synchronized (androidSecureRandomSync) { - if (androidSecureRandom == null) { - androidSecureRandom = new java.security.SecureRandom(); - } - return androidSecureRandom; - } - } - - @Override - public void secureRandomBytes(byte[] out) { - if (out == null) return; - androidSecureRandom().nextBytes(out); - } - - @Override - public byte[] aesEncrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] plaintext) { - return androidAes(transformation, key, iv, aad, plaintext, javax.crypto.Cipher.ENCRYPT_MODE); - } - - @Override - public byte[] aesDecrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] ciphertext) { - return androidAes(transformation, key, iv, aad, ciphertext, javax.crypto.Cipher.DECRYPT_MODE); - } - - private static byte[] androidAes(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] input, int mode) { - try { - javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation); - javax.crypto.spec.SecretKeySpec keySpec = new javax.crypto.spec.SecretKeySpec(key, "AES"); - String tu = transformation == null ? "" : transformation.toUpperCase(); - if (tu.indexOf("GCM") >= 0) { - cipher.init(mode, keySpec, new javax.crypto.spec.GCMParameterSpec(128, iv)); - } else if (iv != null) { - cipher.init(mode, keySpec, new javax.crypto.spec.IvParameterSpec(iv)); - } else { - cipher.init(mode, keySpec); - } - if (aad != null && aad.length > 0) { - cipher.updateAAD(aad); - } - return cipher.doFinal(input); - } catch (java.security.GeneralSecurityException e) { - throw new RuntimeException("AES " + (mode == javax.crypto.Cipher.ENCRYPT_MODE ? "encrypt" : "decrypt") + " failed: " + e.getMessage()); - } - } - - /// The RSA transformations this port implements, matched exactly. - /// - /// A substring test for "OAEP" would answer every OAEP name -- including - /// RSA/ECB/OAEPWithSHA-1AndMGF1Padding -- with the SHA-256 parameters below, - /// producing ciphertext no standards-compliant peer could read under the name - /// it asked for. The native ports already accept only these two, so refusing - /// anything else here keeps every port answering the same question. - private static boolean cn1IsOaepTransformation(String transformation) { - return com.codename1.security.Cipher.RSA_OAEP_SHA256.equals(transformation); - } - - private static void cn1CheckRsaTransformation(String transformation) { - if (!cn1IsOaepTransformation(transformation) - && !com.codename1.security.Cipher.RSA_PKCS1.equals(transformation)) { - throw new RuntimeException("unsupported cipher transformation: " + transformation); - } - } - - /// The OAEP parameters every port agrees on. - /// - /// The JCE transformation name "OAEPWithSHA-256AndMGF1Padding" leaves MGF1 on - /// SHA-1 by default, which no other backend here can reproduce: Web Crypto's - /// RSA-OAEP uses one hash for the label and the mask, and so does Apple's - /// SecKey. Naming SHA-256 for both is the only pairing all six ports can - /// produce, so it is what the portable constant means -- stated explicitly - /// rather than inherited from a provider default. - private static javax.crypto.spec.OAEPParameterSpec cn1OaepParameters() { - return new javax.crypto.spec.OAEPParameterSpec("SHA-256", "MGF1", - java.security.spec.MGF1ParameterSpec.SHA256, - javax.crypto.spec.PSource.PSpecified.DEFAULT); - } - - @Override - public byte[] rsaEncrypt(String transformation, byte[] publicKeyX509, byte[] plaintext) { - try { - javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation); - java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); - java.security.PublicKey key = kf.generatePublic(new java.security.spec.X509EncodedKeySpec(publicKeyX509)); - cn1CheckRsaTransformation(transformation); - if (cn1IsOaepTransformation(transformation)) { - cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key, cn1OaepParameters()); - } else { - cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key); - } - return cipher.doFinal(plaintext); - } catch (java.security.GeneralSecurityException e) { - throw new RuntimeException("RSA encrypt failed: " + e.getMessage()); - } - } - - @Override - public byte[] rsaDecrypt(String transformation, byte[] privateKeyPkcs8, byte[] ciphertext) { - try { - javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation); - java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); - java.security.PrivateKey key = kf.generatePrivate(new java.security.spec.PKCS8EncodedKeySpec(privateKeyPkcs8)); - cn1CheckRsaTransformation(transformation); - if (cn1IsOaepTransformation(transformation)) { - cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key, cn1OaepParameters()); - } else { - cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key); - } - return cipher.doFinal(ciphertext); - } catch (java.security.GeneralSecurityException e) { - throw new RuntimeException("RSA decrypt failed: " + e.getMessage()); - } - } - - @Override - public byte[] cryptoSign(String algorithm, String keyAlgorithm, byte[] privateKeyPkcs8, byte[] data) { - try { - java.security.KeyFactory kf = java.security.KeyFactory.getInstance(keyAlgorithm); - java.security.PrivateKey priv = kf.generatePrivate(new java.security.spec.PKCS8EncodedKeySpec(privateKeyPkcs8)); - java.security.Signature sig = java.security.Signature.getInstance(algorithm); - sig.initSign(priv); - sig.update(data); - return sig.sign(); - } catch (java.security.GeneralSecurityException e) { - throw new RuntimeException("sign failed: " + e.getMessage()); - } - } - - @Override - public boolean cryptoVerify(String algorithm, String keyAlgorithm, byte[] publicKeyX509, byte[] data, byte[] signature) { - try { - java.security.KeyFactory kf = java.security.KeyFactory.getInstance(keyAlgorithm); - java.security.PublicKey pub = kf.generatePublic(new java.security.spec.X509EncodedKeySpec(publicKeyX509)); - java.security.Signature sig = java.security.Signature.getInstance(algorithm); - sig.initVerify(pub); - sig.update(data); - return sig.verify(signature); - } catch (java.security.GeneralSecurityException e) { - throw new RuntimeException("verify failed: " + e.getMessage()); - } - } - - @Override - public byte[][] generateRsaKeyPair(int bits) { - try { - java.security.KeyPairGenerator kpg = java.security.KeyPairGenerator.getInstance("RSA"); - kpg.initialize(bits); - java.security.KeyPair kp = kpg.generateKeyPair(); - return new byte[][]{ kp.getPublic().getEncoded(), kp.getPrivate().getEncoded() }; - } catch (java.security.GeneralSecurityException e) { - throw new RuntimeException("RSA keypair generation failed: " + e.getMessage()); - } - } -} +/* + * 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.Manifest; +import android.annotation.TargetApi; +import com.codename1.impl.android.permissions.DevicePermission; +import com.codename1.impl.android.permissions.PermissionsHelper; +import com.codename1.location.AndroidLocationManager; +import android.app.*; +import android.content.pm.PackageManager.NameNotFoundException; +import android.media.AudioTimestamp; +import android.support.v4.content.ContextCompat; +import android.view.MotionEvent; +import com.codename1.codescan.ScanResult; +import com.codename1.media.Media; +import com.codename1.ui.geom.Dimension; + + +import android.webkit.CookieSyncManager; +import android.content.*; +import android.content.pm.*; +import android.content.res.AssetFileDescriptor; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Paint; +import android.graphics.Rect; +import android.graphics.Typeface; +import android.graphics.Path; +import android.graphics.drawable.Drawable; +import android.media.AudioManager; +import android.net.Uri; +import android.os.Vibrator; +import android.os.PowerManager; +import android.provider.Settings; +import android.telephony.TelephonyManager; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.TypedValue; +import android.view.KeyEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.accessibility.AccessibilityManager; +import android.view.Window; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.RelativeLayout; +import android.widget.TextView; +import com.codename1.ui.BrowserComponent; +import com.codename1.ui.AccessibilityColorVisionDeficiency; + +import com.codename1.ui.Component; +import com.codename1.ui.Font; +import com.codename1.ui.Image; +import com.codename1.ui.PeerComponent; +import com.codename1.ui.ClipboardContent; +import com.codename1.ui.ClipboardDataProvider; +import com.codename1.ui.events.ActionEvent; +import com.codename1.impl.CodenameOneImplementation; +import com.codename1.impl.VirtualKeyboardInterface; +import com.codename1.ui.plaf.UIManager; +import com.codename1.ui.util.Resources; +import java.lang.ref.SoftReference; +import java.lang.reflect.Method; +import java.net.URISyntaxException; +import java.nio.charset.StandardCharsets; +import java.util.Vector; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.graphics.Matrix; +import android.graphics.drawable.BitmapDrawable; +import android.hardware.Camera; +import android.media.AudioFormat; +import android.media.AudioRecord; +import android.media.ExifInterface; +import android.media.MediaPlayer; +import android.media.MediaRecorder; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.os.Build; +import android.os.Bundle; +import android.os.PersistableBundle; +import android.os.Environment; +import android.os.Handler; +import android.os.IBinder; +import android.os.Looper; +import android.os.RemoteException; +import android.provider.MediaStore; +import android.provider.Settings; +import android.provider.Settings.Secure; +import android.renderscript.Allocation; +import android.renderscript.Element; +import android.renderscript.RenderScript; +import android.renderscript.ScriptIntrinsicBlur; +import android.support.v4.app.NotificationCompat; +import android.support.v4.content.FileProvider; +import android.support.v4.media.MediaBrowserCompat; +import android.support.v4.media.session.MediaControllerCompat; +import android.support.v4.media.session.PlaybackStateCompat; +import android.telephony.SmsManager; +import android.telephony.gsm.GsmCellLocation; +import android.text.Html; +import android.view.*; +import android.view.View.MeasureSpec; +import android.view.accessibility.AccessibilityEvent; +import android.view.accessibility.AccessibilityManager; +import android.webkit.*; +import android.widget.*; +import com.codename1.background.BackgroundFetch; +import com.codename1.capture.VideoCaptureConstraints; +import com.codename1.codescan.CodeScanner; +import com.codename1.contacts.Contact; +import com.codename1.db.Database; +import com.codename1.impl.android.compat.app.NotificationCompatWrapper; +import com.codename1.impl.android.compat.app.NotificationCompatWrapper.ActionWrapper; +import com.codename1.impl.android.compat.app.RemoteInputWrapper; +import com.codename1.io.BufferedInputStream; +import com.codename1.io.BufferedOutputStream; +import com.codename1.io.*; +import com.codename1.l10n.L10NManager; +import com.codename1.location.LocationManager; +import com.codename1.media.AbstractMedia; +import com.codename1.media.AsyncMedia; +import com.codename1.media.AsyncMedia.MediaErrorType; +import com.codename1.media.AsyncMedia.MediaException; +import com.codename1.media.Audio; +import com.codename1.media.AudioService; +import com.codename1.media.BackgroundAudioService; +import com.codename1.media.MediaProxy; +import com.codename1.media.MediaRecorderBuilder; +import com.codename1.messaging.Message; +import com.codename1.notifications.LocalNotification; +import com.codename1.notifications.NotificationChannelBuilder; +import com.codename1.notifications.NotificationPermissionCallback; +import com.codename1.notifications.NotificationPermissionRequest; +import com.codename1.notifications.NotificationPermissionResult; +import com.codename1.background.ForegroundService; +import com.codename1.background.WorkRequest; +import com.codename1.share.SharedContent; +import com.codename1.payment.Purchase; +import com.codename1.push.PushAction; +import com.codename1.push.PushActionCategory; +import com.codename1.push.PushActionsProvider; +import com.codename1.push.PushCallback; +import com.codename1.push.PushContent; +import com.codename1.ui.*; +import com.codename1.ui.Dialog; +import com.codename1.ui.Display; +import com.codename1.ui.animations.Animation; +import com.codename1.ui.animations.CommonTransitions; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.geom.GeneralPath; +import com.codename1.ui.geom.Rectangle; +import com.codename1.ui.geom.Shape; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.plaf.Style; +import com.codename1.ui.util.EventDispatcher; +import com.codename1.util.AsyncResource; +import com.codename1.util.Callback; +import java.io.File; +import java.io.BufferedReader; +import java.io.FileDescriptor; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.io.RandomAccessFile; +import java.nio.channels.FileLock; +import java.io.Writer; +import java.lang.reflect.Constructor; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.net.URLConnection; +import java.text.DateFormat; +import java.text.NumberFormat; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.Hashtable; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; +import com.codename1.util.StringUtil; +import com.codename1.util.SuccessCallback; +import java.io.*; +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Modifier; +import java.net.CookieHandler; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.NetworkInterface; +import java.net.ServerSocket; +import java.security.MessageDigest; +import java.text.ParseException; +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; +import javax.net.ssl.HttpsURLConnection; +import javax.xml.parsers.ParserConfigurationException; + +import org.json.JSONException; +import org.json.JSONObject; +import org.json.JSONStringer; +import org.xml.sax.SAXException; +//import android.webkit.JavascriptInterface; + +public class AndroidImplementation extends CodenameOneImplementation implements IntentResultListener { + private AndroidCalendarSource calendarSource; + private static final AtomicLong V3_NOTIFICATION_SEQUENCE = new AtomicLong(); + + public static final Thread.UncaughtExceptionHandler exceptionHandler = new Thread.UncaughtExceptionHandler() { + @Override + public void uncaughtException(Thread t, Throwable e) { + try { + com.codename1.crash.CrashProtection.capture(e); + } catch (Throwable ignore) { + } + } + }; + + public static final int FLAG_ONE_SHOT = 0x40000000; + public static final int FLAG_MUTABLE = 0x02000000; + + public static final int FLAG_IMMUTABLE = 0x04000000; + + /** + * make sure these important keys have a negative value when passed to + * Codename One or they might be interpreted as characters. + */ + static final int DROID_IMPL_KEY_LEFT = -23446; + static final int DROID_IMPL_KEY_RIGHT = -23447; + static final int DROID_IMPL_KEY_UP = -23448; + static final int DROID_IMPL_KEY_DOWN = -23449; + static final int DROID_IMPL_KEY_FIRE = -23450; + static final int DROID_IMPL_KEY_MENU = -23451; + static final int DROID_IMPL_KEY_BACK = -23452; + static final int DROID_IMPL_KEY_BACKSPACE = -23453; + static final int DROID_IMPL_KEY_CLEAR = -23454; + static final int DROID_IMPL_KEY_SEARCH = -23455; + static final int DROID_IMPL_KEY_CALL = -23456; + static final int DROID_IMPL_KEY_VOLUME_UP = -23457; + static final int DROID_IMPL_KEY_VOLUME_DOWN = -23458; + static final int DROID_IMPL_KEY_MUTE = -23459; + static final int DROID_IMPL_KEY_ENTER = -23460; + static final int DROID_IMPL_KEY_TAB = -23461; + static final int DROID_IMPL_KEY_ESCAPE = -23462; + static final int DROID_IMPL_KEY_HOME = -23463; + static final int DROID_IMPL_KEY_END = -23464; + static final int DROID_IMPL_KEY_PAGE_UP = -23465; + static final int DROID_IMPL_KEY_PAGE_DOWN = -23466; + static final int DROID_IMPL_KEY_INSERT = -23467; + static final int DROID_IMPL_KEY_FORWARD_DEL = -23468; + static final int DROID_IMPL_KEY_F1 = -23469; + static final int DROID_IMPL_KEY_F2 = -23470; + static final int DROID_IMPL_KEY_F3 = -23471; + static final int DROID_IMPL_KEY_F4 = -23472; + static final int DROID_IMPL_KEY_F5 = -23473; + static final int DROID_IMPL_KEY_F6 = -23474; + static final int DROID_IMPL_KEY_F7 = -23475; + static final int DROID_IMPL_KEY_F8 = -23476; + static final int DROID_IMPL_KEY_F9 = -23477; + static final int DROID_IMPL_KEY_F10 = -23478; + static final int DROID_IMPL_KEY_F11 = -23479; + static final int DROID_IMPL_KEY_F12 = -23480; + static int[] leftSK = new int[]{DROID_IMPL_KEY_MENU}; + + /** + * @return the activity + */ + public static CodenameOneActivity getActivity() { + return activity; + } + + // ---- low level text input source (pure Codename One editors) ---- + + private static volatile com.codename1.ui.TextInputClient activeInputClient; + private static volatile com.codename1.ui.TextInputState activeInputState; + private static volatile com.codename1.ui.TextInputConfig activeInputConfig; + /// Synchronous mirror of edits the input connection has posted but the EDT has not yet + /// applied and echoed back. IMEs (notably Gboard) commit text and immediately re-read the + /// surrounding text; without this mirror they would see pre-commit text and desync their + /// suggestion model. Cleared when the authoritative state from the EDT has caught up with + /// every posted edit (the seq pair below). + private static volatile com.codename1.ui.TextInputState pendingInputState; + /// Generation of the last edit the input connection posted (written on the IME thread). + private static volatile int pendingPostedSeq; + /// Generation of the last posted edit the EDT applied (written on the EDT). + private static volatile int pendingAppliedSeq; + + /// Returns the editing state as the IME must see it right now: the pending synchronous + /// mirror when an edit is in flight, otherwise the last state pushed from the EDT. + static com.codename1.ui.TextInputState currentInputState() { + com.codename1.ui.TextInputState pending = pendingInputState; + return pending != null ? pending : activeInputState; + } + + /// Records the input connection's synchronous mirror of an in-flight edit and returns the + /// edit's generation; the connection marks it applied from the EDT runnable that delivers + /// the edit to the client. + static int setPendingInputState(com.codename1.ui.TextInputState state) { + pendingInputState = state; + return ++pendingPostedSeq; + } + + /// Marks a posted edit as applied on the EDT (called right before the client mutation whose + /// state push may then retire the mirror). + static void markPendingApplied(int seq) { + pendingAppliedSeq = seq; + } + + /// Routes a hardware (Bluetooth / Chromebook) key event to the bound text input client. + /// Hardware keys bypass the IME entirely, and the pure editor's raw key path is disabled + /// while a platform session is active, so without this they would be silently dropped. + /// Returns true when the event was consumed for the client (including the matching key-up + /// of a consumed key-down); false leaves the event to the regular Codename One pipeline + /// (BACK, D-pad game keys on non-editor forms, ...). + static boolean routeHardwareKeyToActiveClient(boolean down, android.view.KeyEvent event) { + com.codename1.ui.TextInputClient client = activeInputClient; + if (client == null || event == null) { + return false; + } + return CN1TextInputConnection.deliverHardwareKey(client, event, down); + } + + /// Re-requests the soft keyboard for the bound text input client. Called on every tap so a + /// keyboard the user dismissed (back gesture) returns when the editor is tapped again, the + /// same behavior a native EditText has. No-op when no client is bound. + static void showSoftInputForActiveClient() { + if (activeInputClient == null) { + return; + } + final CodenameOneActivity a = getActivity(); + final CodenameOneSurface view = instance != null ? instance.myView : null; + if (a == null || view == null) { + return; + } + a.runOnUiThread(new Runnable() { + public void run() { + if (activeInputClient == null) { + return; + } + android.view.View v = view.getAndroidView(); + v.requestFocus(); + android.view.inputmethod.InputMethodManager imm = (android.view.inputmethod.InputMethodManager) + a.getSystemService(android.content.Context.INPUT_METHOD_SERVICE); + if (imm != null) { + imm.showSoftInput(v, 0); + } + } + }); + } + + static com.codename1.ui.TextInputConfig currentInputConfig() { + return activeInputConfig; + } + + /// Called by the rendering view's `onCreateInputConnection` to supply the custom input connection + /// when a pure editor is bound. Returns null when no client is active so the view keeps its default + /// behavior. + static android.view.inputmethod.InputConnection createEditorInputConnection(android.view.View view, android.view.inputmethod.EditorInfo editorInfo) { + com.codename1.ui.TextInputClient client = activeInputClient; + if (client == null) { + return null; + } + configureEditorInfo(editorInfo, activeInputConfig); + return new CN1TextInputConnection(view, client); + } + + /// True when a pure editor text input client is currently bound. + static boolean hasActiveInputClient() { + return activeInputClient != null; + } + + /// The Android autofill hint for a one-time code, spelled out rather than referenced as + /// `View.AUTOFILL_HINT_SMS_OTP` because the constant is newer than the SDK this port + /// compiles against. The string is the contract: it is what an autofill service matches on. + private static final String AUTOFILL_HINT_SMS_OTP = "smsOTPCode"; + + /// What the platform may fill into the currently bound field, or null when it is not a field + /// the platform can fill. + /// + /// Only the one-time code is offered. The rendering surface is a single view standing in for + /// whichever field is being edited, so claiming a hint puts the whole surface forward as that + /// kind of field -- true only while the code field holds the session, which is why the hint is + /// applied when a session starts and dropped when it ends. + private static String[] editorAutofillHints() { + com.codename1.ui.TextInputConfig cfg = activeInputConfig; + if (cfg != null && (cfg.getConstraint() & com.codename1.ui.TextArea.ONE_TIME_CODE) != 0) { + return new String[]{AUTOFILL_HINT_SMS_OTP}; + } + return null; + } + + /// Puts the surface forward as an autofillable field, or withdraws it, to match the field the + /// input session is bound to. Called on the UI thread as a session starts and stops. + /// + /// #### Parameters + /// + /// - `v`: the rendering view + /// + /// - `sessionActive`: true while a client is bound + static void updateEditorAutofill(android.view.View v, boolean sessionActive) { + if (v == null || android.os.Build.VERSION.SDK_INT < 26) { + return; + } + android.view.autofill.AutofillManager afm = + (android.view.autofill.AutofillManager) v.getContext() + .getSystemService(android.view.autofill.AutofillManager.class); + String[] hints = sessionActive ? editorAutofillHints() : null; + if (hints == null) { + v.setImportantForAutofill(android.view.View.IMPORTANT_FOR_AUTOFILL_NO); + v.setAutofillHints((String[]) null); + if (afm != null) { + afm.notifyViewExited(v); + } + return; + } + v.setAutofillHints(hints); + v.setImportantForAutofill(android.view.View.IMPORTANT_FOR_AUTOFILL_YES); + if (afm != null) { + // the session only starts once the framework is told the view was entered; a view + // that merely carries hints is never offered anything + afm.notifyViewEntered(v); + } + } + + /// Applies a value the platform filled in, replacing whatever the field held. Called by the + /// rendering view on the UI thread; the edit itself belongs to the EDT. + /// + /// #### Parameters + /// + /// - `value`: the value the autofill service supplied + /// + /// #### Returns + /// + /// true when the value was taken + static boolean autofillEditor(android.view.autofill.AutofillValue value) { + final com.codename1.ui.TextInputClient client = activeInputClient; + if (client == null || value == null || !value.isText()) { + return false; + } + // Only into a field that asked for this. The hint lives on the surface and is put + // there and taken away on Android's UI thread, while the session it describes changes + // on the EDT, so for a moment after the user moves from a code field to an ordinary + // one the view still advertises smsOTPCode while the session behind it is something + // else. A fill delivered in that gap would otherwise land a code in whatever the user + // tapped into. Asking what the CURRENT session advertises closes it: the answer is + // read from the same field the identity check below uses. + if (editorAutofillHints() == null) { + return false; + } + com.codename1.ui.Display.getInstance().callSerially( + new ApplyAutofilledText(client, value.getTextValue().toString())); + return true; + } + + private static final class ApplyAutofilledText implements Runnable { + private final com.codename1.ui.TextInputClient client; + private final String text; + + ApplyAutofilledText(com.codename1.ui.TextInputClient client, String text) { + this.client = client; + this.text = text; + } + + public void run() { + // The session may be gone: the platform fills on the UI thread and this runs a hop + // later on the EDT, and in between the user can have moved to another field or left + // the screen. Applying it then would edit a field nothing is bound to any more and + // fire its listeners -- and an OtpField's completion listener submits a code, so a + // late fill would verify one for a flow the user has already left. The rest of this + // bridge guards its callbacks the same way. + if (client != activeInputClient || editorAutofillHints() == null) { + return; + } + // A filled value replaces the field rather than being inserted at the caret: the + // platform is answering "the value is this", not typing into what is there. It + // still arrives as a commit rather than a raw range replacement, because a field + // filters what it accepts and a filled value has no more right to bypass that + // than a typed one -- an OTP field asked for six digits and can be handed + // "123-456" by an autofill service that kept the separator, and a replacement + // would leave the field holding a value it would never have let anyone type, + // never reaching the length that completes it. + // Ending any composition first. A commit replaces the composed range in + // preference to the selection, so selecting the whole field is not enough to + // replace the whole field while an input method is mid-word: the filled value + // would land inside the composition and leave whatever surrounded it, which + // for a code field means a full-length wrong code that submits itself. + client.finishComposing(); + client.setSelectionRange(0, client.getTextLength()); + client.commitText(text); + } + } + + /// The value the platform should see for the bound field, or null when nothing is bound. + /// + /// Answered from the state snapshot rather than the editor itself. This runs on Android's UI + /// thread whenever an autofill service asks what the field holds, while the document belongs + /// to the EDT, and reading a length and then a range out of a document another thread is + /// editing is two reads of something that can change in between. Clamped offsets would not + /// rescue it either, since the buffer underneath can be restructured mid-read. The snapshot + /// is immutable and is what the rest of this bridge already uses to answer the platform + /// across that boundary; a value one edit out of date is the correct trade against a crash + /// inside somebody else's autofill query. + static android.view.autofill.AutofillValue editorAutofillValue() { + // Read the state AFTER the guards and confirm the session did not move under it. + // The three fields are assigned separately on the EDT, so taking the state first + // and validating afterwards can pair one field's text with the next field's + // configuration -- and the pairing that matters is a password field's text with a + // code field's hint. One session snapshot would express this better than three + // fields and a re-check, but that is the whole input bridge's shape rather than + // this method's, and the property needed here is only that nothing is returned + // for a session other than the one that was checked. + // + // Gated the same way the write path is, and for a sharper reason: between the EDT + // moving to another field and the UI thread taking the hint off the view, the + // surface still looks like a code field over a session that is something else -- + // and answering this query then would hand that field's text to an SMS autofill + // service. The field after a code field is as likely to be a password as anything. + com.codename1.ui.TextInputClient client = activeInputClient; + if (client == null || editorAutofillHints() == null) { + return null; + } + com.codename1.ui.TextInputState state = activeInputState; + if (state == null || client != activeInputClient) { + return null; + } + String text = state.getText(); + return android.view.autofill.AutofillValue.forText(text == null ? "" : text); + } + + private static void configureEditorInfo(android.view.inputmethod.EditorInfo editorInfo, com.codename1.ui.TextInputConfig cfg) { + int constraint = cfg == null ? 0 : cfg.getConstraint(); + int inputType; + switch (constraint & 0xffff) { + case com.codename1.ui.TextArea.NUMERIC: + inputType = android.text.InputType.TYPE_CLASS_NUMBER + | android.text.InputType.TYPE_NUMBER_FLAG_SIGNED; + break; + case com.codename1.ui.TextArea.DECIMAL: + inputType = android.text.InputType.TYPE_CLASS_NUMBER + | android.text.InputType.TYPE_NUMBER_FLAG_SIGNED + | android.text.InputType.TYPE_NUMBER_FLAG_DECIMAL; + break; + case com.codename1.ui.TextArea.PHONENUMBER: + inputType = android.text.InputType.TYPE_CLASS_PHONE; + break; + case com.codename1.ui.TextArea.EMAILADDR: + inputType = android.text.InputType.TYPE_CLASS_TEXT + | android.text.InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS; + break; + case com.codename1.ui.TextArea.URL: + inputType = android.text.InputType.TYPE_CLASS_TEXT + | android.text.InputType.TYPE_TEXT_VARIATION_URI; + break; + default: + inputType = android.text.InputType.TYPE_CLASS_TEXT; + break; + } + boolean text = (inputType & android.text.InputType.TYPE_MASK_CLASS) == android.text.InputType.TYPE_CLASS_TEXT; + boolean password = (constraint & com.codename1.ui.TextArea.PASSWORD) != 0; + if (password) { + inputType = text + ? inputType | android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD + : android.text.InputType.TYPE_CLASS_NUMBER | android.text.InputType.TYPE_NUMBER_VARIATION_PASSWORD; + text = (inputType & android.text.InputType.TYPE_MASK_CLASS) == android.text.InputType.TYPE_CLASS_TEXT; + } + boolean multiline = cfg == null || cfg.isMultiline(); + if (text) { + if (multiline) { + inputType |= android.text.InputType.TYPE_TEXT_FLAG_MULTI_LINE; + } + if (password || (cfg != null && !cfg.isAutoCorrect())) { + inputType |= android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS; + } + if (!password && cfg != null && cfg.isAutoCapitalize()) { + inputType |= android.text.InputType.TYPE_TEXT_FLAG_CAP_SENTENCES; + } + } + if ((constraint & com.codename1.ui.TextArea.ONE_TIME_CODE) != 0 && text) { + // a code is not a word: prediction would offer completions for it and, worse, learn it + inputType |= android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS; + } + editorInfo.inputType = inputType; + editorInfo.imeOptions = android.view.inputmethod.EditorInfo.IME_FLAG_NO_EXTRACT_UI; + if (multiline) { + editorInfo.imeOptions |= android.view.inputmethod.EditorInfo.IME_ACTION_NONE; + } else { + editorInfo.imeOptions |= imeActionFor(cfg == null + ? com.codename1.ui.TextInputConfig.ACTION_DEFAULT : cfg.getActionType()); + } + editorInfo.initialSelStart = activeInputState != null ? activeInputState.getSelectionStart() : 0; + editorInfo.initialSelEnd = activeInputState != null ? activeInputState.getSelectionEnd() : 0; + } + + private static int imeActionFor(int actionType) { + switch (actionType) { + case com.codename1.ui.TextInputConfig.ACTION_NEXT: + return android.view.inputmethod.EditorInfo.IME_ACTION_NEXT; + case com.codename1.ui.TextInputConfig.ACTION_SEARCH: + return android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH; + case com.codename1.ui.TextInputConfig.ACTION_SEND: + return android.view.inputmethod.EditorInfo.IME_ACTION_SEND; + case com.codename1.ui.TextInputConfig.ACTION_DONE: + default: + return android.view.inputmethod.EditorInfo.IME_ACTION_DONE; + } + } + + /// Maps an Android `EditorInfo.IME_ACTION_*` code back to the `TextInputConfig` action constant + /// delivered to `TextInputClient.onEditorAction`. + static int textInputActionFor(int imeActionCode) { + switch (imeActionCode) { + case android.view.inputmethod.EditorInfo.IME_ACTION_NEXT: + return com.codename1.ui.TextInputConfig.ACTION_NEXT; + case android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH: + return com.codename1.ui.TextInputConfig.ACTION_SEARCH; + case android.view.inputmethod.EditorInfo.IME_ACTION_SEND: + return com.codename1.ui.TextInputConfig.ACTION_SEND; + case android.view.inputmethod.EditorInfo.IME_ACTION_DONE: + return com.codename1.ui.TextInputConfig.ACTION_DONE; + default: + return com.codename1.ui.TextInputConfig.ACTION_DEFAULT; + } + } + + @Override + public boolean isTextInputSupported() { + return true; + } + + @Override + public Object startTextInput(com.codename1.ui.TextInputClient client, com.codename1.ui.TextInputConfig config) { + activeInputClient = client; + activeInputConfig = config; + activeInputState = client.getEditingState(); + pendingInputState = null; + final CodenameOneActivity a = getActivity(); + final CodenameOneSurface view = myView; + if (a == null || view == null) { + return client; + } + a.runOnUiThread(new Runnable() { + public void run() { + android.view.View v = view.getAndroidView(); + v.setFocusable(true); + v.setFocusableInTouchMode(true); + v.requestFocus(); + android.view.inputmethod.InputMethodManager imm = (android.view.inputmethod.InputMethodManager) + a.getSystemService(android.content.Context.INPUT_METHOD_SERVICE); + if (imm != null) { + imm.restartInput(v); + imm.showSoftInput(v, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT); + } + updateEditorAutofill(v, true); + } + }); + return client; + } + + @Override + public void updateTextInputState(Object handle, com.codename1.ui.TextInputState state) { + if (handle == null || handle != activeInputClient || state == null) { + // a stale handle (an unbalanced session that was already replaced) must not + // disturb the currently bound client + return; + } + activeInputState = state; + // retire the connection's synchronous mirror only when this push reflects every posted + // edit; clearing early would hide an in-flight edit from the IME's immediate re-reads + if (pendingAppliedSeq == pendingPostedSeq) { + pendingInputState = null; + } + final CodenameOneActivity a = getActivity(); + final CodenameOneSurface view = myView; + if (a == null || view == null) { + return; + } + a.runOnUiThread(new Runnable() { + public void run() { + android.view.inputmethod.InputMethodManager imm = (android.view.inputmethod.InputMethodManager) + a.getSystemService(android.content.Context.INPUT_METHOD_SERVICE); + if (imm != null && activeInputClient != null) { + com.codename1.ui.TextInputState s = activeInputState; + imm.updateSelection(view.getAndroidView(), s.getSelectionStart(), s.getSelectionEnd(), + s.getComposingStart(), s.getComposingEnd()); + } + } + }); + } + + @Override + public void stopTextInput(Object handle) { + if (handle == null || handle != activeInputClient) { + return; + } + activeInputClient = null; + activeInputState = null; + activeInputConfig = null; + pendingInputState = null; + final CodenameOneActivity a = getActivity(); + final CodenameOneSurface view = myView; + if (a == null || view == null) { + return; + } + a.runOnUiThread(new Runnable() { + public void run() { + android.view.inputmethod.InputMethodManager imm = (android.view.inputmethod.InputMethodManager) + a.getSystemService(android.content.Context.INPUT_METHOD_SERVICE); + if (imm != null) { + imm.hideSoftInputFromWindow(view.getAndroidView().getWindowToken(), 0); + imm.restartInput(view.getAndroidView()); + } + updateEditorAutofill(view.getAndroidView(), false); + } + }); + } + + + @Override + public void setDisableScreenshots(final boolean disable) { + final CodenameOneActivity a = getActivity(); + if (a == null || a.getWindow() == null) { + return; + } + a.runOnUiThread(new Runnable() { + @Override + public void run() { + if (disable) { + a.getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE); + } else { + a.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_SECURE); + } + } + }); + } + + /** + * @param aActivity the activity to set + */ + public static void setActivity(CodenameOneActivity aActivity) { + activity = aActivity; + if (activity != null) { + activityComponentName = activity.getComponentName(); + } + + } + CodenameOneSurface myView = null; + private AndroidAccessibilityProvider accessibilityProvider; + private volatile boolean accessibilityTreeUpdateRequired; + CodenameOneTextPaint defaultFont; + private final char[] tmpchar = new char[1]; + private final Rect tmprect = new Rect(); + protected int defaultFontHeight; + private Vibrator v = null; + private boolean vibrateInitialized = false; + private int displayWidth; + private int displayHeight; + static CodenameOneActivity activity; + static ComponentName activityComponentName; + private static PowerManager.WakeLock pushWakeLock; + public static synchronized void acquirePushWakeLock(long timeout) { + if (getContext() == null) return; + try { + if (pushWakeLock == null) { + PowerManager pm = (PowerManager) getContext().getSystemService(Context.POWER_SERVICE); + pushWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "CN1:PushWakeLock"); + } + pushWakeLock.acquire(timeout); + } catch (Exception ex) { + com.codename1.io.Log.e(ex); + } + } + + private static Context context; + private static PermissionPromptCallback permissionPromptCallback; + RelativeLayout relativeLayout; + final Vector nativePeers = new Vector(); + int lastDirectionalKeyEventReceivedByWrapper; + private EventDispatcher callback; + private int timeout = -1; + private CodeScannerImpl scannerInstance; + private HashMap apIds; + private static View viewBelow; + private static View viewAbove; + private static int aboveSpacing; + private static int belowSpacing; + public static boolean asyncView = false; + public static boolean textureView = false; + private AudioService background; + private boolean asyncEditMode = false; + private boolean compatPaintMode; + private MediaRecorder recorder = null; + + private boolean statusBarHidden; + private boolean superPeerMode = true; + + + private ValueCallback mUploadMessage; + public ValueCallback uploadMessage; + + /** + * Keeps track of running contexts. + * @see #startContext(Context) + * @see #stopContext(Context) + */ + private static HashSet activeContexts = new HashSet(); + + /** + * A method to be called when a Context begins its execution. This adds the + * context to the context set. When the contenxt's execution completes, it should + * call {@link #stopContext} to clear up resources. + * @param ctx The context that is starting. + * @see #stopContext(Context) + */ + public static void startContext(Context ctx) { + + while (deinitializingEdt) { + // It is possible that deinitialize was called just before the + // last context was destroyed so there is a pending deinitialize + // working its way through the system. Give it some time + // before forcing the deinitialize + System.out.println("Waiting for deinitializing to complete before starting a new initialization"); + Util.sleep(30); + } + if (deinitializing && instance != null) { + instance.deinitialize(); + } + synchronized(activeContexts) { + activeContexts.add(ctx); + if (instance == null) { + // If this is our first rodeo, just call Display.init() as that should + // be sufficient to set everything up. + Display.init(ctx); + } else { + // If we've initialized before, we should "re-initialize" the implementation + // Reinitializing will force views to be created even if the EDT was already + // running in background mode. + reinit(ctx); + } + } + } + + /** + * Cleans up resources in the given context. This method should be called by + * any Activity or Service that called startContext() when it started. + * @param ctx The context to stop. + * + * @see #startContext(Context) + */ + public static void stopContext(Context ctx) { + synchronized(activeContexts) { + activeContexts.remove(ctx); + if (activeContexts.isEmpty()) { + // If we are the last context, we should deinitialize + syncDeinitialize(); + } else { + if (instance != null && getActivity() != null) { + // if this is an activity, then we should clean up + // our UI resources anyways because the last context + // to be cleaned up might not have access to the UI thread. + instance.deinitialize(); + } + } + } + } + + @Override + public void screenshot(SuccessCallback callback) { + final Activity activity = (Activity) getContext(); + final AndroidScreenshotTask task = new AndroidScreenshotTask(myView, activity, callback); + activity.runOnUiThread(task); + } + + @Override + public void setPlatformHint(String key, String value) { + if(key.equals("platformHint.compatPaintMode")) { + compatPaintMode = value.equalsIgnoreCase("true"); + return; + } + if(key.equals("platformHint.legacyPaint")) { + AndroidAsyncView.legacyPaintLogic = value.equalsIgnoreCase("true");; + } + } + + + /** + * This method in used internally for ads + * @param above shown above the view + * @param below shown below the view + */ + public static void setViewAboveBelow(View above, View below, int spacingAbove, int spacingBelow) { + viewBelow = below; + viewAbove = above; + aboveSpacing = spacingAbove; + belowSpacing = spacingBelow; + } + + static boolean hasViewAboveBelow(){ + return viewBelow != null || viewAbove != null; + } + + /** + * Copy the input stream into the output stream, closes both streams when finishing or in + * a case of an exception + * + * @param i source + * @param o destination + */ + private static void copy(InputStream i, OutputStream o) throws IOException { + copy(i, o, 8192); + } + + /** + * Copy the input stream into the output stream, closes both streams when finishing or in + * a case of an exception + * + * @param i source + * @param o destination + * @param bufferSize the size of the buffer, which should be a power of 2 large enoguh + */ + private static void copy(InputStream i, OutputStream o, int bufferSize) throws IOException { + try { + byte[] buffer = new byte[bufferSize]; + int size = i.read(buffer); + while(size > -1) { + o.write(buffer, 0, size); + size = i.read(buffer); + } + } finally { + sCleanup(o); + sCleanup(i); + } + } + + private static void sCleanup(Object o) { + try { + if(o != null) { + if(o instanceof InputStream) { + ((InputStream)o).close(); + return; + } + if(o instanceof OutputStream) { + ((OutputStream)o).close(); + return; + } + } + } catch(Throwable t) {} + } + + /** + * Copied here since the cleanup method in util would crash append notification that runs when the app isn't in the foreground + */ + private static byte[] readInputStream(InputStream i) throws IOException { + ByteArrayOutputStream b = new ByteArrayOutputStream(); + copy(i, b); + return b.toByteArray(); + } + + + public static void appendNotification(String type, String body, Context a) { + appendNotification(type, body, null, null, a); + } + + /** Receives the managed typed envelope from FCM without applying legacy push decoding. */ + public static void handleV3Push(final String envelope, Context context, + boolean appRunning, Class appStubClass) { + if (appRunning && Display.isInitialized() + && com.codename1.push.PushClient.hasActiveClient()) { + Display.getInstance().callSerially(new Runnable() { + public void run() { + com.codename1.push.PushClient.dispatch(envelope); + } + }); + return; + } + try { + org.json.JSONObject message = new org.json.JSONObject(envelope); + // The pending-push file explicitly encodes whether a legacy type is present. + // A missing type is the sentinel for a typed V3 envelope and is replayed intact. + appendNotification(null, envelope, context); + if (message.optBoolean("silent", false)) { + return; + } + String title = message.optString("title", ""); + String body = message.optString("body", ""); + String image = message.optString("image", ""); + if (title.length() == 0 && body.length() == 0 && image.length() == 0) { + return; + } + if (title.length() == 0) { + title = context.getApplicationInfo().loadLabel(context.getPackageManager()).toString(); + } + Intent intent = new Intent(context, appStubClass); + PendingIntent contentIntent = createPendingIntent(context, 0, intent); + int smallIcon = context.getResources().getIdentifier("ic_stat_notify", "drawable", + context.getPackageName()); + if (smallIcon == 0) { + smallIcon = context.getApplicationInfo().icon; + } + NotificationCompat.Builder builder = new NotificationCompat.Builder(context) + .setContentTitle(title) + .setContentText(body) + .setSmallIcon(smallIcon) + .setContentIntent(contentIntent) + .setAutoCancel(true) + .setWhen(System.currentTimeMillis()); + NotificationManager manager = (NotificationManager) + context.getSystemService(Context.NOTIFICATION_SERVICE); + setNotificationChannel(manager, builder, context); + String collapseKey = message.optString("collapseKey", null); + String messageId = message.optString("id", null); + String notificationTag; + if (collapseKey != null && collapseKey.length() > 0) { + notificationTag = v3NotificationTag("CN1_PUSH_V3_COLLAPSE:", collapseKey); + } else if (messageId != null && messageId.length() > 0) { + notificationTag = v3NotificationTag("CN1_PUSH_V3_MESSAGE:", messageId); + } else { + notificationTag = "CN1_PUSH_V3_EPHEMERAL:" + System.currentTimeMillis() + + ":" + V3_NOTIFICATION_SEQUENCE.incrementAndGet(); + } + manager.notify(notificationTag, 0, builder.build()); + } catch (Exception error) { + Log.e("Codename One", "Failed to handle a Push V3 envelope", error); + } + } + + private static String v3NotificationTag(String prefix, String value) { + if (prefix.length() + value.length() <= 128) { + return prefix + value; + } + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder out = new StringBuilder(prefix.length() + digest.length * 2); + out.append(prefix); + for (byte item : digest) { + int unsigned = item & 0xff; + if (unsigned < 0x10) { + out.append('0'); + } + out.append(Integer.toHexString(unsigned)); + } + return out.toString(); + } catch (Exception error) { + return prefix + Integer.toHexString(value.hashCode()); + } + } + + public static void appendNotification(String type, String body, String image, String category, Context a) { + try { + String[] fileList = a.fileList(); + byte[] data = null; + for (int iter = 0; iter < fileList.length; iter++) { + if (fileList[iter].equals("CN1$AndroidPendingNotifications")) { + InputStream is = a.openFileInput("CN1$AndroidPendingNotifications"); + if(is != null) { + data = readInputStream(is); + sCleanup(a); + break; + } + } + } + DataOutputStream os = new DataOutputStream(a.openFileOutput("CN1$AndroidPendingNotifications", 0)); + if(data != null) { + data[0]++; + os.write(data); + } else { + os.writeByte(1); + } + String bodyType = type; + if (image != null || category != null) { + type = "99"; + } + if(type != null) { + os.writeBoolean(true); + os.writeUTF(type); + } else { + os.writeBoolean(false); + } + if ("99".equals(type)) { + String msg = "body="+java.net.URLEncoder.encode(body, "UTF-8") + +"&type="+java.net.URLEncoder.encode(bodyType, "UTF-8"); + if (category != null) { + msg += "&category="+java.net.URLEncoder.encode(category, "UTF-8"); + } + if (image != null) { + msg += "&image="+java.net.URLEncoder.encode(image, "UTF-8"); + } + os.writeUTF(msg); + + } else { + os.writeUTF(body); + } + os.writeLong(System.currentTimeMillis()); + } catch(IOException err) { + err.printStackTrace(); + } + } + + private static Map splitQuery(String urlencodeQueryString) { + String[] parts = urlencodeQueryString.split("&"); + Map out = new HashMap(); + for (String part : parts) { + int pos = part.indexOf("="); + String k,v; + if (pos > 0) { + k = part.substring(0, pos); + v = part.substring(pos+1); + } else { + k = part; + v = ""; + } + try { + k = java.net.URLDecoder.decode(k, "UTF-8"); + v = java.net.URLDecoder.decode(v, "UTF-8"); + } catch (UnsupportedEncodingException ex) { + // won't happen + com.codename1.io.Log.e(ex); + } + out.put(k, v); + } + return out; + } + + public String getStackTrace(Thread parentThread, Throwable t) { + System.out.println("CN1SS:ERR:Invoking getStackTrace in AndroidImplementation"); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + PrintWriter w = new PrintWriter(new OutputStreamWriter(bos, StandardCharsets.UTF_8)); + t.printStackTrace(w); + w.close(); + System.out.println("CN1SS:ERR:AndroidImplementation getStackTrace completed"); + return new String(bos.toByteArray(), StandardCharsets.UTF_8); + } + + public static void initPushContent(String message, String image, String messageType, String category, Context context) { + com.codename1.push.PushContent.reset(); + + int iMessageType = 1; + try {iMessageType = Integer.parseInt(messageType);}catch(Throwable t){} + + String actionId = null; + String reply = null; + boolean cancel = true; + if (context instanceof Activity) { + Activity activity = (Activity)context; + Bundle extras = activity.getIntent().getExtras(); + if (extras != null) { + actionId = extras.getString("pushActionId"); + extras.remove("pushActionId"); + + if (actionId != null && RemoteInputWrapper.isSupported()) { + Bundle textExtras = RemoteInputWrapper.getResultsFromIntent(activity.getIntent()); + if (textExtras != null) { + CharSequence cs = textExtras.getCharSequence(actionId + "$Result"); + if (cs != null) { + reply = cs.toString(); + } + } + + + } + } + + } + if (cancel) { + PushNotificationService.cancelNotification(context); + } + com.codename1.push.PushContent.setType(iMessageType); + com.codename1.push.PushContent.setCategory(category); + if (actionId != null) { + com.codename1.push.PushContent.setActionId(actionId); + } + if (reply != null) { + com.codename1.push.PushContent.setTextResponse(reply); + } + switch (iMessageType) { + case 1: + case 5: + com.codename1.push.PushContent.setBody(message);break; + case 2: com.codename1.push.PushContent.setMetaData(message);break; + case 3: { + String[] parts = message.split(";"); + com.codename1.push.PushContent.setMetaData(parts[1]); + com.codename1.push.PushContent.setBody(parts[0]); + break; + } + case 4: { + String[] parts = message.split(";"); + com.codename1.push.PushContent.setTitle(parts[0]); + com.codename1.push.PushContent.setBody(parts[1]); + break; + } + case 101: { + com.codename1.push.PushContent.setBody(message.substring(message.indexOf(" ") + 1)); + com.codename1.push.PushContent.setType(1); + break; + } + case 102: { + String[] parts = message.split(";"); + com.codename1.push.PushContent.setTitle(parts[1]); + com.codename1.push.PushContent.setBody(parts[2]); + com.codename1.push.PushContent.setType(2); + break; + } + } + } + + // Name of file where we install the push notification categories as an XML file + // if the main class implements PushActiosProvider + private static String FILE_NAME_NOTIFICATION_CATEGORIES = "CN1$AndroidNotificationCategories"; + + + + /** + * Action categories are defined on the Main class by implementing the PushActionsProvider, however + * the main class may not be available to the push receiver, so we need to save these categories + * to the file system when the app is installed, then the push receiver can load these actions + * when it sends a push while the app isn't running. + * @param provider A reference to the App's main class + * @throws IOException + */ + public static void installNotificationActionCategories(PushActionsProvider provider) throws IOException { + // Assume that CN1 is running... this will run when the app starts + // up + Context context = getContext(); + boolean requiresUpdate = false; + + File categoriesFile = new File(activity.getFilesDir().getAbsolutePath() + "/" + FILE_NAME_NOTIFICATION_CATEGORIES); + if (!categoriesFile.exists()) { + requiresUpdate = true; + } + if (!requiresUpdate) { + try { + PackageInfo packageInfo = context.getPackageManager().getPackageInfo(context.getApplicationContext().getPackageName(), PackageManager.GET_PERMISSIONS); + if (packageInfo.lastUpdateTime > categoriesFile.lastModified()) { + requiresUpdate = true; + } + } catch (Exception ex) { + ex.printStackTrace(); + } + } + + if (!requiresUpdate) { + return; + } + + OutputStream os = getContext().openFileOutput(FILE_NAME_NOTIFICATION_CATEGORIES, 0); + PushActionCategory[] categories = provider.getPushActionCategories(); + javax.xml.parsers.DocumentBuilderFactory docFactory = javax.xml.parsers.DocumentBuilderFactory.newInstance(); + javax.xml.parsers.DocumentBuilder docBuilder; + try { + docBuilder = docFactory.newDocumentBuilder(); + } catch (ParserConfigurationException ex) { + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + throw new IOException("Faield to create document builder for creating notification categories XML document", ex); + } + + // root elements + org.w3c.dom.Document doc = docBuilder.newDocument(); + org.w3c.dom.Element root = (org.w3c.dom.Element)doc.createElement("categories"); + doc.appendChild(root); + for (PushActionCategory category : categories) { + org.w3c.dom.Element categoryEl = (org.w3c.dom.Element)doc.createElement("category"); + org.w3c.dom.Attr idAttr = doc.createAttribute("id"); + idAttr.setValue(category.getId()); + categoryEl.setAttributeNode(idAttr); + + for (PushAction action : category.getActions()) { + org.w3c.dom.Element actionEl = (org.w3c.dom.Element)doc.createElement("action"); + org.w3c.dom.Attr actionIdAttr = doc.createAttribute("id"); + actionIdAttr.setValue(action.getId()); + actionEl.setAttributeNode(actionIdAttr); + + + org.w3c.dom.Attr actionTitleAttr = doc.createAttribute("title"); + if (action.getTitle() != null) { + actionTitleAttr.setValue(action.getTitle()); + } else { + actionTitleAttr.setValue(action.getId()); + } + actionEl.setAttributeNode(actionTitleAttr); + + if (action.getIcon() != null) { + org.w3c.dom.Attr actionIconAttr = doc.createAttribute("icon"); + String iconVal = action.getIcon(); + try { + // We'll store the resource IDs for the icon + // rather than the icon name because that is what + // the push notifications require. + iconVal = ""+context.getResources().getIdentifier(iconVal, "drawable", context.getPackageName()); + actionIconAttr.setValue(iconVal); + actionEl.setAttributeNode(actionIconAttr); + } catch (Exception ex) { + ex.printStackTrace(); + + } + + } + + if (action.getTextInputPlaceholder() != null) { + org.w3c.dom.Attr textInputPlaceholderAttr = doc.createAttribute("textInputPlaceholder"); + textInputPlaceholderAttr.setValue(action.getTextInputPlaceholder()); + actionEl.setAttributeNode(textInputPlaceholderAttr); + } + if (action.getTextInputButtonText() != null) { + org.w3c.dom.Attr textInputButtonTextAttr = doc.createAttribute("textInputButtonText"); + textInputButtonTextAttr.setValue(action.getTextInputButtonText()); + actionEl.setAttributeNode(textInputButtonTextAttr); + } + categoryEl.appendChild(actionEl); + } + root.appendChild(categoryEl); + + } + try { + javax.xml.transform.TransformerFactory transformerFactory = javax.xml.transform.TransformerFactory.newInstance(); + javax.xml.transform.Transformer transformer = transformerFactory.newTransformer(); + javax.xml.transform.dom.DOMSource source = new javax.xml.transform.dom.DOMSource(doc); + javax.xml.transform.stream.StreamResult result = new javax.xml.transform.stream.StreamResult(os); + transformer.transform(source, result); + + } catch (Exception ex) { + throw new IOException("Failed to save notification categories as XML.", ex); + } + + } + + /** + * Retrieves the app's available push action categories from the XML file in which they + * should have been installed on the first load. + * @param context + * @return + * @throws IOException + */ + private static PushActionCategory[] getInstalledPushActionCategories(Context context) throws IOException { + // NOTE: This method may be called from the PushReceiver when the app isn't running so we can't access + // the main activity context, display properties, or any CN1 stuff. Just native android + + File categoriesFile = new File(context.getFilesDir().getAbsolutePath() + "/" + FILE_NAME_NOTIFICATION_CATEGORIES); + if (!categoriesFile.exists()) { + return new PushActionCategory[0]; + } + javax.xml.parsers.DocumentBuilderFactory docFactory = javax.xml.parsers.DocumentBuilderFactory.newInstance(); + javax.xml.parsers.DocumentBuilder docBuilder; + try { + docBuilder = docFactory.newDocumentBuilder(); + } catch (ParserConfigurationException ex) { + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + throw new IOException("Faield to create document builder for creating notification categories XML document", ex); + } + org.w3c.dom.Document doc; + try { + doc = docBuilder.parse(context.openFileInput(FILE_NAME_NOTIFICATION_CATEGORIES)); + } catch (SAXException ex) { + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + throw new IOException("Failed to parse instaled push action categories", ex); + } + org.w3c.dom.Element root = doc.getDocumentElement(); + java.util.List out = new ArrayList(); + org.w3c.dom.NodeList l = root.getElementsByTagName("category"); + int len = l.getLength(); + for (int i=0; i actions = new ArrayList(); + org.w3c.dom.NodeList al = el.getElementsByTagName("action"); + int alen = al.getLength(); + for (int j=0; j= 23) { + return PendingIntent.getActivity(ctx, value, intent, FLAG_IMMUTABLE); + } else { + return PendingIntent.getActivity(ctx, value, intent, PendingIntent.FLAG_CANCEL_CURRENT); + } + } + + public static PendingIntent createMutablePendingIntent(Context ctx, int value, Intent intent) { + if (android.os.Build.VERSION.SDK_INT >= 23) { + return PendingIntent.getActivity(ctx, value, intent, FLAG_MUTABLE); + } else { + return PendingIntent.getActivity(ctx, value, intent, PendingIntent.FLAG_CANCEL_CURRENT); + } + } + + public static PendingIntent getPendingIntent(Context ctx, int value, Intent intent) { + if (android.os.Build.VERSION.SDK_INT >= 23) { + return PendingIntent.getService(ctx, value, intent, FLAG_IMMUTABLE); + } else { + return PendingIntent.getService(ctx, value, intent, PendingIntent.FLAG_CANCEL_CURRENT); + } + } + + public static PendingIntent getBroadcastPendingIntent(Context ctx, int value, Intent intent) { + if (android.os.Build.VERSION.SDK_INT >= 23) { + // PendingIntent.FLAG_IMMUTABLE + return PendingIntent.getBroadcast(ctx, value, intent, 67108864); + } else { + return PendingIntent.getBroadcast(ctx, value, intent, PendingIntent.FLAG_CANCEL_CURRENT); + } + } + + /** + * Adds actions to a push notification. This is called by the Push broadcast receiver probably before + * Codename One is initialized + * @param provider Reference to the app's main class which implements PushActionsProvider + * @param categoryId The category ID of the push notification. + * @param builder The builder for the push notification. + * @param targetIntent The target intent... this should go to the app's main Activity. + * @param context The current context (inside the Broadcast receiver). + * @throws IOException + */ + public static void addActionsToNotification(PushActionsProvider provider, String categoryId, NotificationCompat.Builder builder, Intent targetIntent, Context context) throws IOException { + // NOTE: THis will likely run when the main activity isn't running so we won't have + // access to any display properties... just native Android APIs will be accessible. + + PushActionCategory category = null; + PushActionCategory[] categories; + if (provider != null) { + categories = provider.getPushActionCategories(); + } else { + categories = getInstalledPushActionCategories(context); + } + for (PushActionCategory candidateCategory : categories) { + if (categoryId.equals(candidateCategory.getId())) { + category = candidateCategory; + break; + } + } + if (category == null) { + return; + } + + int requestCode = 1; + for (PushAction action : category.getActions()) { + Intent newIntent = (Intent)targetIntent.clone(); + newIntent.putExtra("pushActionId", action.getId()); + PendingIntent contentIntent = createMutablePendingIntent(context, requestCode++, newIntent); + try { + int iconId; + try { + iconId = Integer.parseInt(action.getIcon()); + } catch (NumberFormatException ex) { + iconId = 0; + } + if (ActionWrapper.BuilderWrapper.isSupported()) { + // We need to take this abstracted "wrapper" approach because the Action.Builder class, and RemoteInput class + // aren't available until API 22. + // These classes use reflection to provide support for these classes safely. + ActionWrapper.BuilderWrapper actionBuilder = new ActionWrapper.BuilderWrapper(iconId, action.getTitle(), contentIntent); + if (action.getTextInputPlaceholder() != null && RemoteInputWrapper.isSupported()) { + RemoteInputWrapper.BuilderWrapper remoteInputBuilder = new RemoteInputWrapper.BuilderWrapper(action.getId()+"$Result"); + remoteInputBuilder.setLabel(action.getTextInputPlaceholder()); + + RemoteInputWrapper remoteInput = remoteInputBuilder.build(); + actionBuilder.addRemoteInput(remoteInput); + } + ActionWrapper actionWrapper = actionBuilder.build(); + new NotificationCompatWrapper.BuilderWrapper(builder).addAction(actionWrapper); + } else { + builder.addAction(iconId, action.getTitle(), contentIntent); + } + } catch (Exception ex) { + ex.printStackTrace(); + } + } + + } + + public static void firePendingPushes(final PushCallback c, final Context a) { + try { + if(c != null) { + InputStream i = a.openFileInput("CN1$AndroidPendingNotifications"); + if(i == null) { + return; + } + DataInputStream is = new DataInputStream(i); + int count = is.readByte(); + for(int iter = 0 ; iter < count ; iter++) { + boolean hasType = is.readBoolean(); + String actualType = null; + if(hasType) { + actualType = is.readUTF(); + } + final String t; + final String b; + final String category; + final String image; + if ("99".equals(actualType)) { + // This was a rich push + Map vals = splitQuery(is.readUTF()); + t = vals.get("type"); + b = vals.get("body"); + category = vals.get("category"); + image = vals.get("image"); + } else { + t = actualType; + b = is.readUTF(); + category = null; + image = null; + } + long s = is.readLong(); + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + Display.getInstance().setProperty("pendingPush", "true"); + Display.getInstance().setProperty("pushType", t); + initPushContent(b, image, t, category, a); + if(t != null && ("3".equals(t) || "6".equals(t))) { + String[] a = b.split(";"); + c.push(a[0]); + c.push(a[1]); + } else if (t != null && ("101".equals(t))) { + c.push(b.substring(b.indexOf(" ")+1)); + } else { + c.push(b); + } + Display.getInstance().setProperty("pendingPush", null); + } + }); + } + a.deleteFile("CN1$AndroidPendingNotifications"); + } + } catch(IOException err) { + } + } + + public static String[] getPendingPush(String type, Context a) { + InputStream i = null; + try { + i = a.openFileInput("CN1$AndroidPendingNotifications"); + if (i == null) { + return null; + } + DataInputStream is = new DataInputStream(i); + int count = is.readByte(); + Vector v = new Vector(); + for (int iter = 0; iter < count; iter++) { + boolean hasType = is.readBoolean(); + String actualType = null; + if (hasType) { + actualType = is.readUTF(); + } + + final String t; + final String b; + if ("99".equals(actualType)) { + // This was a rich push + Map vals = splitQuery(is.readUTF()); + t = vals.get("type"); + b = vals.get("body"); + //category = vals.get("category"); + //image = vals.get("image"); + } else { + t = actualType; + b = is.readUTF(); + //category = null; + //image = null; + } + long s = is.readLong(); + if(t != null && ("3".equals(t) || "6".equals(t))) { + String[] m = b.split(";"); + v.add(m[0]); + } else if(t != null && "4".equals(t)){ + String[] m = b.split(";"); + v.add(m[1]); + } else if(t != null && "2".equals(t)){ + continue; + }else if (t != null && "101".equals(t)) { + v.add(b.substring(b.indexOf(" ")+1)); + }else{ + v.add(b); + } + } + String [] retVal = new String[v.size()]; + for (int j = 0; j < retVal.length; j++) { + retVal[j] = (String)v.get(j); + } + return retVal; + + } catch (Exception ex) { + ex.printStackTrace(); + } finally { + try { + if(i != null){ + i.close(); + } + } catch (IOException ex) { + } + } + return null; + } + + private static AndroidImplementation instance; + private static final String INTENT_PROPERTY_PREFIX = "android.intent."; + private static final String INTENT_EXTRA_PROPERTY_PREFIX = "android.intent.extra."; + private static final Set intentPropertyKeys = new HashSet(); + private static final Object intentPropertyLock = new Object(); + private static Intent lastPublishedIntent; + + public static AndroidImplementation getInstance() { + return instance; + } + + public static void clearAppArg() { + if (instance != null) { + instance.setAppArg(null); + clearIntentProperties(); + } + } + + /// Delivers a link that arrived at an already-running activity, so the + /// router sees it on Android as it already does on iOS. + /// + /// The two ports were asymmetric here, and silently so. iOS routes every + /// deep link through `Display.setProperty("AppArg", url)`, which fires + /// [com.codename1.router.Navigation#dispatchExternalUrl]. Android's + /// `onNewIntent` only stored the intent, and [#getAppArg] then derived + /// the value lazily through the implementation's own setter -- so + /// `setProperty` never ran and the router never fired. Anything built on + /// `@Route` therefore worked on iOS and did nothing on Android, which + /// reads as a feature that "just doesn't convert" on the platform rather + /// than as a bug. + /// + /// Deliberately narrow. Only `ACTION_VIEW` with an http or https scheme + /// goes through here; `EXTRA_TEXT` shares, `content://` attachments and + /// `EXTRA_STREAM` payloads keep their existing lazy path. Dispatching for + /// every intent would double-fire against the `setAppArg` inside + /// [#getAppArg] and would change behaviour for every share-target + /// application in the field. + /// + /// #### Parameters + /// + /// - `intent`: the intent delivered to the running activity + static void dispatchNewIntentUrl(Intent intent) { + if (intent == null || instance == null || !Display.isInitialized()) { + return; + } + try { + if (!Intent.ACTION_VIEW.equals(intent.getAction())) { + return; + } + android.net.Uri data = intent.getData(); + if (data == null) { + return; + } + String scheme = data.getScheme(); + if (!"http".equals(scheme) && !"https".equals(scheme)) { + return; + } + // Cleared first so the value below is what getAppArg() reports, + // rather than whatever the previous intent left cached. + instance.setAppArg(null); + clearIntentProperties(); + // The intent is stored UNMODIFIED, and the url is marked as delivered by + // remembering the intent's identity instead of by erasing its data. + // + // Two earlier shapes were both wrong. Clearing the data on the intent + // passed in broke the ordinary way to extend onNewIntent() -- + // super.onNewIntent(intent) followed by the subclass reading + // intent.getData(), which had just been nulled underneath it. Storing a + // data-less COPY fixed that one and broke two more readers: the + // documented `android.intent.data` property is published from whatever + // the activity has stored, and native integrations read + // getActivity().getIntent().getData() after onNewIntent(). Both saw a + // warm deep link as no deep link at all while cold links still carried + // it -- an asymmetry an application has no way to work around. + // + // What actually has to be suppressed is narrower than the data: only + // getAppArg()'s rebuilding of the url from the stored intent, because + // CodenameOneActivity.onStop() clears the app arg and the next read + // after a resume would otherwise report the same deep link a second + // time and open one tapped invite twice. + getActivity().setIntent(intent); + markAppArgDelivered(intent); + // Published here rather than left to getAppArg(), since the properties + // for the previous intent were just cleared and the reader that used to + // repopulate them lazily is exactly the one now suppressed. + publishIntentProperties(getActivity(), intent); + Display.getInstance().setProperty("AppArg", data.toString()); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + /// Identity of the intent whose url [#dispatchNewIntentUrl] already delivered as + /// the app arg. Weak because it needs to outlive nothing: the activity holds the + /// intent, and once it stores a different one this reference is free to go. + private static java.lang.ref.WeakReference deliveredAppArgIntent; + + private static void markAppArgDelivered(Intent intent) { + synchronized (intentPropertyLock) { + deliveredAppArgIntent = new java.lang.ref.WeakReference(intent); + } + } + + private static boolean isAppArgDelivered(Intent intent) { + synchronized (intentPropertyLock) { + return deliveredAppArgIntent != null && deliveredAppArgIntent.get() == intent; + } + } + + private static void clearIntentProperties() { + synchronized (intentPropertyLock) { + if (Display.isInitialized()) { + for (String key : new ArrayList(intentPropertyKeys)) { + Display.getInstance().setProperty(key, null); + } + } + intentPropertyKeys.clear(); + lastPublishedIntent = null; + } + } + + private static void publishIntentProperties(Activity activity, Intent intent) { + if (intent == null) { + return; + } + + synchronized (intentPropertyLock) { + if (intent == lastPublishedIntent) { + return; + } + + Map nextProperties = new HashMap(); + nextProperties.put(INTENT_PROPERTY_PREFIX + "action", intent.getAction()); + nextProperties.put(INTENT_PROPERTY_PREFIX + "data", intent.getDataString()); + nextProperties.put(INTENT_PROPERTY_PREFIX + "type", intent.getType()); + + // Only getCallingPackage() is a verified caller identity. Referrer values are caller-controlled. + String callerPackage = activity.getCallingPackage(); + nextProperties.put(INTENT_PROPERTY_PREFIX + "caller", callerPackage); + nextProperties.put(INTENT_PROPERTY_PREFIX + "caller.verified", callerPackage != null ? "true" : "false"); + + Bundle extras = intent.getExtras(); + if (extras != null) { + for (String key : extras.keySet()) { + Object value = extras.get(key); + String propertyKey = key.startsWith(INTENT_EXTRA_PROPERTY_PREFIX) ? key : INTENT_EXTRA_PROPERTY_PREFIX + key; + nextProperties.put(propertyKey, value == null ? null : String.valueOf(value)); + } + } + + if (Display.isInitialized()) { + ArrayList keysToRemove = new ArrayList(); + for (String key : intentPropertyKeys) { + if (!nextProperties.containsKey(key)) { + keysToRemove.add(key); + } + } + for (String key : keysToRemove) { + Display.getInstance().setProperty(key, null); + intentPropertyKeys.remove(key); + } + for (Map.Entry entry : nextProperties.entrySet()) { + Display.getInstance().setProperty(entry.getKey(), entry.getValue()); + intentPropertyKeys.add(entry.getKey()); + } + } else { + intentPropertyKeys.clear(); + intentPropertyKeys.addAll(nextProperties.keySet()); + } + + lastPublishedIntent = intent; + } + } + + public static Context getContext() { + Context out = getActivity(); + if (out != null) { + return out; + } + return context; + } + + public void setContext(Context c) { + context = c; + } + + @Override + public void init(Object m) { + // NOTE: Do not explicitly set the PlayServices instance to anything other than + // an instance of the base PlayServices class. The Build Server will automatically + // swap this for the appropriate subclass depending on the playServicesVersion of + // the build. + PlayServices.setInstance(new PlayServices()); // <---- DO NOT CHANGE - Build server will replace with appropriate subclass instance + if (m instanceof CodenameOneActivity) { + setContext(null); + setActivity((CodenameOneActivity) m); + } else { + setActivity(null); + setContext((Context)m); + } + // The nearby bridge is cached for the life of the process while + // Android recreates the activity freely -- a configuration change, + // or "Don't keep activities". An association chooser opened by the + // old activity delivers its result to the NEW one, where the + // backend's result listener is not installed, so the association + // resource never settled and every later association answered BUSY. + // Told here because this is the one place that knows it changed. + if (nearbyBridge != null) { + nearbyBridge.onActivityChanged(); + } + + instance = this; + if(getActivity() != null && getActivity().hasUI()){ + if (!hasActionBar()) { + try { + getActivity().requestWindowFeature(Window.FEATURE_NO_TITLE); + } catch (Exception e) { + com.codename1.io.Log.p("requestWindowFeature FEATURE_NO_TITLE threw exception: " + e.toString()); + } + } else { + getActivity().invalidateOptionsMenu(); + try { + getActivity().requestWindowFeature(Window.FEATURE_ACTION_BAR); + getActivity().requestWindowFeature(Window.FEATURE_PROGRESS); + + if(android.os.Build.VERSION.SDK_INT >= 21){ + //WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS + getActivity().getWindow().addFlags(-2147483648); + } + } catch (Exception e) { + //Log.d("Codename One", "No idea why this throws a Runtime Error", e); + } + NotifyActionBar notify = new NotifyActionBar(getActivity(), false); + notify.run(); + } + + if(statusBarHidden) { + getActivity().getWindow().getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN); + getActivity().getWindow().setStatusBarColor(android.graphics.Color.TRANSPARENT); + } + + if(Display.getInstance().getProperty("StatusbarHidden", "").equals("true")){ + getActivity().getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN); + } + + if(Display.getInstance().getProperty("KeepScreenOn", "").equals("true")){ + getActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + + if(Display.getInstance().getProperty("DisableScreenshots", "").equals("true")){ + getActivity().getWindow().addFlags(WindowManager.LayoutParams.FLAG_SECURE); + } + + if (m instanceof CodenameOneActivity) { + ((CodenameOneActivity) m).setDefaultIntentResultListener(this); + ((CodenameOneActivity) m).setIntentResultListener(this); + } + + /** + * translate our default font height depending on the screen density. + * this is required for new high resolution devices. otherwise + * everything looks awfully small. + * + * we use our default font height value of 16 and go from there. i + * thought about using new Paint().getTextSize() for this value but if + * some new version of android suddenly returns values already tranlated + * to the screen then we might end up with too large fonts. the + * documentation is not very precise on that. + */ + final int defaultFontPixelHeight = 16; + this.defaultFontHeight = this.translatePixelForDPI(defaultFontPixelHeight); + + + this.defaultFont = (CodenameOneTextPaint) ((NativeFont) this.createFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_MEDIUM)).font; + Display.getInstance().setTransitionYield(-1); + + initSurface(); + /** + * devices are extremely sensitive so dragging should start a little + * later than suggested by default implementation. + */ + this.setDragStartPercentage(1); + VirtualKeyboardInterface vkb = new AndroidKeyboard(this); + Display.getInstance().registerVirtualKeyboard(vkb); + Display.getInstance().setDefaultVirtualKeyboard(vkb); + + InPlaceEditView.endEdit(); + + getActivity().getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN); + + if (nativePeers.size() > 0) { + for (int i = 0; i < nativePeers.size(); i++) { + ((AndroidImplementation.AndroidPeer) nativePeers.elementAt(i)).init(); + } + } + } else { + /** + * translate our default font height depending on the screen density. + * this is required for new high resolution devices. otherwise + * everything looks awfully small. + * + * we use our default font height value of 16 and go from there. i + * thought about using new Paint().getTextSize() for this value but if + * some new version of android suddenly returns values already tranlated + * to the screen then we might end up with too large fonts. the + * documentation is not very precise on that. + */ + final int defaultFontPixelHeight = 16; + this.defaultFontHeight = this.translatePixelForDPI(defaultFontPixelHeight); + + + this.defaultFont = (CodenameOneTextPaint) ((NativeFont) this.createFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_MEDIUM)).font; + } + HttpURLConnection.setFollowRedirects(false); + CookieHandler.setDefault(null); + VideoCaptureConstraints.init(new AndroidVideoCaptureConstraintsCompiler()); + } + + + + @Override + public boolean isInitialized(){ +// Removing the check for null view to prevent strange things from happening when +// calling from a Service context. +// if(getActivity() != null && myView == null){ +// //if the view is null deinitialize the Display +// if(super.isInitialized()){ +// syncDeinitialize(); +// } +// return false; +// } + return super.isInitialized(); + } + + /** + * Reinitializes CN1. + * @param i Context to initialize it with. + * + * @see #startContext(Context) + */ + private static void reinit(Object i) { + if (instance != null && ((i instanceof CodenameOneActivity) || instance.myView == null)) { + instance.init(i); + } + Display.init(i); + + // This is a hack to fix an issue that caused the screen to appear blank when + // the app is loaded from memory after being unloaded. + + // This issue only seems to occur when the Activity had been unloaded + // so to test this you'll need to check the "Don't keep activities" checkbox under/ + // Developer options. + // Developer options. + Display.getInstance().callSerially(new Runnable() { + public void run() { + Display.getInstance().invokeAndBlock(new Runnable(){ public void run(){ + Util.sleep(50); + }}); + if (!Display.isInitialized() || Display.getInstance().isMinimized()) { + return; + } + Form cur = Display.getInstance().getCurrent(); + if (cur != null) { + cur.forceRevalidate(); + } + } + + }); + } + + private static class InvalidateOptionsMenuImpl implements Runnable { + private Activity activity; + + public InvalidateOptionsMenuImpl(Activity activity) { + this.activity = activity; + } + + @Override + public void run() { + activity.invalidateOptionsMenu(); + } + } + + @Override + public Boolean isDarkMode() { + try { + int nightModeFlags = getActivity().getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; + switch (nightModeFlags) { + case Configuration.UI_MODE_NIGHT_YES: + return true; + case Configuration.UI_MODE_NIGHT_NO: + return false; + default: + return null; + } + } catch(Throwable t) { + return null; + } + } + + @Override + public boolean isLargerTextEnabled() { + return getLargerTextScale() > 1.0f; + } + + @Override + public float getLargerTextScale() { + try { + Configuration configuration; + if (getActivity() != null) { + configuration = getActivity().getResources().getConfiguration(); + } else { + configuration = getContext().getResources().getConfiguration(); + } + return configuration.fontScale; + } catch (Throwable t) { + return 1.0f; + } + } + + + private boolean hasActionBar() { + return android.os.Build.VERSION.SDK_INT >= 11; + } + + public int translatePixelForDPI(int pixel) { + return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, pixel, + getContext().getResources().getDisplayMetrics()); + } + + /** + * Returns the platform EDT thread priority + */ + public int getEDTThreadPriority(){ + return Thread.NORM_PRIORITY; + } + + /// Android reports this directly as DisplayMetrics.density, so there is no + /// need to make callers derive it from the density bucket -- the bucket is a + /// coarse DPI band and rounds to a different number than the scale the + /// platform itself lays out with. + /// + /// Read the same way getDeviceDensity does, preferring the activity's own + /// display, because a multi-display device can have a different scale per + /// display and the resources copy is the default one. + @Override + public float getDevicePixelRatio() { + DisplayMetrics metrics = new DisplayMetrics(); + if (getActivity() != null) { + getActivity().getWindowManager().getDefaultDisplay().getMetrics(metrics); + } else if (getContext() != null) { + metrics = getContext().getResources().getDisplayMetrics(); + } else { + return super.getDevicePixelRatio(); + } + // 0 means "not reported", which is what the portable contract expects. + return metrics.density > 0 ? metrics.density : super.getDevicePixelRatio(); + } + + @Override + public int getDeviceDensity() { + DisplayMetrics metrics = new DisplayMetrics(); + if (getActivity() != null) { + getActivity().getWindowManager().getDefaultDisplay().getMetrics(metrics); + } else { + metrics = getContext().getResources().getDisplayMetrics(); + } + + int dpi = metrics.densityDpi; + if (dpi < DisplayMetrics.DENSITY_MEDIUM) { + return Display.DENSITY_LOW; + } + if (dpi < 213) { + return Display.DENSITY_MEDIUM; + } + // 213 == TV + if (dpi <= DisplayMetrics.DENSITY_HIGH) { + return Display.DENSITY_HIGH; + } + if (dpi < 400) { + return Display.DENSITY_VERY_HIGH; + } + if (dpi < 560) { + return Display.DENSITY_HD; + } + if (dpi <= 640) { + return Display.DENSITY_2HD; + } + return Display.DENSITY_4K; + } + + public static boolean isImmersive() { + if (getActivity() == null) { + return false; + } + return isImmersive(getActivity().getWindow()); + } + public static boolean isImmersive(Window window) { + if (Build.VERSION.SDK_INT >= 35) { + // Android 15+ is always immersive (overlay mode by default) + return true; + } + // On Android 34 and below, we can't detect decorFitsSystemWindows + // reliably at runtime. So the app must make the decision explicitly. + return false; + } + public static Rect getSystemBarInsets(final View rootView) { + final Rect result = new Rect(0, 0, 0, 0); + try { + Object insets = View.class + .getMethod("getRootWindowInsets") + .invoke(rootView); + if (insets == null) return result; + // Get android.view.WindowInsets$Type.systemBars() + Class typeClass = Class.forName("android.view.WindowInsets$Type"); + int systemBarsMask = ((Integer) typeClass + .getMethod("systemBars") + .invoke(null)).intValue(); + // Call insets.getInsets(int) + Object insetsObject = insets.getClass() + .getMethod("getInsets", new Class[]{int.class}) + .invoke(insets, new Object[]{systemBarsMask}); + if (insetsObject == null) return result; + Class insetsClass = insetsObject.getClass(); + int left = ((Integer) insetsClass.getField("left").get(insetsObject)).intValue(); + int top = ((Integer) insetsClass.getField("top").get(insetsObject)).intValue(); + int right = ((Integer) insetsClass.getField("right").get(insetsObject)).intValue(); + int bottom = ((Integer) insetsClass.getField("bottom").get(insetsObject)).intValue(); + // Include mandatory gesture insets (e.g. gesture navigation handle area). + // Some devices expose a larger interaction-protected bottom region here + // than in plain system bar insets. + try { + int mandatoryGesturesMask = ((Integer) typeClass + .getMethod("mandatorySystemGestures") + .invoke(null)).intValue(); + Object mandatoryInsetsObject = insets.getClass() + .getMethod("getInsets", new Class[]{int.class}) + .invoke(insets, new Object[]{mandatoryGesturesMask}); + if (mandatoryInsetsObject != null) { + Class mandatoryInsetsClass = mandatoryInsetsObject.getClass(); + left = Math.max(left, ((Integer) mandatoryInsetsClass.getField("left").get(mandatoryInsetsObject)).intValue()); + top = Math.max(top, ((Integer) mandatoryInsetsClass.getField("top").get(mandatoryInsetsObject)).intValue()); + right = Math.max(right, ((Integer) mandatoryInsetsClass.getField("right").get(mandatoryInsetsObject)).intValue()); + bottom = Math.max(bottom, ((Integer) mandatoryInsetsClass.getField("bottom").get(mandatoryInsetsObject)).intValue()); + } + } catch (Throwable t) { + // Ignore if mandatory gesture insets are unavailable. + } + result.set(left, top, right, bottom); + } catch (Throwable t) { + t.printStackTrace(); // Optional: log this or suppress if expected + } + return result; + } + + + public Rectangle getDisplaySafeArea(Rectangle rect) { + if (rect == null) { + rect = new Rectangle(); + } + if (getProperty("android.useSafeAreaInsets", "true").equals("false")) { + return super.getDisplaySafeArea(rect); + } + if (this.myView != null) { + rect.setBounds( + this.myView.getSafeAreaInsets().left, + this.myView.getSafeAreaInsets().top, + getDisplayWidth() - this.myView.getSafeAreaInsets().right - this.myView.getSafeAreaInsets().left, + getDisplayHeight() - this.myView.getSafeAreaInsets().top - this.myView.getSafeAreaInsets().bottom + ); + return rect; + } + + return super.getDisplaySafeArea(rect); + } + + /** + * A status flag to indicate that CN1 is in the process of deinitializing. + */ + private static boolean deinitializing; + private static boolean deinitializingEdt; + + public static void syncDeinitialize() { + if (deinitializingEdt){ + return; + } + deinitializingEdt = true; // This will get unset in {@link #deinitialize()} + deinitializing = true; + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + Display.deinitialize(); + deinitializingEdt = false; + } + }); + } + + public void deinitialize() { + //activity.getWindowManager().removeView(relativeLayout); + super.deinitialize(); + if (getActivity() != null) { + + Runnable r = new Runnable() { + public void run() { + synchronized (AndroidImplementation.this) { + if (!deinitializing) { + return; + } + deinitializing = false; + } + if (nativePeers.size() > 0) { + for (int i = 0; i < nativePeers.size(); i++) { + ((AndroidImplementation.AndroidPeer) nativePeers.elementAt(i)).deinit(); + } + } + if (accessibilityProvider != null) { + accessibilityProvider.dispose(); + accessibilityProvider = null; + } + if (relativeLayout != null) { + relativeLayout.removeAllViews(); + } + relativeLayout = null; + myView = null; + } + }; + + if (Looper.getMainLooper().getThread() == Thread.currentThread()) { + deinitializing = true; + r.run(); + } else { + deinitializing = true; + getActivity().runOnUiThread(r); + } + } else { + deinitializing = false; + } + } + + /** + * init view. a lot of back and forth between this thread and the UI thread. + */ + private void initSurface() { + if (getActivity() != null && myView == null) { + relativeLayout= new RelativeLayout(getActivity()); + relativeLayout.setLayoutParams(new RelativeLayout.LayoutParams( + RelativeLayout.LayoutParams.FILL_PARENT, + RelativeLayout.LayoutParams.FILL_PARENT)); + relativeLayout.setFocusable(false); + + getActivity().getWindow().setBackgroundDrawable(null); + if(asyncView) { + if(android.os.Build.VERSION.SDK_INT < 14){ + myView = new AndroidSurfaceView(getActivity(), AndroidImplementation.this); + } else { + int hardwareAcceleration = 16777216; + getActivity().getWindow().setFlags(hardwareAcceleration, hardwareAcceleration); + myView = new AndroidAsyncView(getActivity(), AndroidImplementation.this); + } + } else { + int hardwareAcceleration = 16777216; + getActivity().getWindow().setFlags(hardwareAcceleration, hardwareAcceleration); + superPeerMode = true; + myView = new AndroidAsyncView(getActivity(), AndroidImplementation.this); + } + myView.getAndroidView().setVisibility(View.VISIBLE); + // Makes the surface an Android drop target, so a drag from another application -- + // or from elsewhere in this one -- reaches the components that asked for it. + AndroidNativeDragAndDrop.install(this, myView.getAndroidView()); + + if (hideOverlayWindowsRequested) { + setHideOverlayWindows(true); + } + + if (Build.VERSION.SDK_INT >= 16) { + final View semanticHost = myView.getAndroidView(); + accessibilityProvider = new AndroidAccessibilityProvider(semanticHost, this); + semanticHost.setAccessibilityDelegate(new View.AccessibilityDelegate() { + @Override + public android.view.accessibility.AccessibilityNodeProvider getAccessibilityNodeProvider(View host) { + return accessibilityProvider; + } + }); + } + + relativeLayout.addView(myView.getAndroidView()); + myView.getAndroidView().setVisibility(View.VISIBLE); + + int id = getActivity().getResources().getIdentifier("main", "layout", getActivity().getApplicationInfo().packageName); + RelativeLayout root = (RelativeLayout) LayoutInflater.from(getActivity()).inflate(id, null); + if(viewAbove != null) { + RelativeLayout.LayoutParams lp = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT); + lp.addRule(RelativeLayout.ALIGN_PARENT_TOP); + lp.addRule(RelativeLayout.CENTER_HORIZONTAL); + + RelativeLayout.LayoutParams lp2 = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT); + lp2.setMargins(0, 0, aboveSpacing, 0); + relativeLayout.setLayoutParams(lp2); + root.addView(viewAbove, lp); + } + root.addView(relativeLayout); + if(viewBelow != null) { + RelativeLayout.LayoutParams lp = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT); + lp.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM); + lp.addRule(RelativeLayout.CENTER_HORIZONTAL); + + RelativeLayout.LayoutParams lp2 = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.MATCH_PARENT, RelativeLayout.LayoutParams.MATCH_PARENT); + lp2.setMargins(0, 0, 0, belowSpacing); + relativeLayout.setLayoutParams(lp2); + root.addView(viewBelow, lp); + } + getActivity().setContentView(root); + if (!myView.getAndroidView().hasFocus()) { + myView.getAndroidView().requestFocus(); + } + } + } + + @Override + public void confirmControlView() { + if(myView == null){ + return; + } + myView.getAndroidView().setVisibility(View.VISIBLE); + //ugly workaround for a bug where on some android versions the async view + //came back black from the background. + if(myView instanceof AndroidAsyncView){ + final AndroidAsyncView finalView = (AndroidAsyncView)myView; + new Thread(new Runnable() { + @Override + public void run() { + Util.sleep(1000); + finalView.setPaintViewOnBuffer(false); + } + }).start(); + } + } + + public void hideNotifyPublic() { + super.hideNotify(); + saveTextEditingState(); + } + + public void showNotifyPublic() { + super.showNotify(); + } + + @Override + public boolean isMinimized() { + return getActivity() == null || ((CodenameOneActivity)getActivity()).isBackground(); + } + + @Override + public boolean minimizeApplication() { + Activity activity = getActivity(); + if (activity != null) { + // Move the app task to background instead of explicitly launching HOME. + // Some OEM launchers are no longer exported and can throw SecurityException + // when invoked via an ACTION_MAIN/CATEGORY_HOME intent. + if (activity.moveTaskToBack(true)) { + return true; + } + } + + // Fallback for edge-cases where there is no active activity/task. + Intent startMain = new Intent(Intent.ACTION_MAIN); + startMain.addCategory(Intent.CATEGORY_HOME); + startMain.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + startMain.putExtra("WaitForResult", Boolean.FALSE); + try { + getContext().startActivity(startMain); + return true; + } catch (SecurityException ex) { + Log.e("Codename One", "Unable to minimize application", ex); + return false; + } + } + + @Override + public void restoreMinimizedApplication() { + if (getActivity() != null) { + Intent i = new Intent(getActivity(), getActivity().getClass()); + i.setAction(Intent.ACTION_MAIN); + i.addCategory(Intent.CATEGORY_LAUNCHER); + getContext().startActivity(i); + } + } + + @Override + public boolean isNativeInputImmediate() { + return true; + } + + public void editString(final Component cmp, int maxSize, final int constraint, String text, int keyCode) { + InPlaceEditView.edit(this, cmp, constraint); + } + + protected boolean editInProgress() { + return InPlaceEditView.isEditing(); + } + + @Override + public boolean isAsyncEditMode() { + return asyncEditMode; + } + + void setAsyncEditMode(boolean async) { + asyncEditMode = async; + } + + void callHideTextEditor() { + super.hideTextEditor(); + } + + @Override + public void hideTextEditor() { + InPlaceEditView.hideActiveTextEditor(); + } + + @Override + public boolean isNativeEditorVisible(Component c) { + return super.isNativeEditorVisible(c) && !InPlaceEditView.isActiveTextEditorHidden(); + } + + public static void stopEditing() { + stopEditing(false); + } + + public static void stopEditing(final boolean forceVKBClose){ + if (getActivity() == null) { + return; + } + final boolean[] flag = new boolean[]{false}; + + // InPlaceEditView.endEdit must be called from the UI thread. + // We must wait for this call to be over, otherwise Codename One's painting + // of the next form will be garbled. + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + // Must be called from the UI thread + InPlaceEditView.stopEdit(forceVKBClose); + + synchronized (flag) { + flag[0] = true; + flag.notify(); + } + } + }); + + if (!flag[0]) { + // Wait (if necessary) for the asynchronous runOnUiThread to do its work + synchronized (flag) { + + try { + flag.wait(); + } catch (InterruptedException e) { + } + } + } + } + + @Override + public void saveTextEditingState() { + stopEditing(true); + } + + @Override + public void stopTextEditing() { + saveTextEditingState(); + } + + @Override + public void stopTextEditing(final Runnable onFinish) { + final Form f = Display.getInstance().getCurrent(); + f.addSizeChangedListener(new ActionListener() { + @Override + public void actionPerformed(ActionEvent evt) { + f.removeSizeChangedListener(this); + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + onFinish.run(); + } + }); + } + }); + stopEditing(true); + } + + + protected void setLastSizeChangedWH(int w, int h) { + // not used? + //this.lastSizeChangeW = w; + //this.lastSizeChangeH = h; + } + + /*@Override + public boolean handleEDTException(final Throwable err) { + + final boolean[] messageComplete = new boolean[]{false}; + + Log.e("Codename One", "Err on EDT", err); + + activity.runOnUiThread(new Runnable() { + @Override + public void run() { + UIManager m = UIManager.getInstance(); + final FrameLayout frameLayout = new FrameLayout( + activity); + final TextView textView = new TextView( + activity); + textView.setGravity(Gravity.CENTER); + frameLayout.addView(textView, new FrameLayout.LayoutParams( + FrameLayout.LayoutParams.FILL_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT)); + textView.setText("An internal application error occurred: " + err.toString()); + AlertDialog.Builder bob = new AlertDialog.Builder( + activity); + bob.setView(frameLayout); + bob.setTitle(""); + bob.setPositiveButton(m.localize("ok", "OK"), + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface d, int which) { + d.dismiss(); + synchronized (messageComplete) { + messageComplete[0] = true; + messageComplete.notify(); + } + } + }); + AlertDialog editDialog = bob.create(); + editDialog.show(); + } + }); + + synchronized (messageComplete) { + if (messageComplete[0]) { + return true; + } + try { + messageComplete.wait(); + } catch (Exception ignored) { + ; + } + } + return true; + }*/ + + @Override + public InputStream getResourceAsStream(Class cls, String resource) { + try { + if (resource.startsWith("/")) { + resource = resource.substring(1); + } + return getContext().getAssets().open(resource); + } catch (IOException ex) { + Log.i("Codename One", "Resource not found: " + resource); + return null; + } + } + + @Override + protected void pointerPressed(final int x, final int y) { + super.pointerPressed(x, y); + } + + @Override + protected void pointerPressed(final int[] x, final int[] y) { + super.pointerPressed(x, y); + } + + @Override + protected void pointerReleased(final int x, final int y) { + super.pointerReleased(x, y); + } + + @Override + protected void pointerReleased(final int[] x, final int[] y) { + super.pointerReleased(x, y); + } + + @Override + protected void pointerDragged(int x, int y) { + super.pointerDragged(x, y); + } + + @Override + protected void pointerDragged(int[] x, int[] y) { + super.pointerDragged(x, y); + } + + @Override + protected void pointerHover(int x, int y) { + super.pointerHover(x, y); + } + + @Override + protected void pointerHover(int[] x, int[] y) { + super.pointerHover(x, y); + } + + @Override + protected void pointerHoverPressed(int x, int y) { + super.pointerHoverPressed(x, y); + } + + @Override + protected void pointerHoverPressed(int[] x, int[] y) { + super.pointerHoverPressed(x, y); + } + + @Override + protected void pointerHoverReleased(int x, int y) { + super.pointerHoverReleased(x, y); + } + + @Override + protected void pointerHoverReleased(int[] x, int[] y) { + super.pointerHoverReleased(x, y); + } + + @Override + protected int getDragAutoActivationThreshold() { + return 1000000; + } + + @Override + public void flushGraphics() { + if (myView != null) { + myView.flushGraphics(); + } + + } + + @Override + public void flushGraphics(int x, int y, int width, int height) { + this.tmprect.set(x, y, x + width, y + height); + if (myView != null) { + myView.flushGraphics(this.tmprect); + } + } + + @Override + public int charWidth(Object nativeFont, char ch) { + this.tmpchar[0] = ch; + float w = (nativeFont == null ? this.defaultFont + : (Paint) ((NativeFont) nativeFont).font).measureText(this.tmpchar, 0, 1); + if (w - (int) w > 0) { + return (int) (w + 1); + } + return (int) w; + } + + @Override + public int charsWidth(Object nativeFont, char[] ch, int offset, int length) { + float w = (nativeFont == null ? this.defaultFont + : (Paint) ((NativeFont) nativeFont).font).measureText(ch, offset, length); + if (w - (int) w > 0) { + return (int) (w + 1); + } + return (int) w; + } + + @Override + public int stringWidth(Object nativeFont, String str) { + float w = (nativeFont == null ? this.defaultFont + : (Paint) ((NativeFont) nativeFont).font).measureText(str); + if (w - (int) w > 0) { + return (int) (w + 1); + } + return (int) w; + } + + @Override + public void setNativeFont(Object graphics, Object font) { + if (font == null) { + font = this.defaultFont; + } + if (font instanceof NativeFont) { + ((AndroidGraphics) graphics).setFont((CodenameOneTextPaint) ((NativeFont) font).font); + } else { + ((AndroidGraphics) graphics).setFont((CodenameOneTextPaint) font); + } + } + + @Override + public int getHeight(Object nativeFont) { + CodenameOneTextPaint font = (nativeFont == null ? this.defaultFont + : (CodenameOneTextPaint) ((NativeFont) nativeFont).font); + if(font.fontHeight < 0) { + Paint.FontMetrics fm = font.getFontMetrics(); + font.fontHeight = (int)Math.ceil(fm.bottom - fm.top); + } + return font.fontHeight; + } + + @Override + public int getFontAscent(Object nativeFont) { + Paint font = (nativeFont == null ? this.defaultFont + : (Paint) ((NativeFont) nativeFont).font); + return -Math.round(font.getFontMetrics().ascent); + } + + @Override + public int getFontDescent(Object nativeFont) { + Paint font = (nativeFont == null ? this.defaultFont + : (Paint) ((NativeFont) nativeFont).font); + return Math.abs(Math.round(font.getFontMetrics().descent)); + } + + @Override + public boolean isBaselineTextSupported() { + return true; + } + + + + + + + public int getFace(Object nativeFont) { + if (nativeFont == null) { + return Font.FACE_SYSTEM; + } + return ((NativeFont) nativeFont).face; + } + + public int getStyle(Object nativeFont) { + if (nativeFont == null) { + return Font.STYLE_PLAIN; + } + return ((NativeFont) nativeFont).style; + } + + @Override + public int getSize(Object nativeFont) { + if (nativeFont == null) { + return Font.SIZE_MEDIUM; + } + return ((NativeFont) nativeFont).size; + } + + @Override + public boolean isTrueTypeSupported() { + return true; + } + + @Override + public boolean isNativeFontSchemeSupported() { + return true; + } + + private Typeface fontToRoboto(String fontName) { + if("native:MainThin".equals(fontName)) { + return Typeface.create("sans-serif-thin", Typeface.NORMAL); + } + if("native:MainLight".equals(fontName)) { + return Typeface.create("sans-serif-light", Typeface.NORMAL); + } + if("native:MainRegular".equals(fontName)) { + return Typeface.create("sans-serif", Typeface.NORMAL); + } + + if("native:MainBold".equals(fontName)) { + return Typeface.create("sans-serif-condensed", Typeface.BOLD); + } + + if("native:MainBlack".equals(fontName)) { + return Typeface.create("sans-serif-black", Typeface.BOLD); + } + + if("native:ItalicThin".equals(fontName)) { + return Typeface.create("sans-serif-thin", Typeface.ITALIC); + } + + if("native:ItalicLight".equals(fontName)) { + return Typeface.create("sans-serif-thin", Typeface.ITALIC); + } + + if("native:ItalicRegular".equals(fontName)) { + return Typeface.create("sans-serif", Typeface.ITALIC); + } + + if("native:ItalicBold".equals(fontName)) { + return Typeface.create("sans-serif-condensed", Typeface.BOLD_ITALIC); + } + + if("native:ItalicBlack".equals(fontName)) { + return Typeface.create("sans-serif-black", Typeface.BOLD_ITALIC); + } + + throw new IllegalArgumentException("Unsupported native font type: " + fontName); + } + + @Override + public Object loadTrueTypeFont(String fontName, String fileName) { + if(fontName.startsWith("native:")) { + Typeface t = fontToRoboto(fontName); + int fontStyle = com.codename1.ui.Font.STYLE_PLAIN; + if(t.isBold()) { + fontStyle |= com.codename1.ui.Font.STYLE_BOLD; + } + if(t.isItalic()) { + fontStyle |= com.codename1.ui.Font.STYLE_ITALIC; + } + CodenameOneTextPaint newPaint = new CodenameOneTextPaint(t); + newPaint.setAntiAlias(true); + newPaint.setSubpixelText(true); + return new NativeFont(com.codename1.ui.Font.FACE_SYSTEM, fontStyle, + com.codename1.ui.Font.SIZE_MEDIUM, newPaint, fileName, 0, 0); + } + Typeface t = Typeface.createFromAsset(getContext().getAssets(), fileName); + if(t == null) { + throw new RuntimeException("Font not found: " + fileName); + } + CodenameOneTextPaint newPaint = new CodenameOneTextPaint(t); + newPaint.setAntiAlias(true); + newPaint.setSubpixelText(true); + return new NativeFont(com.codename1.ui.Font.FACE_SYSTEM, + com.codename1.ui.Font.STYLE_PLAIN, com.codename1.ui.Font.SIZE_MEDIUM, newPaint, fileName, 0, 0); + } + + public static class NativeFont { + int face; + int style; + int size; + public Object font; + String fileName; + float height; + int weight; + + public NativeFont(int face, int style, int size, Object font, String fileName, float height, int weight) { + this(face, style, size, font); + this.fileName = fileName; + this.height = height; + this.weight = weight; + } + + public NativeFont(int face, int style, int size, Object font) { + this.face = face; + this.style = style; + this.size = size; + this.font = font; + } + + public boolean equals(Object o) { + if(o == null) { + return false; + } + NativeFont n = ((NativeFont)o); + if(fileName != null) { + return n.fileName != null && fileName.equals(n.fileName) && n.height == height && n.weight == weight; + } + return n.face == face && n.style == style && n.size == size && font.equals(n.font); + } + + public int hashCode() { + return face | style | size; + } + } + + /// Returns a copy of the given native font with its paint's letter spacing set + /// to the supplied value (Android letter spacing is in EM units, independent of + /// font size). Used by Style.letterSpacing so a per-UIID spacing -- matching the + /// Material text-appearance for each component -- is baked into the SAME paint + /// that does both measureText (layout) and drawText (render), keeping advances + /// consistent. Other ports get the default no-op. + @Override + public Object deriveTrueTypeFontWithLetterSpacing(Object font, float letterSpacing) { + NativeFont fnt = (NativeFont) font; + CodenameOneTextPaint copy = new CodenameOneTextPaint((CodenameOneTextPaint) fnt.font); + copy.setLetterSpacing(letterSpacing); + return new NativeFont(fnt.face, fnt.style, fnt.size, copy, fnt.fileName, fnt.height, fnt.weight); + } + + @Override + public Object deriveTrueTypeFont(Object font, float size, int weight) { + NativeFont fnt = (NativeFont)font; + CodenameOneTextPaint paint = (CodenameOneTextPaint)fnt.font; + paint.setAntiAlias(true); + Typeface type = paint.getTypeface(); + int fontstyle = Typeface.NORMAL; + if ((weight & Font.STYLE_BOLD) != 0 || type.isBold()) { + fontstyle |= Typeface.BOLD; + } + if ((weight & Font.STYLE_ITALIC) != 0 || type.isItalic()) { + fontstyle |= Typeface.ITALIC; + } + type = Typeface.create(type, fontstyle); + CodenameOneTextPaint newPaint = new CodenameOneTextPaint(type); + newPaint.setTextSize(size); + newPaint.setAntiAlias(true); + // preserve any letter spacing already configured on the source paint + newPaint.setLetterSpacing(paint.getLetterSpacing()); + NativeFont n = new NativeFont(com.codename1.ui.Font.FACE_SYSTEM, weight, com.codename1.ui.Font.SIZE_MEDIUM, newPaint, fnt.fileName, size, weight); + return n; + } + + @Override + public Object createFont(int face, int style, int size) { + Typeface typeface = null; + switch (face) { + case Font.FACE_MONOSPACE: + typeface = Typeface.MONOSPACE; + break; + default: + typeface = Typeface.DEFAULT; + break; + } + + int fontstyle = Typeface.NORMAL; + if ((style & Font.STYLE_BOLD) != 0) { + fontstyle |= Typeface.BOLD; + } + if ((style & Font.STYLE_ITALIC) != 0) { + fontstyle |= Typeface.ITALIC; + } + + + int height = this.defaultFontHeight; + int diff = height / 3; + + switch (size) { + case Font.SIZE_SMALL: + height -= diff; + break; + case Font.SIZE_LARGE: + height += diff; + break; + } + + Paint font = new CodenameOneTextPaint(Typeface.create(typeface, fontstyle)); + font.setAntiAlias(true); + font.setUnderlineText((style & Font.STYLE_UNDERLINED) != 0); + font.setTextSize(height); + return new NativeFont(face, style, size, font); + + } + + /** + * Loads a native font based on a lookup for a font name and attributes. + * Font lookup values can be separated by commas and thus allow fallback if + * the primary font isn't supported by the platform. + * + * @param lookup string describing the font + * @return the native font object + */ + public Object loadNativeFont(String lookup) { + try { + lookup = lookup.split(";")[0]; + int typeface = Typeface.NORMAL; + String familyName = lookup.substring(0, lookup.indexOf("-")); + String style = lookup.substring(lookup.indexOf("-") + 1, lookup.lastIndexOf("-")); + String size = lookup.substring(lookup.lastIndexOf("-") + 1, lookup.length()); + + if (style.equals("bolditalic")) { + typeface = Typeface.BOLD_ITALIC; + } else if (style.equals("italic")) { + typeface = Typeface.ITALIC; + } else if (style.equals("bold")) { + typeface = Typeface.BOLD; + } + Paint font = new CodenameOneTextPaint(Typeface.create(familyName, typeface)); + font.setAntiAlias(true); + font.setTextSize(Integer.parseInt(size)); + return new NativeFont(0, 0, 0, font); + } catch (Exception err) { + return null; + } + } + + /** + * Indicates whether loading a font by a string is supported by the platform + * + * @return true if the platform supports font lookup + */ + @Override + public boolean isLookupFontSupported() { + return true; + } + + @Override + public boolean isAntiAliasedTextSupported() { + return true; + } + + @Override + public void setAntiAliasedText(Object graphics, boolean a) { + android.graphics.Paint p = ((AndroidGraphics) graphics).getFont(); + if(p != null) { + p.setAntiAlias(a); + } + } + + @Override + public Object getDefaultFont() { + CodenameOneTextPaint paint = new CodenameOneTextPaint(this.defaultFont); + return new NativeFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_MEDIUM, paint); + } + + + private AndroidGraphics nullGraphics; + + private AndroidGraphics getNullGraphics() { + if (nullGraphics == null) { + Bitmap bitmap = Bitmap.createBitmap(getDisplayWidth()==0?100:getDisplayWidth(), getDisplayHeight()==0?100:getDisplayHeight(), + Bitmap.Config.ARGB_8888); + nullGraphics = (AndroidGraphics) this.getNativeGraphics(bitmap); + } + return nullGraphics; + } + + + @Override + public Object getNativeGraphics() { + if(myView != null){ + nullGraphics = null; + return myView.getGraphics(); + }else{ + return getNullGraphics(); + } + } + + @Override + public Object getNativeGraphics(Object image) { + AndroidGraphics g = new AndroidGraphics(this, new Canvas((Bitmap) image), true); + g.underlyingBitmap = (Bitmap) image; + g.setClip(0, 0, ((Bitmap)image).getWidth(), ((Bitmap)image).getHeight()); + return g; + } + + @Override + public void getRGB(Object nativeImage, int[] arr, int offset, int x, int y, + int width, int height) { + ((Bitmap) nativeImage).getPixels(arr, offset, width, x, y, width, + height); + } + + private int sampleSizeOverride = -1; + + @Override + public Object createImage(String path) throws IOException { + int IMAGE_MAX_SIZE = getDisplayHeight(); + if (exists(path)) { + Bitmap b = null; + try { + //Decode image size + BitmapFactory.Options o = new BitmapFactory.Options(); + o.inJustDecodeBounds = true; + o.inPreferredConfig = Bitmap.Config.ARGB_8888; + + InputStream fis = createFileInputStream(path); + BitmapFactory.decodeStream(fis, null, o); + fis.close(); + + int scale = 1; + if (o.outHeight > IMAGE_MAX_SIZE || o.outWidth > IMAGE_MAX_SIZE) { + scale = (int) Math.pow(2, (int) Math.round(Math.log(IMAGE_MAX_SIZE / (double) Math.max(o.outHeight, o.outWidth)) / Math.log(0.5))); + } + + //Decode with inSampleSize + BitmapFactory.Options o2 = new BitmapFactory.Options(); + o2.inPreferredConfig = Bitmap.Config.ARGB_8888; + + if(sampleSizeOverride != -1) { + o2.inSampleSize = sampleSizeOverride; + } else { + String sampleSize = Display.getInstance().getProperty("android.sampleSize", null); + if(sampleSize != null) { + o2.inSampleSize = Integer.parseInt(sampleSize); + } else { + o2.inSampleSize = scale; + } + } + o2.inPurgeable = true; + o2.inInputShareable = true; + fis = createFileInputStream(path); + b = BitmapFactory.decodeStream(fis, null, o2); + fis.close(); + + //fix rotation + ExifInterface exif = new ExifInterface(removeFilePrefix(path)); + int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); + + int angle = 0; + switch (orientation) { + case ExifInterface.ORIENTATION_ROTATE_90: + angle = 90; + break; + case ExifInterface.ORIENTATION_ROTATE_180: + angle = 180; + break; + case ExifInterface.ORIENTATION_ROTATE_270: + angle = 270; + break; + } + + if (sampleSizeOverride < 0 && angle != 0) { + Matrix mat = new Matrix(); + mat.postRotate(angle); + Bitmap correctBmp = Bitmap.createBitmap(b, 0, 0, b.getWidth(), b.getHeight(), mat, true); + b.recycle(); + b = correctBmp; + } + } catch (IOException e) { + } + return b; + } else { + InputStream in = this.getResourceAsStream(getClass(), path); + if (in == null) { + throw new IOException("Resource not found. " + path); + } + try { + return this.createImage(in); + } finally { + if (in != null) { + try { + in.close(); + } catch (Exception ignored) { + ; + } + } + } + } + } + + @Override + public boolean areMutableImagesFast() { + if (myView == null) return false; + return !myView.alwaysRepaintAll(); + } + + @Override + public void repaint(Animation cmp) { + if(myView != null && myView.alwaysRepaintAll()) { + if(cmp instanceof Component) { + Component c = (Component)cmp; + c.setDirtyRegion(null); + if(c.getParent() != null) { + cmp = c.getComponentForm(); + } else { + Form f = getCurrentForm(); + if(f != null) { + cmp = f; + } + } + } else { + // make sure the form is repainted for standalone anims e.g. in the case + // of replace animation + Form f = getCurrentForm(); + if(f != null) { + super.repaint(f); + } + } + } + super.repaint(cmp); + } + + @Override + public Object createImage(InputStream i) throws IOException { + BitmapFactory.Options opts = new BitmapFactory.Options(); + opts.inPreferredConfig = Bitmap.Config.ARGB_8888; + return BitmapFactory.decodeStream(i, null, opts); + } + + @Override + public void releaseImage(Object image) { + Bitmap i = (Bitmap) image; + i.recycle(); + } + + @Override + public Object createImage(byte[] bytes, int offset, int len) { + BitmapFactory.Options opts = new BitmapFactory.Options(); + opts.inPreferredConfig = Bitmap.Config.ARGB_8888; + return BitmapFactory.decodeByteArray(bytes, offset, len, opts); + } + + @Override + public Object createImage(int[] rgb, int width, int height) { + return Bitmap.createBitmap(rgb, width, height, Bitmap.Config.ARGB_8888); + } + + @Override + public boolean isAlphaMutableImageSupported() { + return true; + } + + @Override + public Object scale(Object nativeImage, int width, int height) { + return Bitmap.createScaledBitmap((Bitmap) nativeImage, width, height, + false); + } + + // @Override +// public Object rotate(Object image, int degrees) { +// Matrix matrix = new Matrix(); +// matrix.postRotate(degrees); +// return Bitmap.createBitmap((Bitmap) image, 0, 0, ((Bitmap) image).getWidth(), ((Bitmap) image).getHeight(), matrix, true); +// } + @Override + public boolean isRotationDrawingSupported() { + return false; + } + + @Override + protected boolean cacheLinearGradients() { + return false; + } + + @Override + public boolean isNativeInputSupported() { + return true; + } + + /** + * Returns true if the underlying OS supports opening the native navigation + * application + * @return true if the underlying OS supports launch of native navigation app + */ + public boolean isOpenNativeNavigationAppSupported(){ + return true; + } + + /** + * Opens the native navigation app in the given coordinate. + * @param latitude + * @param longitude + */ + public void openNativeNavigationApp(double latitude, double longitude){ + execute("google.navigation:ll=" + latitude+ "," + longitude); + } + + + @Override + public void openNativeNavigationApp(String location) { + execute("google.navigation:q=" + Util.encodeUrl(location)); + } + + @Override + public Object createMutableImage(int width, int height, int fillColor) { + Bitmap bitmap = Bitmap.createBitmap(width, height, + Bitmap.Config.ARGB_8888); + AndroidGraphics graphics = (AndroidGraphics) this.getNativeGraphics(bitmap); + graphics.fillBitmap(fillColor); + return bitmap; + } + + @Override + public int getImageHeight(Object i) { + return ((Bitmap) i).getHeight(); + } + + @Override + public int getImageWidth(Object i) { + return ((Bitmap) i).getWidth(); + } + + @Override + public void drawImage(Object graphics, Object img, int x, int y) { + ((AndroidGraphics) graphics).drawImage(img, x, y); + } + + @Override + public void tileImage(Object graphics, Object img, int x, int y, int w, int h) { + ((AndroidGraphics) graphics).tileImage(img, x, y, w, h); + } + + public boolean isScaledImageDrawingSupported() { + return true; + } + + public void drawImage(Object graphics, Object img, int x, int y, int w, int h) { + ((AndroidGraphics) graphics).drawImage(img, x, y, w, h); + } + + @Override + public void drawLine(Object graphics, int x1, int y1, int x2, int y2) { + ((AndroidGraphics) graphics).drawLine(x1, y1, x2, y2); + } + + @Override + public boolean isAntiAliasingSupported() { + return true; + } + + @Override + public void setAntiAliased(Object graphics, boolean a) { + ((AndroidGraphics) graphics).getPaint().setAntiAlias(a); + } + + @Override + public void drawPolygon(Object graphics, int[] xPoints, int[] yPoints, int nPoints) { + ((AndroidGraphics) graphics).drawPolygon(xPoints, yPoints, nPoints); + } + + @Override + public void fillPolygon(Object graphics, int[] xPoints, int[] yPoints, int nPoints) { + ((AndroidGraphics) graphics).fillPolygon(xPoints, yPoints, nPoints); + } + + @Override + public void drawRGB(Object graphics, int[] rgbData, int offset, int x, + int y, int w, int h, boolean processAlpha) { + ((AndroidGraphics) graphics).drawRGB(rgbData, offset, x, y, w, h, processAlpha); + } + + @Override + public void drawRect(Object graphics, int x, int y, int width, int height) { + ((AndroidGraphics) graphics).drawRect(x, y, width, height); + } + + @Override + public void drawRoundRect(Object graphics, int x, int y, int width, + int height, int arcWidth, int arcHeight) { + ((AndroidGraphics) graphics).drawRoundRect(x, y, width, height, arcWidth, arcHeight); + } + + @Override + public void drawString(Object graphics, String str, int x, int y) { + ((AndroidGraphics) graphics).drawString(str, x, y); + } + + @Override + public void drawArc(Object graphics, int x, int y, int width, int height, + int startAngle, int arcAngle) { + ((AndroidGraphics) graphics).drawArc(x, y, width, height, startAngle, arcAngle); + } + + @Override + public void fillArc(Object graphics, int x, int y, int width, int height, + int startAngle, int arcAngle) { + ((AndroidGraphics) graphics).fillArc(x, y, width, height, startAngle, arcAngle); + } + + @Override + public void fillRect(Object graphics, int x, int y, int width, int height) { + ((AndroidGraphics) graphics).fillRect(x, y, width, height); + } + + @Override + public void fillRect(Object graphics, int x, int y, int w, int h, byte alpha) { + ((AndroidGraphics) graphics).fillRect(x, y, w, h, alpha); + } + + @Override + public void paintComponentBackground(Object graphics, int x, int y, int width, int height, Style s) { + if((!asyncView) || compatPaintMode ) { + super.paintComponentBackground(graphics, x, y, width, height, s); + return; + } + ((AndroidGraphics) graphics).paintComponentBackground(x, y, width, height, s); + } + + @Override + public void fillLinearGradient(Object graphics, int startColor, int endColor, int x, int y, int width, int height, boolean horizontal) { + if(!asyncView) { + super.fillLinearGradient(graphics, startColor, endColor, x, y, width, height, horizontal); + return; + } + ((AndroidGraphics)graphics).fillLinearGradient(startColor, endColor, x, y, width, height, horizontal); + } + + @Override + public void fillRectRadialGradient(Object graphics, int startColor, int endColor, int x, int y, int width, int height, float relativeX, float relativeY, float relativeSize) { + if(!asyncView) { + super.fillRectRadialGradient(graphics, startColor, endColor, x, y, width, height, relativeX, relativeY, relativeSize); + return; + } + ((AndroidGraphics)graphics).fillRectRadialGradient(startColor, endColor, x, y, width, height, relativeX, relativeY, relativeSize); + } + + @Override + public void fillRadialGradient(Object graphics, int startColor, int endColor, int x, int y, int width, int height) { + ((AndroidGraphics)graphics).fillRadialGradient(startColor, endColor, x, y, width, height); + } + + @Override + public void fillRadialGradient(Object graphics, int startColor, int endColor, int x, int y, int width, int height, int startAngle, int arcAngle) { + ((AndroidGraphics)graphics).fillRadialGradient(startColor, endColor, x, y, width, height, startAngle, arcAngle); + } + + @Override + public void fillGradient(Object graphics, com.codename1.ui.Gradient gradient, + int x, int y, int width, int height) { + // Always route Android multi-stop gradients through the native Shader + // path - the software rasterizer in the base impl would otherwise + // allocate a per-call ARGB buffer on the Bitmap-graphics path used by + // mutable images, which on Android emulator hardware GCs heavily for + // conic / large fills (the case that hung the instrumentation suite). + ((AndroidGraphics) graphics).fillGradient(gradient, x, y, width, height); + } + + @Override + public void drawLabelComponent(Object nativeGraphics, int cmpX, int cmpY, int cmpHeight, int cmpWidth, Style style, String text, Object icon, Object stateIcon, int preserveSpaceForState, int gap, boolean rtl, boolean isOppositeSide, int textPosition, int stringWidth, boolean isTickerRunning, int tickerShiftText, boolean endsWith3Points, int valign) { + if(AndroidAsyncView.legacyPaintLogic) { + super.drawLabelComponent(nativeGraphics, cmpX, cmpY, cmpHeight, cmpWidth, style, text, icon, stateIcon, preserveSpaceForState, gap, rtl, isOppositeSide, textPosition, stringWidth, isTickerRunning, tickerShiftText, endsWith3Points, valign); + return; + } + ((AndroidGraphics)nativeGraphics).drawLabelComponent(cmpX, cmpY, cmpHeight, cmpWidth, style, text, + (Bitmap)icon, (Bitmap)stateIcon, preserveSpaceForState, gap, rtl, isOppositeSide, textPosition, stringWidth, + isTickerRunning, tickerShiftText, endsWith3Points, valign); + } + + + @Override + public void fillRoundRect(Object graphics, int x, int y, int width, + int height, int arcWidth, int arcHeight) { + ((AndroidGraphics) graphics).fillRoundRect(x, y, width, height, arcWidth, arcHeight); + } + + @Override + public int getAlpha(Object graphics) { + return ((AndroidGraphics) graphics).getAlpha(); + } + + @Override + public void setAlpha(Object graphics, int alpha) { + ((AndroidGraphics) graphics).setAlpha(alpha); + } + + @Override + public boolean isAlphaGlobal() { + return true; + } + + @Override + public void setColor(Object graphics, int RGB) { + ((AndroidGraphics) graphics).setColor((getColor(graphics) & 0xff000000) | RGB); + } + + @Override + public int getBackKeyCode() { + return DROID_IMPL_KEY_BACK; + } + + @Override + public int getBackspaceKeyCode() { + return DROID_IMPL_KEY_BACKSPACE; + } + + @Override + public int getClearKeyCode() { + return DROID_IMPL_KEY_CLEAR; + } + + @Override + public int getClipHeight(Object graphics) { + return ((AndroidGraphics) graphics).getClipHeight(); + } + + @Override + public int getClipWidth(Object graphics) { + return ((AndroidGraphics) graphics).getClipWidth(); + } + + @Override + public int getClipX(Object graphics) { + return ((AndroidGraphics) graphics).getClipX(); + } + + @Override + public int getClipY(Object graphics) { + return ((AndroidGraphics) graphics).getClipY(); + } + + @Override + public void setClip(Object graphics, int x, int y, int width, int height) { + ((AndroidGraphics) graphics).setClip(x, y, width, height); + } + + @Override + public boolean isShapeClipSupported(Object graphics){ + return Build.VERSION.SDK_INT > Build.VERSION_CODES.HONEYCOMB; + } + + @Override + public void setClip(Object graphics, Shape shape) { + //Path p = cn1ShapeToAndroidPath(shape); + ((AndroidGraphics) graphics).setClip(shape); + } + + + @Override + public void clipRect(Object graphics, int x, int y, int width, int height) { + ((AndroidGraphics) graphics).clipRect(x, y, width, height); + } + + @Override + public int getColor(Object graphics) { + return ((AndroidGraphics) graphics).getColor(); + } + + @Override + public int getDisplayHeight() { + if (this.myView != null) { + int h = this.myView.getViewHeight(); + displayHeight = h; + return h; + } + return displayHeight; + } + + @Override + public int getDisplayWidth() { + if (this.myView != null) { + int w = this.myView.getViewWidth(); + displayWidth = w; + return w; + } + return displayWidth; + } + + @Override + public int getActualDisplayHeight() { + DisplayMetrics dm = getContext().getResources().getDisplayMetrics(); + return dm.heightPixels; + } + + @Override + public int getGameAction(int keyCode) { + switch (keyCode) { + case DROID_IMPL_KEY_DOWN: + return Display.GAME_DOWN; + case DROID_IMPL_KEY_UP: + return Display.GAME_UP; + case DROID_IMPL_KEY_LEFT: + return Display.GAME_LEFT; + case DROID_IMPL_KEY_RIGHT: + return Display.GAME_RIGHT; + case DROID_IMPL_KEY_FIRE: + return Display.GAME_FIRE; + default: + return 0; + } + } + + @Override + public int getKeyCode(int gameAction) { + switch (gameAction) { + case Display.GAME_DOWN: + return DROID_IMPL_KEY_DOWN; + case Display.GAME_UP: + return DROID_IMPL_KEY_UP; + case Display.GAME_LEFT: + return DROID_IMPL_KEY_LEFT; + case Display.GAME_RIGHT: + return DROID_IMPL_KEY_RIGHT; + case Display.GAME_FIRE: + return DROID_IMPL_KEY_FIRE; + default: + return 0; + } + } + + @Override + public int[] getSoftkeyCode(int index) { + if (index == 0) { + return leftSK; + } + return null; + } + + @Override + public int getSoftkeyCount() { + /** + * one menu button only. we may have to stuff some code here as soon as + * there are devices that no longer have only a single menu button. + */ + return 1; + } + + @Override + public void vibrate(int duration) { + if (!this.vibrateInitialized) { + try { + v = (Vibrator) getContext().getSystemService(Context.VIBRATOR_SERVICE); + } catch (Throwable e) { + Log.e("Codename One", "problem with virbrator(0)", e); + } finally { + this.vibrateInitialized = true; + } + } + if (v != null) { + try { + v.vibrate(duration); + } catch (Throwable e) { + Log.e("Codename One", "problem with virbrator(1)", e); + } + } + } + + @Override + public boolean isTouchDevice() { + return getContext().getPackageManager().hasSystemFeature(PackageManager.FEATURE_TOUCHSCREEN); + } + + @Override + public boolean hasPendingPaints() { + //if the view is not visible make sure the edt won't wait. + if (myView != null && myView.getAndroidView().getVisibility() != View.VISIBLE) { + return true; + } else { + return super.hasPendingPaints(); + } + } + + public void revalidate() { + if (myView != null) { + myView.getAndroidView().setVisibility(View.VISIBLE); + Form form = getCurrentForm(); + if (form != null) { + form.revalidate(); + } + flushGraphics(); + } + + } + + @Override + public int getKeyboardType() { + if (Display.getInstance().getDefaultVirtualKeyboard().isVirtualKeyboardShowing()) { + return Display.KEYBOARD_TYPE_VIRTUAL; + } + /** + * can we detect this? but even if we could i think it is best to have + * this fixed to qwerty. we pass unicode values to Codename One in any + * case. check AndroidView.onKeyUpDown() method. and read comment below. + */ + return Display.KEYBOARD_TYPE_QWERTY; + /** + * some info from the MIDP docs about keycodes: + * + * "Applications receive keystroke events in which the individual keys + * are named within a space of key codes. Every key for which events are + * reported to MIDP applications is assigned a key code. The key code + * values are unique for each hardware key unless two keys are obvious + * synonyms for each other. MIDP defines the following key codes: + * KEY_NUM0, KEY_NUM1, KEY_NUM2, KEY_NUM3, KEY_NUM4, KEY_NUM5, KEY_NUM6, + * KEY_NUM7, KEY_NUM8, KEY_NUM9, KEY_STAR, and KEY_POUND. (These key + * codes correspond to keys on a ITU-T standard telephone keypad.) Other + * keys may be present on the keyboard, and they will generally have key + * codes distinct from those list above. In order to guarantee + * portability, applications should use only the standard key codes. + * + * The standard key codes values are equal to the Unicode encoding for + * the character that represents the key. If the device includes any + * other keys that have an obvious correspondence to a Unicode + * character, their key code values should equal the Unicode encoding + * for that character. For keys that have no corresponding Unicode + * character, the implementation must use negative values. Zero is + * defined to be an invalid key code." + * + * Because the MIDP implementation is our reference and that + * implementation does not interpret the given keycodes we behave alike + * and pass on the unicode values. + */ + } + + /** + * Exits the application... + */ + public void exitApplication() { + android.os.Process.killProcess(android.os.Process.myPid()); + } + + /** + * finishAndRemoveTask() arrived in Lollipop, and there is nothing to remove without an + * activity -- a push or background service process owns no task of its own. + */ + @Override + public boolean isExitAndClearTaskSupported() { + return Build.VERSION.SDK_INT >= 21 && getActivity() != null; + } + + @Override + public void exitApplicationAndClearTask() { + final CodenameOneActivity a = getActivity(); + if (a == null || Build.VERSION.SDK_INT < 21) { + exitApplication(); + return; + } + Runnable finishAndKill = new Runnable() { + public void run() { + try { + a.finishAndRemoveTask(); + } catch (Throwable t) { + // A task we failed to remove is still a task we must exit, so log and fall + // through to the kill rather than leaving the application running. + com.codename1.io.Log.e(t); + } + // Killing here is what makes this behave like exitApplication(), which never + // returns to its caller either. It does not race the removal: finishAndRemoveTask() + // is a blocking binder call into the activity manager, so the task is already off + // the recents list when it returns. Measured on an API 36 emulator with a probe + // that ran this exact sequence 29 times -- the task was gone from + // "dumpsys activity recents" every time, while the control that only killed the + // process (what exitApplication() does) left it there every time. + android.os.Process.killProcess(android.os.Process.myPid()); + } + }; + if (Looper.getMainLooper().getThread() == Thread.currentThread()) { + finishAndKill.run(); + } else { + a.runOnUiThread(finishAndKill); + } + } + + @Override + public void notifyPushCompletion() { + if (pushWakeLock != null && pushWakeLock.isHeld()) { + try { + pushWakeLock.release(); + } catch (Exception ex) { + com.codename1.io.Log.e(ex); + } + } + } + + @Override + public void notifyCommandBehavior(int commandBehavior) { + if (commandBehavior == Display.COMMAND_BEHAVIOR_NATIVE) { + if (getActivity() instanceof CodenameOneActivity) { + ((CodenameOneActivity) getActivity()).enableNativeMenu(true); + } + } + } + + private static class NotifyActionBar implements Runnable { + private Activity activity; + private boolean show; + + public NotifyActionBar(Activity activity, int commandBehavior) { + this.activity = activity; + show = commandBehavior == Display.COMMAND_BEHAVIOR_NATIVE; + } + + public NotifyActionBar(Activity activity, boolean show) { + this.activity = activity; + this.show = show; + } + + @Override + public void run() { + activity.invalidateOptionsMenu(); + if (activity.getActionBar() == null) { + return; + } + if (show) { + activity.getActionBar().show(); + } else { + activity.getActionBar().hide(); + } + } + } + + @Override + public String getAppArg() { + if (super.getAppArg() != null) { + // This just maintains backward compatibility in case people are manually + // setting the AppArg in their properties. It reproduces the general + // behaviour the existed when AppArg was just another Display property. + return super.getAppArg(); + } + if (getActivity() == null) { + return null; + } + + android.content.Intent intent = getActivity().getIntent(); + if (intent != null) { + publishIntentProperties(getActivity(), intent); + String sharedText = intent.getStringExtra(Intent.EXTRA_TEXT); + intent.removeExtra(Intent.EXTRA_TEXT); + Uri u = intent.getData(); + String scheme = intent.getScheme(); + if (u != null && isAppArgDelivered(intent)) { + // dispatchNewIntentUrl() already handed this url over as the app arg + // on the warm path. The data stays on the intent for the readers that + // want it -- `android.intent.data` above, and native code asking the + // activity for its intent -- and only the second delivery is dropped. + u = null; + } + if (u == null && intent.getExtras() != null) { + if (intent.getExtras().keySet().contains("android.intent.extra.STREAM")) { + try { + u = (Uri)intent.getParcelableExtra("android.intent.extra.STREAM"); + scheme = u.getScheme(); + System.out.println("u="+u); + } catch (Exception ex) { + Log.d("Codename One", "Failed to load parcelable extra from intent: "+ex.getMessage()); + } + } + + } + if (u != null) { + //String scheme = intent.getScheme(); + intent.setData(null); + if ("content".equals(scheme)) { + try { + InputStream attachment = getActivity().getContentResolver().openInputStream(u); + if (attachment != null) { + String name = getContentName(getActivity().getContentResolver(), u); + if (name != null) { + String filePath = getAppHomePath() + + getFileSystemSeparator() + name; + if(filePath.startsWith("file:")) { + filePath = filePath.substring(5); + } + File f = new File(filePath); + OutputStream tmp = createFileOuputStream(f); + byte[] buffer = new byte[1024]; + int read = -1; + while ((read = attachment.read(buffer)) > -1) { + tmp.write(buffer, 0, read); + } + tmp.close(); + attachment.close(); + setAppArg(addFile(filePath)); + return addFile(filePath); + } + } + } catch (FileNotFoundException e) { + e.printStackTrace(); + return null; + } catch (IOException e) { + e.printStackTrace(); + return null; + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } else { + + /* + // Why do we need this special case? u.toString() + // will include the full URL including query string. + // This special case causes urls like myscheme://part1/part2 + // to only return "/part2" which is obviously problematic and + // is inconsistent with iOS. Is this special case necessary + // in some versions of Android? + String encodedPath = u.getEncodedPath(); + if (encodedPath != null && encodedPath.length() > 0) { + String query = u.getQuery(); + if(query != null && query.length() > 0){ + encodedPath += "?" + query; + } + setAppArg(encodedPath); + return encodedPath; + } + */ + if (sharedText != null) { + setAppArg(sharedText); + return sharedText; + } else { + setAppArg(u.toString()); + return u.toString(); + } + + } + } else if (sharedText != null) { + setAppArg(sharedText); + return sharedText; + } + } + return null; + } + + // taken from https://stackoverflow.com/a/70380413/756809 + private boolean isRunningOnAndroidStudioEmulator() { + return Build.FINGERPRINT.startsWith("google/sdk_gphone") + && Build.FINGERPRINT.endsWith(":user/release-keys") + && "Google".equals(Build.MANUFACTURER) && Build.PRODUCT.startsWith("sdk_gphone") && "google".equals(Build.BRAND) + && Build.MODEL.startsWith("sdk_gphone"); + } + + // taken from https://stackoverflow.com/a/57960169/756809 + private boolean isEmulator() { + return isRunningOnAndroidStudioEmulator() || + ((Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic")) + || Build.FINGERPRINT.startsWith("generic") + || Build.FINGERPRINT.startsWith("unknown") + || Build.HARDWARE.contains("goldfish") + || Build.HARDWARE.contains("ranchu") + || Build.MODEL.contains("google_sdk") + || Build.MODEL.contains("Emulator") + || Build.MODEL.contains("Android SDK built for x86") + || Build.MODEL.contains("VirtualBox") + || Build.MANUFACTURER.contains("Genymotion") + || Build.PRODUCT.contains("sdk_google") + || Build.PRODUCT.contains("google_sdk") + || Build.PRODUCT.contains("sdk") + || Build.PRODUCT.contains("sdk_x86") + || Build.PRODUCT.contains("vbox86p") + || Build.PRODUCT.contains("emulator") + || Build.PRODUCT.contains("simulator")); + } + + + /** + * @inheritDoc + */ + @Override + public boolean canDial() { + return getContext().getPackageManager().hasSystemFeature(PackageManager.FEATURE_TELEPHONY); + } + + /** + * @inheritDoc + */ + private static String cn1DistributionChannel; + private static boolean cn1DistributionChannelResolved; + /** Codename One channel id-value pair id in the APK Signing Block ('c','n','1','C'). */ + private static final int CN1_CHANNEL_PAIR_ID = 0x636E3143; + + /** + * The distribution channel (app store) stamped into this APK's Signing Block by + * the build server's channel packages, or null for a normal build. Read once and + * cached. Mirrors the daemon's {@code ApkChannelWriter}: locate the signing block + * before the central directory and return the Codename One channel pair's value. + */ + private String readDistributionChannel() { + if (cn1DistributionChannelResolved) { + return cn1DistributionChannel; + } + cn1DistributionChannelResolved = true; + try { + cn1DistributionChannel = cn1ReadChannelFromApk(getContext().getApplicationInfo().sourceDir); + } catch (Throwable t) { + cn1DistributionChannel = null; + } + return cn1DistributionChannel; + } + + private static String cn1ReadChannelFromApk(String path) throws java.io.IOException { + java.io.RandomAccessFile f = new java.io.RandomAccessFile(path, "r"); + try { + long len = f.length(); + long eocd = -1; + long maxBack = Math.min(len, 22 + 0xFFFF); + for (long i = len - 22; i >= len - maxBack && i >= 0; i--) { + if (cn1U32(f, i) == 0x06054b50L) { + eocd = i; + break; + } + } + if (eocd < 0) { + return null; + } + long cdOffset = cn1U32(f, eocd + 16); + if (cdOffset < 24 || cdOffset == 0xFFFFFFFFL) { + return null; + } + byte[] magic = "APK Sig Block 42".getBytes("US-ASCII"); + byte[] m = new byte[magic.length]; + f.seek(cdOffset - 16); + f.readFully(m); + for (int i = 0; i < magic.length; i++) { + if (m[i] != magic[i]) { + return null; + } + } + long sizeOfBlock = cn1U64(f, cdOffset - 24); + long blockStart = cdOffset - 8 - sizeOfBlock; + if (blockStart < 0) { + return null; + } + long p = blockStart + 8, to = cdOffset - 24; + while (p < to) { + long pairLen = cn1U64(f, p); + p += 8; + if (pairLen < 4 || p + pairLen > to + 8) { + break; + } + if ((int) cn1U32(f, p) == CN1_CHANNEL_PAIR_ID) { + byte[] v = new byte[(int) (pairLen - 4)]; + f.seek(p + 4); + f.readFully(v); + return new String(v, "UTF-8"); + } + p += pairLen; + } + return null; + } finally { + f.close(); + } + } + + private static long cn1U32(java.io.RandomAccessFile f, long at) throws java.io.IOException { + f.seek(at); + int b0 = f.read(), b1 = f.read(), b2 = f.read(), b3 = f.read(); + return (b0 & 0xFFL) | ((b1 & 0xFFL) << 8) | ((b2 & 0xFFL) << 16) | ((b3 & 0xFFL) << 24); + } + + private static long cn1U64(java.io.RandomAccessFile f, long at) throws java.io.IOException { + f.seek(at); + long v = 0; + for (int i = 0; i < 8; i++) { + v |= (f.read() & 0xFFL) << (8 * i); + } + return v; + } + + public String getProperty(String key, String defaultValue) { + if(key.equalsIgnoreCase("cn1_push_prefix")) { + /*if(!checkForPermission(Manifest.permission.READ_PHONE_STATE, "This is required to get notifications")){ + return ""; + }*/ + boolean has = hasAndroidMarket(); + if(has) { + return "gcm"; + } + return defaultValue; + } + if ("OS".equals(key)) { + return "Android"; + } + if ("DistributionChannel".equalsIgnoreCase(key) || "cn1.channel".equalsIgnoreCase(key)) { + // The app store this build was distributed through, stamped into the APK + // Signing Block by the Codename One build server's channel packages + // (android.distributionChannels). Empty for a normal Google Play build. + String ch = readDistributionChannel(); + return ch != null ? ch : defaultValue; + } + + // It's possible that this is triggering a Google Play data collection verification error + /*if ("androidId".equals(key)) { + return Settings.Secure.getString(getContext().getContentResolver(), Settings.Secure.ANDROID_ID); + }*/ + + /*if ("cellId".equals(key)) { + try { + if(!checkForPermission(Manifest.permission.READ_PHONE_STATE, "This is required to get the cellId")){ + return defaultValue; + } + String serviceName = Context.TELEPHONY_SERVICE; + TelephonyManager telephonyManager = (TelephonyManager) getContext().getSystemService(serviceName); + int cellId = ((GsmCellLocation) telephonyManager.getCellLocation()).getCid(); + return "" + cellId; + } catch (Throwable t) { + return defaultValue; + } + }*/ + if ("AppName".equals(key)) { + + final PackageManager pm = getContext().getPackageManager(); + ApplicationInfo ai; + try { + ai = pm.getApplicationInfo(getContext().getPackageName(), 0); + } catch (NameNotFoundException e) { + ai = null; + } + String applicationName = (String) (ai != null ? pm.getApplicationLabel(ai) : null); + if(applicationName == null){ + return defaultValue; + } + return applicationName; + } + if ("AppVersion".equals(key)) { + try { + PackageInfo i = getContext().getPackageManager().getPackageInfo(getContext().getApplicationInfo().packageName, 0); + return i.versionName; + } catch (NameNotFoundException ex) { + ex.printStackTrace(); + } + return defaultValue; + } + if ("Platform".equals(key)) { + String p = System.getProperty("platform"); + if(p == null) { + return defaultValue; + } + return p; + } + if ("User-Agent".equals(key)) { + String ua = getUserAgent(); + if(ua == null) { + return defaultValue; + } + return ua; + } + if("OSVer".equals(key)) { + return "" + android.os.Build.VERSION.RELEASE; + } + if("DeviceName".equals(key)) { + return "" + android.os.Build.MODEL; + } + if("DeviceHardwareModel".equals(key)) { + return "" + android.os.Build.MODEL; + } + if("DeviceManufacturer".equals(key)) { + return "" + android.os.Build.MANUFACTURER; + } + if("Emulator".equals(key)) { + return "" + isEmulator(); + } + /*try { + if ("IMEI".equals(key) || "UDID".equals(key)) { + if(!checkForPermission(Manifest.permission.READ_PHONE_STATE, "This is required to get the device ID")){ + return ""; + } + TelephonyManager tm = (TelephonyManager) getContext().getSystemService(Context.TELEPHONY_SERVICE); + String imei = null; + if (tm!=null && tm.getDeviceId() != null) { + // for phones or 3g tablets + imei = tm.getDeviceId(); + } else { + try { + imei = Secure.getString(getContext().getContentResolver(), Secure.ANDROID_ID); + } catch(Throwable t) { + com.codename1.io.Log.e(t); + } + } + return imei; + } + if ("MSISDN".equals(key)) { + if(!checkForPermission(Manifest.permission.READ_PHONE_STATE, "This is required to get the device ID")){ + return ""; + } + TelephonyManager tm = (TelephonyManager) getContext().getSystemService(Context.TELEPHONY_SERVICE); + return tm.getLine1Number(); + } + } catch(Throwable t) { + // will be caused by no permissions. + return defaultValue; + }*/ + + if (getActivity() != null) { + android.content.Intent intent = getActivity().getIntent(); + if(intent != null){ + Bundle extras = intent.getExtras(); + if (extras != null) { + String value = extras.getString(key); + if(value != null) { + return value; + } + } + } + } + + if(!key.startsWith("android.permission")) { + //these keys/values are from the Application Resources (strings values) + try { + int id = getContext().getResources().getIdentifier(key, "string", getContext().getApplicationInfo().packageName); + if (id != 0) { + String val = getContext().getResources().getString(id); + return val; + } + } catch (Exception e) { + } + } + return System.getProperty(key, super.getProperty(key, defaultValue)); + } + + private String getContentName(ContentResolver resolver, Uri uri) { + Cursor cursor = resolver.query(uri, null, null, null, null); + cursor.moveToFirst(); + int nameIndex = cursor.getColumnIndex(MediaStore.MediaColumns.DISPLAY_NAME); + if (nameIndex >= 0) { + String name = cursor.getString(nameIndex); + cursor.close(); + return name; + } + return null; + } + + private String getUserAgent() { + try { + String userAgent = System.getProperty("http.agent"); + if(userAgent != null){ + return userAgent; + } + } catch (Exception e) { + } + if (getActivity() == null) { + return "Android-CN1"; + } + try { + Constructor constructor = WebSettings.class.getDeclaredConstructor(Context.class, WebView.class); + constructor.setAccessible(true); + try { + WebSettings settings = constructor.newInstance(getActivity(), null); + return settings.getUserAgentString(); + } finally { + constructor.setAccessible(false); + } + } catch (Exception e) { + final StringBuffer ua = new StringBuffer(); + if (Thread.currentThread().getName().equalsIgnoreCase("main")) { + WebView m_webview = new WebView(getActivity()); + ua.append(m_webview.getSettings().getUserAgentString()); + m_webview.destroy(); + } else { + final boolean[] flag = new boolean[1]; + Thread thread = new Thread() { + public void run() { + Looper.prepare(); + WebView m_webview = new WebView(getActivity()); + ua.append(m_webview.getSettings().getUserAgentString()); + m_webview.destroy(); + Looper.loop(); + flag[0] = true; + synchronized (flag) { + flag.notify(); + } + } + }; + thread.setUncaughtExceptionHandler(AndroidImplementation.exceptionHandler); + thread.start(); + while (!flag[0]) { + synchronized (flag) { + try { + flag.wait(100); + } catch (InterruptedException ex) { + } + } + } + } + return ua.toString(); + } + } + + private String getMimeType(String url){ + String type = null; + String extension = MimeTypeMap.getFileExtensionFromUrl(url); + if (extension != null) { + MimeTypeMap mime = MimeTypeMap.getSingleton(); + + type = mime.getMimeTypeFromExtension(extension); + } + if (type == null) { + try { + Uri uri = Uri.parse(url); + ContentResolver cr = getContext().getContentResolver(); + type = cr.getType(uri); + } catch (Throwable t) { + t.printStackTrace(); + } + } + return type; + } + + public static void copy(File src, File dst) throws IOException { + InputStream in = new FileInputStream(src); + try { + OutputStream out = new FileOutputStream(dst); + try { + // Transfer bytes from in to out + byte[] buf = new byte[8096]; + int len; + while ((len = in.read(buf)) > 0) { + out.write(buf, 0, len); + } + } finally { + out.close(); + } + } finally { + in.close(); + } + } + + private static File makeTempCacheCopy(File file) throws IOException { + File cacheDir = new File(getContext().getCacheDir(), "intent_files"); + + // Create the storage directory if it does not exist + if (!cacheDir.exists()) { + if (!cacheDir.mkdirs()) { + Log.d(Display.getInstance().getProperty("AppName", "CodenameOne"), "failed to create directory"); + return null; + } + } + + File copy = new File(cacheDir, "tmp-"+System.currentTimeMillis()+file.getName()); + copy(file, copy); + return copy; + + } + + + + private Intent createIntentForURL(String url) { + Intent intent; + Uri uri; + try { + if (url.startsWith("intent")) { + intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME); + } else { + if(url.startsWith("/") || url.startsWith("file:")) { + if (PermissionsHelper.requiresExternalStoragePermissionForMediaAccess()) { + if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to open the file")){ + return null; + } + } + + } + intent = new Intent(); + intent.setAction(Intent.ACTION_VIEW); + if (url.startsWith("/")) { + File f = new File(url); + Uri furi = null; + try { + furi = FileProvider.getUriForFile(getContext(), getContext().getPackageName()+".provider", f); + } catch (Exception ex) { + f = makeTempCacheCopy(f); + furi = FileProvider.getUriForFile(getContext(), getContext().getPackageName()+".provider", f); + } + + + if (Build.VERSION.SDK_INT < 21) { + List resInfoList = getContext().getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); + for (ResolveInfo resolveInfo : resInfoList) { + String packageName = resolveInfo.activityInfo.packageName; + getContext().grantUriPermission(packageName, furi, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + } + + uri = furi; + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK|Intent.FLAG_GRANT_READ_URI_PERMISSION); + }else{ + + if (url.startsWith("file:")) { + File f = new File(removeFilePrefix(url)); + System.out.println("File size: "+f.length()); + + Uri furi = null; + try { + furi = FileProvider.getUriForFile(getContext(), getContext().getPackageName()+".provider", f); + } catch (Exception ex) { + f = makeTempCacheCopy(f); + furi = FileProvider.getUriForFile(getContext(), getContext().getPackageName()+".provider", f); + } + + + if (Build.VERSION.SDK_INT < 21) { + List resInfoList = getContext().getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); + for (ResolveInfo resolveInfo : resInfoList) { + String packageName = resolveInfo.activityInfo.packageName; + getContext().grantUriPermission(packageName, furi, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + } + uri = furi; + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK|Intent.FLAG_GRANT_READ_URI_PERMISSION); + + + } else { + uri = Uri.parse(url); + } + } + String mimeType = getMimeType(url); + if(mimeType != null){ + intent.setDataAndType(uri, mimeType); + }else{ + intent.setData(uri); + } + } + + return intent; + } catch(Exception err) { + com.codename1.io.Log.e(err); + return null; + } + } + + @Override + public Boolean canExecute(String url) { + try { + Intent it = createIntentForURL(url); + if(it == null) { + return false; + } + final PackageManager mgr = getContext().getPackageManager(); + List list = mgr.queryIntentActivities(it, PackageManager.MATCH_DEFAULT_ONLY); + return list.size() > 0; + } catch(Exception err) { + com.codename1.io.Log.e(err); + return false; + } + } + + + public void execute(String url, ActionListener response) { + if (response != null) { + callback = new EventDispatcher(); + callback.addListener(response); + } + + try { + Intent intent = createIntentForURL(url); + if(intent == null) { + return; + } + if(response != null && getActivity() != null){ + getActivity().startActivityForResult(intent, IntentResultListener.URI_SCHEME); + }else { + getContext().startActivity(intent); + } + return; + } catch (Exception ex) { + com.codename1.io.Log.e(ex); + } + + try { + if(editInProgress()) { + stopEditing(true); + } + getContext().startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url))); + } catch (Exception e) { + e.printStackTrace(); + } + } + + + /** + * @inheritDoc + */ + @Override + public void execute(String url) { + execute(url, null); + } + + /** + * @inheritDoc + */ + public void playBuiltinSound(String soundIdentifier) { + if (getActivity() != null && Display.SOUND_TYPE_BUTTON_PRESS.equals(soundIdentifier)) { + getActivity().runOnUiThread(new Runnable() { + public void run() { + if (myView != null) { + myView.getAndroidView().playSoundEffect(AudioManager.FX_KEY_CLICK); + } + } + }); + } + } + + /** + * @inheritDoc + */ + protected void playNativeBuiltinSound(Object data) { + } + + /** + * @inheritDoc + */ + public boolean isBuiltinSoundAvailable(String soundIdentifier) { + return false; + } + + /** + * @inheritDoc + */ + @Override + public boolean isNativeVideoPlayerControlsIncluded() { + return true; + } + + private static final int STATE_PAUSED = 0; + private static final int STATE_PLAYING = 1; + + private int mCurrentState; + + private MediaBrowserCompat mMediaBrowserCompat; + private android.support.v4.media.session.MediaControllerCompat mMediaControllerCompat; + + private android.support.v4.media.session.MediaControllerCompat.Callback mMediaControllerCompatCallback = new android.support.v4.media.session.MediaControllerCompat.Callback() { + + @Override + public void onPlaybackStateChanged(PlaybackStateCompat state) { + super.onPlaybackStateChanged(state); + if( state == null ) { + return; + } + + switch( state.getState() ) { + case PlaybackStateCompat.STATE_PLAYING: { + mCurrentState = STATE_PLAYING; + break; + } + case PlaybackStateCompat.STATE_PAUSED: { + mCurrentState = STATE_PAUSED; + break; + } + } + } + }; + + private MediaBrowserCompat.ConnectionCallback mMediaBrowserCompatConnectionCallback = new MediaBrowserCompat.ConnectionCallback() { + + @Override + public void onConnected() { + super.onConnected(); + try { + mMediaControllerCompat = new MediaControllerCompat(getActivity(), mMediaBrowserCompat.getSessionToken()); + mMediaControllerCompat.registerCallback(mMediaControllerCompatCallback); + MediaControllerCompat.setMediaController(getActivity(), mMediaControllerCompat); + MediaControllerCompat.getMediaController(getActivity()).getTransportControls().play(); + + } catch( RemoteException e ) { + e.printStackTrace(); + } + } + }; + + //BackgroundAudioService remoteControl; + + @Override + public void startRemoteControl() { + super.startRemoteControl(); + getActivity().runOnUiThread(new Runnable() { + public void run() { + mMediaBrowserCompat = new MediaBrowserCompat(getActivity(), new ComponentName(getActivity(), BackgroundAudioService.class), + mMediaBrowserCompatConnectionCallback, getActivity().getIntent().getExtras()); + + mMediaBrowserCompat.connect(); + AndroidNativeUtil.addLifecycleListener(new LifecycleListener() { + @Override + public void onCreate(Bundle savedInstanceState) { + + } + + @Override + public void onResume() { + + } + + @Override + public void onPause() { + + } + + @Override + public void onDestroy() { + if (mMediaBrowserCompat != null) { + if( MediaControllerCompat.getMediaController(getActivity()).getPlaybackState().getState() == PlaybackStateCompat.STATE_PLAYING ) { + MediaControllerCompat.getMediaController(getActivity()).getTransportControls().pause(); + } + + mMediaBrowserCompat.disconnect(); + mMediaBrowserCompat = null; + } + } + + @Override + public void onSaveInstanceState(Bundle b) { + + } + + @Override + public void onLowMemory() { + + } + }); + } + + }); + + } + + @Override + public void stopRemoteControl() { + super.stopRemoteControl(); + if (mMediaBrowserCompat != null) { + if( MediaControllerCompat.getMediaController(getActivity()).getPlaybackState().getState() == PlaybackStateCompat.STATE_PLAYING ) { + MediaControllerCompat.getMediaController(getActivity()).getTransportControls().pause(); + } + + mMediaBrowserCompat.disconnect(); + mMediaBrowserCompat = null; + } + } + + + @Override + public AsyncResource createBackgroundMediaAsync(final String uri) { + final AsyncResource out = new AsyncResource(); + new Thread(new Runnable() { + public void run() { + try { + out.complete(createBackgroundMedia(uri)); + } catch (IOException ex) { + out.error(ex); + } + } + }).start(); + + return out; + } + + private int nextMediaId; + private int backgroundMediaCount; + private ServiceConnection backgroundMediaServiceConnection; + @Override + public Media createBackgroundMedia(final String uri) throws IOException { + int mediaId = nextMediaId++; + backgroundMediaCount++; + + Intent serviceIntent = new Intent(getContext(), AudioService.class); + serviceIntent.putExtra("mediaLink", uri); + serviceIntent.putExtra("mediaId", mediaId); + if (background == null) { + ServiceConnection mConnection = new ServiceConnection() { + + public void onServiceDisconnected(ComponentName name) { + + background = null; + backgroundMediaServiceConnection = null; + } + + public void onServiceConnected(ComponentName name, IBinder service) { + AudioService.LocalBinder mLocalBinder = (AudioService.LocalBinder) service; + AudioService svc = (AudioService)mLocalBinder.getService(); + background = svc; + } + }; + backgroundMediaServiceConnection = mConnection; + boolean boundSuccess = getContext().bindService(serviceIntent, mConnection, getContext().BIND_AUTO_CREATE); + if (!boundSuccess) { + throw new RuntimeException("Failed to bind background media service for uri "+uri); + } + ContextCompat.startForegroundService(getContext(), serviceIntent); + while (background == null) { + Display.getInstance().invokeAndBlock(new Runnable() { + @Override + public void run() { + Util.sleep(200); + } + }); + } + } else { + ContextCompat.startForegroundService(getContext(), serviceIntent); + } + + while (background.getMedia(mediaId) == null) { + Display.getInstance().invokeAndBlock(new Runnable() { + public void run() { + Util.sleep(200); + } + + }); + } + Media ret = new MediaProxy(background.getMedia(mediaId)) { + + + @Override + public void cleanup() { + super.cleanup(); + if (--backgroundMediaCount <= 0) { + if (backgroundMediaServiceConnection != null) { + try { + getContext().unbindService(backgroundMediaServiceConnection); + } catch (IllegalArgumentException ex) { + // This is thrown sometimes if the service has already been unbound + } + } + } + } + }; + + return ret; + + } + + + /** + * @inheritDoc + */ + @Override + public Media createMedia(final String uri, boolean isVideo, final Runnable onCompletion) throws IOException { + if (getActivity() == null) { + return null; + } + if (uri.startsWith("file://")) { + return createMedia(removeFilePrefix(uri), isVideo, onCompletion); + } + File file = null; + if (uri.indexOf(':') < 0) { + // use a file object to play to try and workaround this issue: + // http://code.google.com/p/android/issues/detail?id=4124 + file = new File(uri); + } + + Uri parsedUri = null; + boolean isContentUri = false; + if (file == null) { + parsedUri = Uri.parse(uri); + isContentUri = parsedUri != null && "content".equalsIgnoreCase(parsedUri.getScheme()); + } + + // The document picker grants temporary permissions for content URIs. Requesting + // READ_EXTERNAL_STORAGE again would surface a redundant prompt on Android 13+, so we only + // ask for classic file paths that require the legacy permission. MediaStore URIs still + // require an explicit permission grant, so they remain subject to the legacy check even + // though they also use the content:// scheme. + boolean requiresLegacyPermission = !uri.startsWith(FileSystemStorage.getInstance().getAppHomePath()); + if (isContentUri && parsedUri != null) { + String authority = parsedUri.getAuthority(); + if (authority != null) { + authority = authority.toLowerCase(); + if (!"media".equals(authority) && !authority.startsWith("media.")) { + if (!"com.android.providers.media.documents".equals(authority)) { + requiresLegacyPermission = false; + } + } + } else { + requiresLegacyPermission = false; + } + } + + if(requiresLegacyPermission) { + if(!PermissionsHelper.checkForPermission(isVideo ? DevicePermission.PERMISSION_READ_VIDEO : DevicePermission.PERMISSION_READ_AUDIO, "This is required to play media")){ + return null; + } + } + + Media retVal; + + if (isVideo) { + final AndroidImplementation.Video[] video = new AndroidImplementation.Video[1]; + final boolean[] flag = new boolean[1]; + final File f = file; + final Uri videoUri = parsedUri; + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + VideoView v = new VideoView(getActivity()); + v.setZOrderMediaOverlay(true); + if (f != null) { + v.setVideoURI(Uri.fromFile(f)); + } else { + v.setVideoURI(videoUri != null ? videoUri : Uri.parse(uri)); + } + video[0] = new AndroidImplementation.Video(v, getActivity(), onCompletion); + flag[0] = true; + synchronized (flag) { + flag.notify(); + } + } + }); + while (!flag[0]) { + synchronized (flag) { + try { + flag.wait(100); + } catch (InterruptedException ex) { + } + } + } + return video[0]; + } else { + MediaPlayer player; + if (file != null) { + FileInputStream is = new FileInputStream(file); + player = new MediaPlayer(); + player.setDataSource(is.getFD()); + player.prepare(); + } else { + player = MediaPlayer.create(getActivity(), parsedUri != null ? parsedUri : Uri.parse(uri)); + if (player == null && isContentUri) { + // Android 13+ introduces stricter access rules for content:// URIs returned + // from the system document picker. The picker grants our activity a + // persistable read permission, but some OEM builds still reject the URI when it + // is passed directly to MediaPlayer. Opening the descriptor ourselves keeps the + // same permission grant while avoiding the OEM bug. + ContentResolver resolver = getContext().getContentResolver(); + if (resolver != null && parsedUri != null) { + AssetFileDescriptor afd = null; + try { + afd = resolver.openAssetFileDescriptor(parsedUri, "r"); + if (afd != null) { + player = new MediaPlayer(); + player.setDataSource(afd.getFileDescriptor(), afd.getStartOffset(), afd.getLength()); + player.prepare(); + } + } finally { + if (afd != null) { + try { + afd.close(); + } catch (IOException ignore) { + } + } + } + } + } + } + if (player == null) { + throw new IOException("Unable to create media player for uri " + uri); + } + retVal = new Audio(getActivity(), player, null, onCompletion); + } + return retVal; + } + + @Override + public void addCompletionHandler(Media media, Runnable onCompletion) { + super.addCompletionHandler(media, onCompletion); + if (media instanceof Video) { + ((Video)media).addCompletionHandler(onCompletion); + } else if (media instanceof Audio) { + ((Audio)media).addCompletionHandler(onCompletion); + } else if (media instanceof MediaProxy) { + ((MediaProxy)media).addCompletionHandler(onCompletion); + } + } + + @Override + public void removeCompletionHandler(Media media, Runnable onCompletion) { + super.removeCompletionHandler(media, onCompletion); + if (media instanceof Video) { + ((Video)media).removeCompletionHandler(onCompletion); + } else if (media instanceof Audio) { + ((Audio)media).removeCompletionHandler(onCompletion); + } else if (media instanceof MediaProxy) { + ((MediaProxy)media).removeCompletionHandler(onCompletion); + } + } + + + + /** + * @inheritDoc + */ + @Override + public Media createMedia(InputStream stream, String mimeType, final Runnable onCompletion) throws IOException { + if (getActivity() == null) { + return null; + } + /*if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to play media")){ + return null; + }*/ + boolean isVideo = mimeType.contains("video"); + + if (!isVideo && stream instanceof FileInputStream) { + MediaPlayer player = new MediaPlayer(); + player.setDataSource(((FileInputStream) stream).getFD()); + player.prepare(); + return new Audio(getActivity(), player, stream, onCompletion); + } + String extension = MimeTypeMap.getFileExtensionFromUrl(mimeType); + final File temp = File.createTempFile("mtmp", extension == null ? "dat" : extension); + temp.deleteOnExit(); + OutputStream out = createFileOuputStream(temp); + + byte buf[] = new byte[256]; + int len = 0; + while ((len = stream.read(buf, 0, buf.length)) > -1) { + out.write(buf, 0, len); + } + out.close(); + stream.close(); + + final Runnable finish = new Runnable() { + + @Override + public void run() { + if(onCompletion != null){ + Display.getInstance().callSerially(onCompletion); + + // makes sure the file is only deleted after the onCompletion was invoked + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + temp.delete(); + } + }); + return; + } + temp.delete(); + } + }; + + if (isVideo) { + final AndroidImplementation.Video[] retVal = new AndroidImplementation.Video[1]; + final boolean[] flag = new boolean[1]; + + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + VideoView v = new VideoView(getActivity()); + v.setZOrderMediaOverlay(true); + v.setVideoURI(Uri.fromFile(temp)); + retVal[0] = new AndroidImplementation.Video(v, getActivity(), finish); + flag[0] = true; + synchronized (flag) { + flag.notify(); + } + } + }); + while (!flag[0]) { + synchronized (flag) { + try { + flag.wait(100); + } catch (InterruptedException ex) { + } + } + } + + return retVal[0]; + } else { + return createMedia(createFileInputStream(temp), mimeType, finish); + } + + } + + @Override + public boolean isSoundPoolSupported() { + return getContext() != null; + } + + @Override + public com.codename1.media.SoundPoolPeer createSoundPool(int maxStreams) { + if (getContext() == null) { + return null; + } + return new com.codename1.media.GameSoundPool(this, maxStreams); + } + + @Override + public Media createMediaRecorder(MediaRecorderBuilder builder) throws IOException { + return createMediaRecorder(builder.getPath(), builder.getMimeType(), builder.getSamplingRate(), builder.getBitRate(), builder.getAudioChannels(), 0, builder.isRedirectToAudioBuffer()); + } + + @Override + public Media createMediaRecorder(final String path, final String mimeType) throws IOException { + MediaRecorderBuilder builder = new MediaRecorderBuilder() + .path(path) + .mimeType(mimeType); + return createMediaRecorder(builder); + } + + + + private Media createMediaRecorder(final String path, final String mimeType, final int sampleRate, final int bitRate, final int audioChannels, final int maxDuration, final boolean redirectToAudioBuffer) throws IOException { + if (getActivity() == null) { + return null; + } + if(!checkForPermission(Manifest.permission.RECORD_AUDIO, "This is required to record audio")){ + return null; + } + final Media[] record = new Media[1]; + final IOException[] error = new IOException[1]; + + final Object lock = new Object(); + synchronized (lock) { + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + synchronized (lock) { + if (redirectToAudioBuffer) { + final int channelConfig =audioChannels == 1 ? android.media.AudioFormat.CHANNEL_IN_MONO + : audioChannels == 2 ? android.media.AudioFormat.CHANNEL_IN_STEREO + : android.media.AudioFormat.CHANNEL_IN_MONO; + final AudioRecord recorder = new AudioRecord( + MediaRecorder.AudioSource.MIC, + sampleRate, + channelConfig, + AudioFormat.ENCODING_PCM_16BIT, + AudioRecord.getMinBufferSize(sampleRate, channelConfig, AudioFormat.ENCODING_PCM_16BIT) + ); + + final com.codename1.media.AudioBuffer audioBuffer = com.codename1.media.MediaManager.getAudioBuffer(path, true, 64); + + record[0] = new AbstractMedia() { + private int lastTime; + private boolean isRecording; + @Override + protected void playImpl() { + if (isRecording) { + return; + } + isRecording = true; + recorder.startRecording(); + fireMediaStateChange(State.Playing); + new Thread(new Runnable() { + public void run() { + float[] audioData = new float[audioBuffer.getMaxSize()]; + short[] buffer = new short[AudioRecord.getMinBufferSize(recorder.getSampleRate(), recorder.getChannelCount(), AudioFormat.ENCODING_PCM_16BIT)]; + int read = -1; + int index = 0; + + while (isRecording && (read = recorder.read(buffer, 0, buffer.length)) >= 0) { + if (read > 0) { + for (int i=0; i= audioData.length) { + audioBuffer.copyFrom(sampleRate, audioChannels, audioData, 0, index); + index = 0; + } + } + if (index > 0) { + audioBuffer.copyFrom(sampleRate, audioChannels, audioData, 0, index); + index = 0; + } + } + } + + } + + }).start(); + } + + @Override + protected void pauseImpl() { + if (!isRecording) { + return; + } + isRecording = false; + recorder.stop(); + + + fireMediaStateChange(State.Paused); + } + + @Override + public void prepare() { + + } + + @Override + public void cleanup() { + pauseImpl(); + recorder.release(); + com.codename1.media.MediaManager.releaseAudioBuffer(path); + + } + + @Override + public int getTime() { + if (isRecording) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + AudioTimestamp ts = new AudioTimestamp(); + recorder.getTimestamp(ts, AudioTimestamp.TIMEBASE_MONOTONIC); + lastTime = (int) (ts.framePosition / ((float) sampleRate / 1000f)); + } + } + return lastTime; + } + + @Override + public void setTime(int time) { + + } + + @Override + public int getDuration() { + return getTime(); + } + + @Override + public void setVolume(int vol) { + + } + + @Override + public int getVolume() { + return 0; + } + + @Override + public boolean isPlaying() { + return recorder.getRecordingState() == AudioRecord.RECORDSTATE_RECORDING; + } + + @Override + public Component getVideoComponent() { + return null; + } + + @Override + public boolean isVideo() { + return false; + } + + @Override + public boolean isFullScreen() { + return false; + } + + @Override + public void setFullScreen(boolean fullScreen) { + + } + + @Override + public void setNativePlayerMode(boolean nativePlayer) { + + } + + @Override + public boolean isNativePlayerMode() { + return false; + } + + @Override + public void setVariable(String key, Object value) { + + } + + @Override + public Object getVariable(String key) { + return null; + } + + }; + lock.notify(); + } else { + MediaRecorder recorder = new MediaRecorder(); + recorder.setAudioSource(MediaRecorder.AudioSource.MIC); + + if(mimeType.contains("amr")){ + recorder.setOutputFormat(MediaRecorder.OutputFormat.AMR_NB); + recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AMR_NB); + }else{ + recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); + recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); + recorder.setAudioSamplingRate(sampleRate); + recorder.setAudioEncodingBitRate(bitRate); + } + if (audioChannels > 0) { + recorder.setAudioChannels(audioChannels); + } + if (maxDuration > 0) { + recorder.setMaxDuration(maxDuration); + } + recorder.setOutputFile(removeFilePrefix(path)); + try { + recorder.prepare(); + record[0] = new AndroidRecorder(recorder); + } catch (IllegalStateException ex) { + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + } catch (IOException ex) { + error[0] = ex; + } finally { + lock.notify(); + } + } + + + + } + } + }); + + try { + lock.wait(); + } catch (InterruptedException ex) { + ex.printStackTrace(); + } + + if (error[0] != null) { + throw error[0]; + } + + return record[0]; + } + } + + public String [] getAvailableRecordingMimeTypes(){ + // audio/aac and audio/mp4 result in the same thing + // AAC are wrapped in an mp4 container. + return new String[]{"audio/amr", "audio/aac", "audio/mp4"}; + } + + + /** + * @inheritDoc + */ + public Object createSoftWeakRef(Object o) { + return new SoftReference(o); + } + + /** + * @inheritDoc + */ + public Object extractHardRef(Object o) { + SoftReference w = (SoftReference) o; + if (w != null) { + return w.get(); + } + return null; + } + + /** + * @inheritDoc + */ + public PeerComponent createNativePeer(Object nativeComponent) { + if (!(nativeComponent instanceof View)) { + throw new IllegalArgumentException(nativeComponent.getClass().getName()); + } + return new AndroidImplementation.AndroidPeer((View) nativeComponent); + } + + private final java.util.Map glSurfaces = + new java.util.IdentityHashMap(); + + private final com.codename1.impl.gpu.GpuImplementation gpuImpl = + new com.codename1.impl.gpu.GpuImplementation() { + @Override + public PeerComponent createPeer(final com.codename1.gpu.RenderView view) { + final CodenameOneActivity a = getActivity(); + if (a == null) { + return null; + } + // The GLSurfaceView must be constructed on the UI thread; block until + // it exists so we can wrap and return its peer to the caller. + final AndroidGLSurface[] holder = new AndroidGLSurface[1]; + final java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1); + a.runOnUiThread(new Runnable() { + public void run() { + try { + holder[0] = new AndroidGLSurface(a, view); + } catch (Throwable t) { + t.printStackTrace(); + } finally { + latch.countDown(); + } + } + }); + try { + latch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + AndroidGLSurface surface = holder[0]; + if (surface == null) { + return null; + } + PeerComponent peer = createNativePeer(surface); + if (peer != null) { + glSurfaces.put(peer, surface); + } + return peer; + } + + @Override + public void setContinuous(PeerComponent peer, final boolean continuous) { + final AndroidGLSurface surface = glSurfaces.get(peer); + if (surface == null) { + return; + } + final CodenameOneActivity a = getActivity(); + if (a == null) { + return; + } + a.runOnUiThread(new Runnable() { + public void run() { + surface.setRenderMode(continuous + ? android.opengl.GLSurfaceView.RENDERMODE_CONTINUOUSLY + : android.opengl.GLSurfaceView.RENDERMODE_WHEN_DIRTY); + } + }); + } + + @Override + public void requestRender(PeerComponent peer) { + AndroidGLSurface surface = glSurfaces.get(peer); + if (surface != null) { + surface.requestRender(); + } + } + }; + + @Override + public com.codename1.impl.gpu.GpuImplementation getGpuImplementation() { + return gpuImpl; + } + + private void blockNativeFocusAll(boolean block) { + synchronized (this.nativePeers) { + final int size = this.nativePeers.size(); + for (int i = 0; i < size; i++) { + AndroidImplementation.AndroidPeer next = (AndroidImplementation.AndroidPeer) this.nativePeers.get(i); + next.blockNativeFocus(block); + } + } + } + + public void onFocusChange(View view, boolean bln) { + + if (bln) { + /** + * whenever the base view receives focus we automatically block + * possible native subviews from gaining focus. + */ + blockNativeFocusAll(true); + if (this.lastDirectionalKeyEventReceivedByWrapper != 0) { + /** + * because we also consume any key event in the OnKeyListener of + * the native wrappers, we have to simulate key events to make + * Codename One move the focus to the next component. + */ + if (myView == null) { + return; + } + if (!myView.getAndroidView().isInTouchMode()) { + switch (lastDirectionalKeyEventReceivedByWrapper) { + case AndroidImplementation.DROID_IMPL_KEY_LEFT: + case AndroidImplementation.DROID_IMPL_KEY_RIGHT: + case AndroidImplementation.DROID_IMPL_KEY_UP: + case AndroidImplementation.DROID_IMPL_KEY_DOWN: + Display.getInstance().keyPressed(lastDirectionalKeyEventReceivedByWrapper); + Display.getInstance().keyReleased(lastDirectionalKeyEventReceivedByWrapper); + break; + default: + Log.d("Codename One", "unexpected keycode: " + lastDirectionalKeyEventReceivedByWrapper); + break; + } + } else { + Log.d("Codename One", "base view gained focus but no key event to process."); + } + lastDirectionalKeyEventReceivedByWrapper = 0; + } + } + + } + + @Override + public void edtIdle(boolean enter) { + super.edtIdle(enter); + if(enter) { + // check if we have peers waiting for resize... + if(myView instanceof AndroidAsyncView) { + ((AndroidAsyncView)myView).resizeViews(); + } + } + } + + static final Map activePeers = new HashMap(); + + + /** + * wrapper component that capsules a native view object in a Codename One + * component. this involves A LOT of back and forth between the Codename One + * EDT and the Android UI thread. + * + * + * To use it you would: + * + * 1) create your native Android view(s). Make sure to work on the Android + * UI thread when constructing and modifying them. 2) create a Codename One + * peer component by calling: + * + * com.codename1.ui.PeerComponent.create(myAndroidView); + * + * 3) currently the view's size is not automatically calculated from the + * native view. so you should set the preferred size of the Codename One + * component manually. + * + * + */ + class AndroidPeer extends PeerComponent { + + private View v; + private AndroidImplementation.AndroidRelativeLayout layoutWrapper = null; + private int currentVisible = View.INVISIBLE; + private boolean lightweightMode; + + public AndroidPeer(View vv) { + super(vv); + this.v = vv; + if(!superPeerMode) { + v.measure(MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED)); + } + } + + @Override + protected Image generatePeerImage() { + try { + Bitmap bmp = AndroidNativeUtil.renderViewOnBitmap(v, getWidth(), getHeight()); + if(bmp == null) { + return Image.createImage(5, 5); + } + Image image = new AndroidImplementation.NativeImage(bmp); + return image; + } catch(Throwable t) { + t.printStackTrace(); + return Image.createImage(5, 5); + } + } + + protected boolean shouldRenderPeerImage() { + return !superPeerMode && (lightweightMode || !isInitialized()); + } + + protected void setLightweightMode(boolean l) { + if(superPeerMode) { + if (l != lightweightMode) { + lightweightMode = l; + if (lightweightMode) { + Image img = generatePeerImage(); + if (img != null) { + peerImage = img; + } + } + + } + return; + } + doSetVisibility(!l); + if (lightweightMode == l) { + return; + } + lightweightMode = l; + } + + @Override + public void setVisible(boolean visible) { + super.setVisible(visible); + this.doSetVisibility(visible); + } + + void doSetVisibility(final boolean visible) { + if (getActivity() == null) { + return; + } + getActivity().runOnUiThread(new Runnable() { + public void run() { + currentVisible = visible ? View.VISIBLE : View.INVISIBLE; + v.setVisibility(currentVisible); + if (visible) { + v.bringToFront(); + } + } + }); + if(visible){ + layoutPeer(); + } + } + + private void doSetVisibilityInternal(final boolean visible) { + if (getActivity() == null) { + return; + } + getActivity().runOnUiThread(new Runnable() { + public void run() { + currentVisible = visible ? View.VISIBLE : View.INVISIBLE; + v.setVisibility(currentVisible); + if (visible) { + v.bringToFront(); + } + } + }); + } + + protected void deinitialize() { + if(!superPeerMode) { + Image i = generatePeerImage(); + setPeerImage(i); + super.deinitialize(); + synchronized (nativePeers) { + nativePeers.remove(this); + } + deinit(); + }else{ + Image img = generatePeerImage(); + if (img != null) { + peerImage = img; + } + + if(myView instanceof AndroidAsyncView){ + ((AndroidAsyncView)myView).removePeerView(v); + } + super.deinitialize(); + } + } + + public void deinit(){ + if (getActivity() == null) { + return; + } + if (peerImage == null) { + peerImage = generatePeerImage(); + } + final boolean [] removed = new boolean[1]; + getActivity().runOnUiThread(new Runnable() { + public void run() { + try { + if (layoutWrapper != null && AndroidImplementation.this.relativeLayout != null) { + AndroidImplementation.this.relativeLayout.removeView(layoutWrapper); + AndroidImplementation.this.relativeLayout.requestLayout(); + layoutWrapper = null; + } + } finally { + removed[0] = true; + } + } + }); + while (!removed[0]) { + Display.getInstance().invokeAndBlock(new Runnable() { + public void run() { + if (!removed[0]) { + try { + Thread.sleep(5); + } catch(InterruptedException er) {} + } + } + }); + } + } + + protected void initComponent() { + super.initComponent(); + if(!superPeerMode) { + synchronized (nativePeers) { + nativePeers.add(this); + } + init(); + setPeerImage(null); + } + } + + public void init(){ + if(superPeerMode || getActivity() == null) { + return; + } + runOnUiThreadAndBlock(new Runnable() { + public void run() { + if (layoutWrapper == null) { + /** + * wrap the native item in a layout that we can move + * around on the surface view as we like. + */ + layoutWrapper = new AndroidImplementation.AndroidRelativeLayout(activity, AndroidImplementation.AndroidPeer.this, v); + layoutWrapper.setBackgroundDrawable(null); + v.setVisibility(currentVisible); + v.setFocusable(AndroidImplementation.AndroidPeer.this.isFocusable()); + v.setFocusableInTouchMode(true); + ArrayList viewList = new ArrayList(); + viewList.add(layoutWrapper); + v.addFocusables(viewList, View.FOCUS_DOWN); + v.addFocusables(viewList, View.FOCUS_UP); + v.addFocusables(viewList, View.FOCUS_LEFT); + v.addFocusables(viewList, View.FOCUS_RIGHT); + if (v.isFocusable() || v.isFocusableInTouchMode()) { + if (AndroidImplementation.AndroidPeer.super.hasFocus()) { + AndroidImplementation.this.blockNativeFocusAll(true); + blockNativeFocus(false); + if (!v.hasFocus()) { + v.requestFocus(); + } + + } else { + blockNativeFocus(true); + } + layoutWrapper.setOnKeyListener(new View.OnKeyListener() { + public boolean onKey(View view, int i, KeyEvent ke) { + lastDirectionalKeyEventReceivedByWrapper = CodenameOneView.internalKeyCodeTranslate(ke.getKeyCode()); + + // move focus back to base view. + if (AndroidImplementation.this.myView == null) return false; + AndroidImplementation.this.myView.getAndroidView().requestFocus(); + + /** + * if the wrapper has focus, then only because + * the wrapped native component just lost focus. + * we consume whatever key events we receive, + * just to make sure no half press/release + * sequence reaches the base view (and therefore + * Codename One). + */ + return true; + } + }); + layoutWrapper.setOnFocusChangeListener(new View.OnFocusChangeListener() { + public void onFocusChange(View view, boolean bln) { + Log.d("Codename One", "on focus change. " + view.toString() + " focus:" + bln + " touchmode: " + v.isInTouchMode()); + } + }); + layoutWrapper.setOnTouchListener(new View.OnTouchListener() { + public boolean onTouch(View v, MotionEvent me) { + if (myView == null) return false; + return myView.getAndroidView().onTouchEvent(me); + } + }); + } + if(AndroidImplementation.this.relativeLayout != null){ + // not sure why this happens but we got an exception where add view was called with + // a layout that was already added... + if(layoutWrapper.getParent() != null) { + ((ViewGroup)layoutWrapper.getParent()).removeView(layoutWrapper); + } + AndroidImplementation.this.relativeLayout.addView(layoutWrapper); + } + } + } + }); + } + private Image peerImage; + public void paint(final Graphics g) { + if(superPeerMode) { + Object nativeGraphics = com.codename1.ui.Accessor.getNativeGraphics(g); + + Object o = v.getLayoutParams(); + AndroidAsyncView.LayoutParams lp; + if(o instanceof AndroidAsyncView.LayoutParams) { + lp = (AndroidAsyncView.LayoutParams) o; + if (lp == null) { + lp = new AndroidAsyncView.LayoutParams( + getX() + g.getTranslateX(), + getY() + g.getTranslateY(), + getWidth(), + getHeight(), AndroidPeer.this); + final AndroidAsyncView.LayoutParams finalLp = lp; + activity.runOnUiThread(new Runnable() { + @Override + public void run() { + v.setLayoutParams(finalLp); + } + }); + lp.dirty = true; + } else { + int x = getX() + g.getTranslateX(); + int y = getY() + g.getTranslateY(); + int w = getWidth(); + int h = getHeight(); + if (x != lp.x || y != lp.y || w != lp.w || h != lp.h) { + lp.dirty = true; + lp.x = x; + lp.y = y; + lp.w = w; + lp.h = h; + } + } + } else { + final AndroidAsyncView.LayoutParams finalLp = new AndroidAsyncView.LayoutParams( + getX() + g.getTranslateX(), + getY() + g.getTranslateY(), + getWidth(), + getHeight(), AndroidPeer.this); + activity.runOnUiThread(new Runnable() { + @Override + public void run() { + v.setLayoutParams(finalLp); + } + }); + finalLp.dirty = true; + lp = finalLp; + } + + // this is a mutable image or side menu etc. where the peer is drawn on a different form... + // Special case... + if(nativeGraphics.getClass() == AndroidGraphics.class) { + if(peerImage == null) { + peerImage = generatePeerImage(); + } + //systemOut("Drawing native image"); + g.drawImage(peerImage, getX(), getY()); + return; + } + synchronized(activePeers) { + activePeers.put(v, this); + } + ((AndroidGraphics) nativeGraphics).drawView(v, lp); + if (lightweightMode && peerImage != null) { + g.drawImage(peerImage, getX(), getY(), getWidth(), getHeight()); + } + } else { + super.paint(g); + } + } + + boolean _initialized() { + return isInitialized(); + } + + @Override + protected void onPositionSizeChange() { + if(!superPeerMode) { + Form f = getComponentForm(); + if (v.getVisibility() == View.INVISIBLE + && f != null + && Display.getInstance().getCurrent() == f) { + doSetVisibilityInternal(true); + return; + } + layoutPeer(); + } + } + + protected void layoutPeer(){ + if (getActivity() == null) { + return; + } + if(!superPeerMode) { + // called by Codename One EDT to position the native component. + activity.runOnUiThread(new Runnable() { + public void run() { + if (layoutWrapper != null) { + if (v.getVisibility() == View.VISIBLE) { + + RelativeLayout.LayoutParams layoutParams = layoutWrapper.createMyLayoutParams( + AndroidImplementation.AndroidPeer.this.getAbsoluteX(), + AndroidImplementation.AndroidPeer.this.getAbsoluteY(), + AndroidImplementation.AndroidPeer.this.getWidth(), + AndroidImplementation.AndroidPeer.this.getHeight()); + layoutWrapper.setLayoutParams(layoutParams); + if (AndroidImplementation.this.relativeLayout != null) { + AndroidImplementation.this.relativeLayout.requestLayout(); + } + + } + } + } + }); + } + } + + void blockNativeFocus(boolean block) { + if (layoutWrapper != null) { + layoutWrapper.setDescendantFocusability(block + ? ViewGroup.FOCUS_BLOCK_DESCENDANTS : ViewGroup.FOCUS_AFTER_DESCENDANTS); + } + } + + @Override + public boolean isFocusable() { + // EDT + if (v != null) { + return v.isFocusableInTouchMode() || v.isFocusable(); + } else { + return super.isFocusable(); + } + } + + @Override + public void onSetFocusable(final boolean focusable) { + // EDT + if (getActivity() == null) { + return; + } + getActivity().runOnUiThread(new Runnable() { + public void run() { + v.setFocusable(focusable); + } + }); + } + + @Override + protected void focusGained() { + Log.d("Codename One", "native focus gain"); + // EDT + super.focusGained(); + if (getActivity() == null) { + return; + } + getActivity().runOnUiThread(new Runnable() { + public void run() { + // allow this one to gain focus + blockNativeFocus(false); + if (!v.hasFocus()) { + if (v.isInTouchMode()) { + v.requestFocusFromTouch(); + } else { + v.requestFocus(); + } + } + } + }); + } + + @Override + protected void focusLost() { + Log.d("Codename One", "native focus loss"); + // EDT + super.focusLost(); + if (layoutWrapper != null && getActivity() != null) { + getActivity().runOnUiThread(new Runnable() { + public void run() { + if(isInitialized()) { + // request focus of the wrapper. that will trigger the + // android focus listener and move focus back to the + // base view. + layoutWrapper.requestFocus(); + } + } + }); + } + } + + public void release() { + deinitialize(); + } + + @Override + protected Dimension calcPreferredSize() { + int w = 1; + int h = 1; + Drawable d = v.getBackground(); + if (d != null) { + w = d.getMinimumWidth(); + h = d.getMinimumHeight(); + } + w = Math.max(v.getMeasuredWidth(), w); + h = Math.max(v.getMeasuredHeight(), h); + if (v instanceof TextView) { + TextView tv = (TextView)v; + w = (int) android.text.Layout.getDesiredWidth(((TextView) v).getText(), ((TextView) v).getPaint()); + int heightMeasureSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED); + tv.measure(w, heightMeasureSpec); + h = (int)Math.max(h, tv.getMeasuredHeight()); + + + } + return new Dimension(w, h); + } + } + + /** + * inner class that wraps the native components. this is a useful thingy to + * handle focus stuff and buffering. + */ + class AndroidRelativeLayout extends RelativeLayout { + + private AndroidImplementation.AndroidPeer peer; + + public AndroidRelativeLayout(Context activity, AndroidImplementation.AndroidPeer peer, View v) { + super(activity); + + this.peer = peer; + this.setLayoutParams(createMyLayoutParams(peer.getAbsoluteX(), peer.getAbsoluteY(), + peer.getWidth(), peer.getHeight())); + if (v.getParent() != null) { + ((ViewGroup)v.getParent()).removeView(v); + } + this.addView(v, new RelativeLayout.LayoutParams( + RelativeLayout.LayoutParams.FILL_PARENT, + RelativeLayout.LayoutParams.FILL_PARENT)); + this.setDrawingCacheEnabled(false); + this.setAlwaysDrawnWithCacheEnabled(false); + this.setFocusable(true); + this.setFocusableInTouchMode(false); + this.setDescendantFocusability(ViewGroup.FOCUS_AFTER_DESCENDANTS); + + } + + /** + * create a layout parameter object that holds the native component's + * position. + * + * @return + */ + private RelativeLayout.LayoutParams createMyLayoutParams(int x, int y, int width, int height) { + RelativeLayout.LayoutParams layoutParams = new RelativeLayout.LayoutParams( + RelativeLayout.LayoutParams.WRAP_CONTENT, + RelativeLayout.LayoutParams.WRAP_CONTENT); + layoutParams.addRule(RelativeLayout.ALIGN_PARENT_LEFT); + layoutParams.addRule(RelativeLayout.ALIGN_PARENT_TOP); + layoutParams.width = width; + layoutParams.height = height; + layoutParams.leftMargin = x; + layoutParams.topMargin = y; + return layoutParams; + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + + int keycode = event.getKeyCode(); + keycode = CodenameOneView.internalKeyCodeTranslate(keycode); + if (keycode == AndroidImplementation.DROID_IMPL_KEY_BACK) { + switch (event.getAction()) { + case KeyEvent.ACTION_DOWN: + // Claim the gesture so the activity's + // OnBackInvokedCallback stands down; on Android 16 the + // platform can deliver both for one press. See + // PredictiveBackBridge. + PredictiveBackBridge.keyEventBackStarted(); + Display.getInstance().keyPressed(keycode); + break; + case KeyEvent.ACTION_UP: + PredictiveBackBridge.keyEventBackFinished(); + Display.getInstance().keyReleased(keycode); + break; + } + return true; + } else { + return super.dispatchKeyEvent(event); + } + } + + + } + + private boolean testedNativeTheme; + private boolean nativeThemeAvailable; + + public boolean hasNativeTheme() { + if (!testedNativeTheme) { + testedNativeTheme = true; + try { + InputStream is; + if (android.os.Build.VERSION.SDK_INT < 14 && !isTablet()) { + is = getResourceAsStream(getClass(), "/androidTheme.res"); + } else { + is = getResourceAsStream(getClass(), "/android_holo_light.res"); + } + nativeThemeAvailable = is != null; + if (is != null) { + is.close(); + } + } catch (IOException ex) { + ex.printStackTrace(); + } + } + return nativeThemeAvailable; + } + + /** + * Installs the native theme, this is only applicable if hasNativeTheme() + * returned true. Notice that this method might replace the + * DefaultLookAndFeel instance and the default transitions. + */ + public void installNativeTheme() { + hasNativeTheme(); + if (!nativeThemeAvailable) { + return; + } + try { + // Resolve desired theme flavor. and.themeMode is the per-platform + // hint (auto | modern | material | hololight | legacy); the legacy + // name cn1.androidTheme is still honored for back-compat. The + // cross-platform shortcut nativeTheme=modern/legacy (deprecated + // alias: cn1.nativeTheme) feeds in when no platform-specific hint + // is set. Default stays on android_holo_light - what master + // shipped and what existing screenshot goldens are anchored + // against. The ancient pre-Holo androidTheme.res is only reached + // via explicit and.hololight=true (historical back-compat) or + // and.themeMode=legacy. + Display d = Display.getInstance(); + String mode = d.getProperty("and.themeMode", + d.getProperty("cn1.androidTheme", null)); + if (mode == null) { + String shared = d.getProperty("nativeTheme", + d.getProperty("cn1.nativeTheme", null)); + if ("modern".equalsIgnoreCase(shared)) { + mode = "material"; + } else if ("legacy".equalsIgnoreCase(shared)) { + mode = "hololight"; + } else if ("true".equalsIgnoreCase(d.getProperty("and.hololight", "false"))) { + mode = "legacy"; + } else { + mode = "hololight"; + } + } else { + mode = mode.toLowerCase(); + } + + String resPath; + if ("material".equals(mode) || "modern".equals(mode) || "auto".equals(mode)) { + resPath = "/AndroidMaterialTheme.res"; + } else if ("hololight".equals(mode) || "holo".equals(mode)) { + resPath = "/android_holo_light.res"; + } else { + resPath = "/androidTheme.res"; + } + + InputStream is = getResourceAsStream(getClass(), resPath); + if (is == null) { + // Modern theme may not be in the apk if the framework build + // skipped native-themes generation. Fall back to Holo Light + // (master's default) so the app still boots with a known look. + is = getResourceAsStream(getClass(), "/android_holo_light.res"); + } + Resources r = Resources.open(is); + Hashtable h = r.getTheme(r.getThemeResourceNames()[0]); + h.put("@commandBehavior", "Native"); + UIManager.getInstance().setThemeProps(h); + is.close(); + Display.getInstance().setCommandBehavior(Display.COMMAND_BEHAVIOR_NATIVE); + } catch (IOException ex) { + ex.printStackTrace(); + } + } + + public boolean isNativeBrowserComponentSupported() { + return true; + } + + @Override + public void setNativeBrowserScrollingEnabled(final PeerComponent browserPeer, final boolean e) { + super.setNativeBrowserScrollingEnabled(browserPeer, e); + if (getActivity() == null) { + return; + } + getActivity().runOnUiThread(new Runnable() { + public void run() { + AndroidBrowserComponent bc = (AndroidBrowserComponent)browserPeer; + bc.setScrollingEnabled(e); + } + }); + } + + @Override + public void setPinchToZoomEnabled(final PeerComponent browserPeer, final boolean e) { + super.setPinchToZoomEnabled(browserPeer, e); + if (getActivity() == null) { + return; + } + getActivity().runOnUiThread(new Runnable() { + public void run() { + AndroidBrowserComponent bc = (AndroidBrowserComponent)browserPeer; + bc.setPinchZoomEnabled(e); + } + }); + } + + public PeerComponent createBrowserComponent(final Object parent) { + if (getActivity() == null) { + return null; + } + final AndroidImplementation.AndroidBrowserComponent[] bc = new AndroidImplementation.AndroidBrowserComponent[1]; + final Throwable[] error = new Throwable[1]; + final Object lock = new Object(); + + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + + synchronized (lock) { + try { + WebView wv = new WebView(getActivity()) { + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + + int keycode = event.getKeyCode(); + keycode = CodenameOneView.internalKeyCodeTranslate(keycode); + if (keycode == AndroidImplementation.DROID_IMPL_KEY_BACK || + (keycode == KeyEvent.KEYCODE_MENU && + Display.getInstance().getCommandBehavior() != Display.COMMAND_BEHAVIOR_NATIVE)) { + boolean backKey = + keycode == AndroidImplementation.DROID_IMPL_KEY_BACK; + switch (event.getAction()) { + case KeyEvent.ACTION_DOWN: + // Claim the gesture so the + // activity's OnBackInvokedCallback + // stands down; on Android 16 the + // platform can deliver both for one + // press. See PredictiveBackBridge. + if (backKey) { + PredictiveBackBridge.keyEventBackStarted(); + } + Display.getInstance().keyPressed(keycode); + break; + case KeyEvent.ACTION_UP: + if (backKey) { + PredictiveBackBridge.keyEventBackFinished(); + } + Display.getInstance().keyReleased(keycode); + break; + } + return true; + } else { + if(Display.getInstance().getProperty( + "android.propogateKeyEvents", "false"). + equalsIgnoreCase("true") && + myView instanceof AndroidAsyncView) { + switch (event.getAction()) { + case KeyEvent.ACTION_DOWN: + Display.getInstance().keyPressed(keycode); + break; + case KeyEvent.ACTION_UP: + Display.getInstance().keyReleased(keycode); + break; + } + return true; + } + + return super.dispatchKeyEvent(event); + } + } + }; + wv.setOnTouchListener(new View.OnTouchListener() { + + @Override + public boolean onTouch(View v, MotionEvent event) { + switch (event.getAction()) { + case MotionEvent.ACTION_DOWN: + case MotionEvent.ACTION_UP: + if (!v.hasFocus()) { + v.requestFocus(); + } + break; + } + return false; + } + }); + + if (android.os.Build.VERSION.SDK_INT >= 19) { + if ("true".equals(Display.getInstance().getProperty("android.webContentsDebuggingEnabled", "false"))) { + wv.setWebContentsDebuggingEnabled(true); + } + } + wv.getSettings().setDomStorageEnabled(true); + wv.getSettings().setAllowFileAccess(true); + wv.getSettings().setAllowContentAccess(true); + wv.requestFocus(View.FOCUS_DOWN); + wv.setFocusableInTouchMode(true); + if (android.os.Build.VERSION.SDK_INT >= 17) { + wv.getSettings().setMediaPlaybackRequiresUserGesture(false); + } + bc[0] = new AndroidImplementation.AndroidBrowserComponent(wv, getActivity(), parent); + lock.notify(); + } catch (Throwable t) { + error[0] = t; + lock.notify(); + } + } + } + }); + while (bc[0] == null && error[0] == null) { + Display.getInstance().invokeAndBlock(new Runnable() { + public void run() { + synchronized (lock) { + if (bc[0] == null && error[0] == null) { + try { + lock.wait(20); + } catch (InterruptedException ex) { + ex.printStackTrace(); + } + } + } + } + + }); + } + if (error[0] != null) { + throw new RuntimeException(error[0]); + } + return bc[0]; + } + + public void setBrowserProperty(PeerComponent browserPeer, String key, Object value) { + ((AndroidImplementation.AndroidBrowserComponent) browserPeer).setProperty(key, value); + } + + public String getBrowserTitle(PeerComponent browserPeer) { + return ((AndroidImplementation.AndroidBrowserComponent) browserPeer).getTitle(); + } + + public String getBrowserURL(PeerComponent browserPeer) { + return ((AndroidImplementation.AndroidBrowserComponent) browserPeer).getURL(); + } + + @Override + public void setBrowserURL(PeerComponent browserPeer, String url, Map headers) { + if (url.startsWith("jar:")) { + url = url.substring(6); + if(url.indexOf("/") != 0) { + url = "/"+url; + } + + url = "file:///android_asset"+url; + } + AndroidImplementation.AndroidBrowserComponent bc = (AndroidImplementation.AndroidBrowserComponent) browserPeer; + if(bc.parent.fireBrowserNavigationCallbacks(url)) { + bc.setURL(url, headers); + } + } + + @Override + public boolean isURLWithCustomHeadersSupported() { + return true; + } + + @Override + public void setBrowserURL(PeerComponent browserPeer, String url) { + setBrowserURL(browserPeer, url, null); + } + + public void browserStop(PeerComponent browserPeer) { + ((AndroidImplementation.AndroidBrowserComponent) browserPeer).stop(); + } + + public void browserDestroy(PeerComponent browserPeer) { + ((AndroidImplementation.AndroidBrowserComponent) browserPeer).destroy(); + } + + /** + * Reload the current page + * + * @param browserPeer browser instance + */ + public void browserReload(PeerComponent browserPeer) { + ((AndroidImplementation.AndroidBrowserComponent) browserPeer).reload(); + } + + /** + * Indicates whether back is currently available + * + * @param browserPeer browser instance + * @return true if back should work + */ + public boolean browserHasBack(PeerComponent browserPeer) { + return ((AndroidImplementation.AndroidBrowserComponent) browserPeer).hasBack(); + } + + public boolean browserHasForward(PeerComponent browserPeer) { + return ((AndroidImplementation.AndroidBrowserComponent) browserPeer).hasForward(); + } + + public void browserBack(PeerComponent browserPeer) { + ((AndroidImplementation.AndroidBrowserComponent) browserPeer).back(); + } + + public void browserForward(PeerComponent browserPeer) { + ((AndroidImplementation.AndroidBrowserComponent) browserPeer).forward(); + } + + public void browserClearHistory(PeerComponent browserPeer) { + ((AndroidImplementation.AndroidBrowserComponent) browserPeer).clearHistory(); + } + + public void setBrowserPage(PeerComponent browserPeer, String html, String baseUrl) { + ((AndroidImplementation.AndroidBrowserComponent) browserPeer).setPage(html, baseUrl); + } + + public void browserExposeInJavaScript(PeerComponent browserPeer, Object o, String name) { + ((AndroidImplementation.AndroidBrowserComponent) browserPeer).exposeInJavaScript(o, name); + } + + private boolean useEvaluateJavascript() { + return android.os.Build.VERSION.SDK_INT >= 19; + } + + + private int jsCallbackIndex=0; + + private void execJSUnsafe(WebView web, String js) { + if (useEvaluateJavascript()) { + web.evaluateJavascript(js, null); + } else { + web.loadUrl("javascript:(function(){"+js+"})()"); + } + } + + private void execJSSafe(final WebView web, final String js) { + if (useJSDispatchThread()) { + runOnJSDispatchThread(new Runnable() { + public void run() { + getActivity().runOnUiThread(new Runnable() { + public void run() { + execJSUnsafe(web, js); + } + }); + } + }); + } else { + getActivity().runOnUiThread(new Runnable() { + public void run() { + execJSUnsafe(web, js); + } + }); + } + } + + private void execJSUnsafe(final AndroidBrowserComponent bc, final String javaScript, final ValueCallback resultCallback) { + if (useEvaluateJavascript()) { + try { + bc.web.evaluateJavascript(javaScript, resultCallback); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + resultCallback.onReceiveValue(null); + } + } else { + jsCallbackIndex = (++jsCallbackIndex) % 1024; + int index = jsCallbackIndex; + + // The jsCallback is a special java object exposed to javascript that we use + // to return values from javascript to java. + synchronized (bc.jsCallback){ + // Initialize the return value to null + while (!bc.jsCallback.isIndexAvailable(index)) { + index++; + } + jsCallbackIndex = index+1; + } + final int fIndex = index; + // We are placing the javascript inside eval() so we need to escape + // the input. + String escaped = StringUtil.replaceAll(javaScript, "\\", "\\\\"); + escaped = StringUtil.replaceAll(escaped, "'", "\\'"); + + final String js = "javascript:(function(){" + + + "try{" + +bc.jsCallback.jsInit() + +bc.jsCallback.jsCleanup() + + AndroidBrowserComponentCallback.JS_RETURNVAL_VARNAME+"["+index+"]" + + "=eval('"+escaped +"');} catch (e){console.log(e)};" + + AndroidBrowserComponentCallback.JS_VAR_NAME+".addReturnValue(" + index+", ''+" + + + AndroidBrowserComponentCallback.JS_RETURNVAL_VARNAME+"["+index+"]" + + ");})()"; + + // Send the Javascript string via SetURL. + // NOTE!! This is sent asynchronously so we will need to wait for + // the result to come in. + bc.setURL(js, null); + if (resultCallback == null) { + return; + } + Thread t = new Thread(new Runnable() { + public void run() { + int maxTries = 500; + int tryCounter = 0; + + // If we are not on the EDT, then it is safe to just loop and wait. + while (!bc.jsCallback.isValueSet(fIndex) && tryCounter++ < maxTries) { + synchronized(bc.jsCallback){ + Util.wait(bc.jsCallback, 20); + } + } + + if (bc.jsCallback.isValueSet(fIndex)) { + String retval = bc.jsCallback.getReturnValue(fIndex); + bc.jsCallback.remove(fIndex); + resultCallback.onReceiveValue(retval != null ? JSONObject.quote(retval) : null); + + } else { + com.codename1.io.Log.e(new RuntimeException("Failed to execute javascript "+js+" after maximum wait time.")); + resultCallback.onReceiveValue(null); + } + } + }); + t.start(); + + } + } + + private void execJSSafe(final AndroidBrowserComponent bc, final String javaScript, final ValueCallback resultCallback) { + if (useJSDispatchThread()) { + runOnJSDispatchThread(new Runnable() { + public void run() { + getActivity().runOnUiThread(new Runnable() { + public void run() { + execJSUnsafe(bc, javaScript, resultCallback); + } + }); + } + }); + } else { + getActivity().runOnUiThread(new Runnable() { + public void run() { + execJSUnsafe(bc, javaScript, resultCallback); + } + }); + } + } + + + + @Override + public void browserExecute(final PeerComponent browserPeer, final String javaScript) { + final AndroidImplementation.AndroidBrowserComponent bc = (AndroidImplementation.AndroidBrowserComponent) browserPeer; + execJSSafe(bc.web, javaScript); + } + + private com.codename1.util.EasyThread jsDispatchThread; + private com.codename1.util.EasyThread jsDispatchThread() { + if (jsDispatchThread == null) { + jsDispatchThread = com.codename1.util.EasyThread.start("JS Dispatch Thread"); + } + return jsDispatchThread; + } + + private boolean useJSDispatchThread() { + + // Before version 24, we need a separate JS dispatch thread to prevent deadlocks + return true;//Build.VERSION.SDK_INT < 24; + } + + public boolean isJSDispatchThread() { + if (useJSDispatchThread()) { + return jsDispatchThread().isThisIt(); + } else { + return (Looper.getMainLooper().getThread() == Thread.currentThread()); + } + } + + public boolean runOnJSDispatchThread(Runnable r) { + if (isJSDispatchThread()) { + r.run(); + return true; + } + if (useJSDispatchThread()) { + jsDispatchThread().run(r); + } else { + getActivity().runOnUiThread(r); + } + return false; + } + + /** + * Executes javascript and returns a string result where appropriate. + * @param browserPeer + * @param javaScript + * @return + */ + @Override + public String browserExecuteAndReturnString(final PeerComponent browserPeer, final String javaScript) { + final AndroidImplementation.AndroidBrowserComponent bc = (AndroidImplementation.AndroidBrowserComponent) browserPeer; + final String[] result = new String[1]; + final boolean[] complete = new boolean[1]; + + execJSSafe(bc, javaScript, new ValueCallback() { + @Override + public void onReceiveValue(String value) { + synchronized(result) { + complete[0] = true; + result[0] = value; + result.notify(); + } + } + }); + synchronized(result) { + if (!complete[0]) { + Util.wait(result, 10000); + } + } + if (result[0] == null) { + return null; + } else { + org.json.JSONTokener tok = new org.json.JSONTokener("{\"result\":"+result[0]+"}"); + try { + JSONObject jso = new JSONObject(tok); + return jso.getString("result"); + } catch (Throwable ex) { + com.codename1.io.Log.e(ex); + return null; + } + + } + + + } + + public boolean supportsBrowserExecuteAndReturnString(PeerComponent browserPeer) { + return true; + } + + public boolean canForceOrientation() { + return true; + } + + public void lockOrientation(boolean portrait) { + if (getActivity() == null) { + return; + } + if(portrait){ + getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); + }else{ + getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); + } + } + + public void unlockOrientation() { + if (getActivity() == null) { + return; + } + getActivity().setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR); + } + + + + public boolean isAffineSupported() { + return true; + } + + public void resetAffine(Object nativeGraphics) { + ((AndroidGraphics) nativeGraphics).resetAffine(); + } + + public void scale(Object nativeGraphics, float x, float y) { + ((AndroidGraphics) nativeGraphics).scale(x, y); + } + + public void rotate(Object nativeGraphics, float angle) { + ((AndroidGraphics) nativeGraphics).rotate(angle); + } + + public void rotate(Object nativeGraphics, float angle, int x, int y) { + ((AndroidGraphics) nativeGraphics).rotate(angle, x, y); + } + + @Override + public void pushClip(Object graphics) { + ((AndroidGraphics) graphics).pushClip(); + } + + @Override + public void popClip(Object graphics) { + ((AndroidGraphics) graphics).popClip(); + } + + @Override + public boolean isTranslateMatrixSupported() { + return true; + } + + @Override + public void translateMatrix(Object nativeGraphics, float x, float y) { + ((AndroidGraphics) nativeGraphics).translateMatrix(x, y); + } + + public void shear(Object nativeGraphics, float x, float y) { + } + + public boolean isTablet() { + return (getContext().getResources().getConfiguration().screenLayout + & Configuration.SCREENLAYOUT_SIZE_MASK) + >= Configuration.SCREENLAYOUT_SIZE_LARGE; + } + + // Foldable / device posture, backed by androidx.window via reflection. The androidx.window + // dependency is only present when the app opts in with the android.foldableSupport build hint; + // when absent these all degrade safely to "not foldable". The tracker is started lazily so it + // only spins up for apps that query the posture APIs. + @Override + public boolean isFoldable() { + AndroidFoldablePosture.start(getActivity()); + return AndroidFoldablePosture.isFoldable(); + } + + @Override + public int getDevicePosture() { + AndroidFoldablePosture.start(getActivity()); + return AndroidFoldablePosture.getPosture(); + } + + @Override + public int getFoldOrientation() { + AndroidFoldablePosture.start(getActivity()); + return AndroidFoldablePosture.getFoldOrientation(); + } + + @Override + public boolean isPostureSeparating() { + AndroidFoldablePosture.start(getActivity()); + return AndroidFoldablePosture.isSeparating(); + } + + @Override + public com.codename1.ui.geom.Rectangle getFoldBounds(com.codename1.ui.geom.Rectangle rect) { + AndroidFoldablePosture.start(getActivity()); + return AndroidFoldablePosture.getFoldBounds(rect); + } + + private Boolean watchCache; + + @Override + public boolean isWatch() { + if(watchCache == null) { + // PackageManager.FEATURE_WATCH ("android.hardware.type.watch") is + // the canonical Wear OS marker; use the string literal so this + // compiles regardless of the configured minimum SDK level. + watchCache = getContext().getPackageManager() + .hasSystemFeature("android.hardware.type.watch"); + } + return watchCache; + } + + private Boolean tvCache; + + @Override + public boolean isTV() { + if(tvCache == null) { + // PackageManager.FEATURE_TELEVISION ("android.hardware.type.television") + // and FEATURE_LEANBACK ("android.software.leanback") are the canonical + // Android TV / Google TV markers; use the string literals so this + // compiles regardless of the configured minimum SDK level. + android.content.pm.PackageManager pm = getContext().getPackageManager(); + boolean tv = pm.hasSystemFeature("android.hardware.type.television") + || pm.hasSystemFeature("android.software.leanback"); + if(!tv) { + // Fall back to the runtime UI mode (covers emulators/devices that + // expose the TV ui-mode without declaring the hardware feature). + android.app.UiModeManager um = (android.app.UiModeManager) + getContext().getSystemService(Context.UI_MODE_SERVICE); + tv = um != null && um.getCurrentModeType() + == Configuration.UI_MODE_TYPE_TELEVISION; + } + tvCache = tv; + } + return tvCache; + } + + @Override + public com.codename1.car.spi.CarBridge getCarBridge() { + // The Android Auto glue (injected by the builder only when the app references + // com.codename1.car) registers its bridge here; null otherwise so the API no-ops. + return AndroidCarSupport.getBridge(); + } + + @Override + public boolean isCarConnected() { + com.codename1.car.spi.CarBridge b = AndroidCarSupport.getBridge(); + return b != null && b.isConnected(); + } + + @Override + public com.codename1.wearable.spi.WearableBridge getWearableBridge() { + // The Wearable Data Layer glue is injected by the builder only when the app references + // com.codename1.wearable; without it this is null and the API no-ops. + Context ctx = getContext(); + return ctx == null ? null : AndroidWearableSupport.getBridge(ctx); + } + + private com.codename1.surfaces.spi.SurfaceBridge surfaceBridge; + + @Override + public com.codename1.surfaces.spi.SurfaceBridge getSurfaceBridge() { + if (surfaceBridge == null) { + surfaceBridge = new com.codename1.impl.android.surfaces.AndroidSurfaceBridge(); + } + return surfaceBridge; + } + + private com.codename1.documents.spi.DocumentProviderBridge documentProviderBridge; + + @Override + public com.codename1.documents.spi.DocumentProviderBridge getDocumentProviderBridge() { + if (documentProviderBridge == null) { + documentProviderBridge = + new com.codename1.impl.android.documents.AndroidDocumentProviderBridge(); + } + return documentProviderBridge; + } + + private com.codename1.continuity.spi.ContinuityBridge continuityBridge; + + /// Returns the continuity bridge, which on Android exists for one job: + /// flushing the state checkpoint when the platform says the process may + /// be killed. Neither cross-device capability exists here and both report + /// themselves unsupported. + /// + /// Synchronized for the reason the intent bridge is: two callers arriving + /// together would each construct one, and each construction registers a + /// lifecycle listener -- so the loser's listener would stay registered and + /// the app would checkpoint twice on every save. + @Override + public synchronized com.codename1.continuity.spi.ContinuityBridge getContinuityBridge() { + if (continuityBridge == null) { + continuityBridge = + new com.codename1.impl.android.continuity.AndroidContinuityBridge(); + } + return continuityBridge; + } + + private com.codename1.intents.spi.IntentBridge intentBridge; + + @Override + // Synchronized for the same reason as the JavaSE bridge: two callers arriving together + // each see a null field and each construct one, and whichever loses the assignment keeps + // the donation or the indexed entities that were recorded through it. Nothing throws. + public synchronized com.codename1.intents.spi.IntentBridge getIntentBridge() { + if (intentBridge == null) { + intentBridge = new com.codename1.impl.android.intents.AndroidIntentBridge(); + } + return intentBridge; + } + + private AndroidHomeBridge homeBridge; + + /// Returns the smart-home bridge. Always returned rather than + /// conditionally null: the bridge answers honestly through + /// {@link AndroidSmartHomeSupport}, which is empty unless the builder + /// injected a delegate, so {@code SmartHome} reports NOT_SUPPORTED + /// without this getter needing to know how the app was built. + /// + /// Note that a delegate being present does not mean the graph is + /// readable. The ordinary Android answer is + /// {@code HomeAvailability.COMMISSIONING_ONLY}: Play services can add a + /// Matter accessory with no setup at all, while reading or controlling + /// one needs the Google Home APIs and a Google Cloud project only the + /// app's developer can create. + @Override + public com.codename1.home.spi.HomeBridge getHomeBridge() { + if (homeBridge == null) { + homeBridge = new AndroidHomeBridge(); + } + return homeBridge; + } + + /// Invoked once the app has started (from the generated stub, next to + /// `deliverPendingSharedContent`) to flush surface actions that arrived through the + /// `CN1SurfaceActionActivity` trampoline before the app instance existed. + public static void deliverPendingSurfaceActions() { + com.codename1.impl.android.surfaces.AndroidSurfaceBridge.deliverPendingActions(); + } + + /// Invoked once the app has started (from the generated stub, beside + /// `deliverPendingSurfaceActions`) to run intent requests the trampoline parked rather than + /// dispatched. + /// + /// A non-headless handler is allowed to touch a `Form`, so the launcher tap can only ask for + /// the app to be brought forward; running the handler has to wait until it is. + public static void deliverPendingIntentRequests() { + // Order matters. The generated bootstrap installs the dispatcher before startContext + // has produced a bridge, so publication is deferred -- and until it happens the bridge + // never sees registerIntents, which is what judges a request the trampoline parked at a + // cold start. Draining the foreground queue alone left such a shortcut opening the app + // and running nothing. + com.codename1.intents.Intents.publishPendingDeclarations(); + com.codename1.impl.android.intents.AndroidIntentBridge.deliverPendingForegroundRequests(); + } + + /** + * Executes r on the UI thread and blocks the EDT to completion + * @param r runnable to execute + */ + public static void runOnUiThreadAndBlock(final Runnable r) { + if (getActivity() == null) { + throw new RuntimeException("Cannot run on UI thread because getActivity() is null. This generally means we are running inside a service in the background so UI access is disabled."); + } + + final boolean[] completed = new boolean[1]; + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + try { + r.run(); + } catch(Throwable t) { + com.codename1.io.Log.e(t); + } + synchronized(completed) { + completed[0] = true; + completed.notify(); + } + } + }); + Display.getInstance().invokeAndBlock(new Runnable() { + @Override + public void run() { + synchronized(completed) { + while(!completed[0]) { + try { + completed.wait(); + } catch(InterruptedException err) {} + } + } + } + }); + } + + public static void runOnUiThreadSync(final Runnable r) { + if (getActivity() == null) { + throw new RuntimeException("Cannot run on UI thread because getActivity() is null. This generally means we are running inside a service in the background so UI access is disabled."); + } + + final boolean[] completed = new boolean[1]; + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + try { + r.run(); + } catch(Throwable t) { + com.codename1.io.Log.e(t); + } + synchronized(completed) { + completed[0] = true; + completed.notify(); + } + } + }); + synchronized(completed) { + while(!completed[0]) { + try { + completed.wait(); + } catch(InterruptedException err) {} + } + } + } + + + public int convertToPixels(int dipCount, boolean horizontal) { + DisplayMetrics dm = getContext().getResources().getDisplayMetrics(); + float ppi = dm.density * 160f; + return (int) (((float) dipCount) / 25.4f * ppi); + } + + public boolean isPortrait() { + int orientation = getContext().getResources().getConfiguration().orientation; + if (orientation == Configuration.ORIENTATION_UNDEFINED + || orientation == Configuration.ORIENTATION_SQUARE) { + return super.isPortrait(); + } + return orientation == Configuration.ORIENTATION_PORTRAIT; + } + + /** + * Checks if this platform supports sharing cookies between Native components (e.g. BrowserComponent) + * and ConnectionRequests. Currently only Android and iOS ports support this. + * @return + */ + @Override + public boolean isNativeCookieSharingSupported() { + return true; + } + + @Override + public void clearNativeCookies() { + CookieManager mgr = getCookieManager(); + mgr.removeAllCookie(); + } + private static CookieManager cookieManager; + private static synchronized CookieManager getCookieManager() { + if (android.os.Build.VERSION.SDK_INT > 28) { + return CookieManager.getInstance(); + } + if (cookieManager == null) { + CookieSyncManager.createInstance(getContext()); // Fixes a crash on Android 4.3 + // https://stackoverflow.com/a/20552998/2935174 + cookieManager = CookieManager.getInstance(); + } + return CookieManager.getInstance(); + } + + @Override + public Vector getCookiesForURL(String url) { + if (isUseNativeCookieStore()) { + try { + URI uri = new URI(url); + + + CookieManager mgr = getCookieManager(); + mgr.removeExpiredCookie(); + String domain = uri.getHost(); + String cookieStr = mgr.getCookie(url); + if (cookieStr != null) { + String[] cookies = cookieStr.split(";"); + int len = cookies.length; + Vector out = new Vector(); + for (int i = 0; i < len; i++) { + Cookie c = new Cookie(); + String[] parts = cookies[i].split("="); + c.setName(parts[0].trim()); + if (parts.length > 1) { + c.setValue(parts[1].trim()); + } else { + c.setValue(""); + } + c.setDomain(domain); + out.add(c); + } + return out; + } + } catch (Exception ex) { + com.codename1.io.Log.e(ex); + } + return new Vector(); + } + return super.getCookiesForURL(url); + } + + public class WebAppInterface { + BrowserComponent bc; + /** Instantiate the interface and set the context */ + WebAppInterface(BrowserComponent bc) { + this.bc = bc; + } + + @JavascriptInterface // must be added for API 17 or higher + public boolean shouldNavigate(String url) { + return bc.fireBrowserNavigationCallbacks(url); + } + } + + class AndroidBrowserComponent extends AndroidImplementation.AndroidPeer { + + private Activity act; + private WebView web; + private BrowserComponent parent; + private boolean scrollingEnabled = true; + protected AndroidBrowserComponentCallback jsCallback; + private boolean lightweightMode = false; + private ProgressDialog progressBar; + private boolean hideProgress; + private int layerType; + + + public AndroidBrowserComponent(final WebView web, Activity act, Object p) { + super(web); + if(!superPeerMode) { + doSetVisibility(false); + } + parent = (BrowserComponent) p; + this.web = web; + layerType = web.getLayerType(); + web.getSettings().setJavaScriptEnabled(true); + web.getSettings().setSupportZoom(parent.isPinchToZoomEnabled()); + this.act = act; + jsCallback = new AndroidBrowserComponentCallback(); + hideProgress = Display.getInstance().getProperty("WebLoadingHidden", "false").equals("true"); + + web.addJavascriptInterface(jsCallback, AndroidBrowserComponentCallback.JS_VAR_NAME); + web.addJavascriptInterface(new WebAppInterface(parent), "cn1application"); + if (android.os.Build.VERSION.SDK_INT >= 21) { + CookieManager.getInstance().setAcceptThirdPartyCookies(web, true); + } + + web.setWebViewClient(new WebViewClient() { + + + + public void onLoadResource(WebView view, String url) { + if (Display.getInstance().getProperty("syncNativeCookies", "false").equals("true")) { + try { + URI uri = new URI(url); + CookieManager mgr = getCookieManager(); + mgr.removeExpiredCookie(); + String domain = uri.getHost(); + removeCookiesForDomain(domain); + String cookieStr = mgr.getCookie(url); + if (cookieStr != null) { + String[] cookies = cookieStr.split(";"); + int len = cookies.length; + ArrayList out = new ArrayList(); + for (int i = 0; i < len; i++) { + Cookie c = new Cookie(); + String[] parts = cookies[i].split("="); + c.setName(parts[0].trim()); + if (parts.length > 1) { + c.setValue(parts[1].trim()); + } else { + c.setValue(""); + } + c.setDomain(domain); + out.add(c); + } + Cookie[] cookiesArr = new Cookie[out.size()]; + out.toArray(cookiesArr); + AndroidImplementation.this.addCookie(cookiesArr, false); + } + + } catch (URISyntaxException ex) { + + } + } + parent.fireWebEvent("onLoadResource", new ActionEvent(url)); + super.onLoadResource(view, url); + setShouldCalcPreferredSize(true); + } + + @Override + public void onPageStarted(WebView view, String url, Bitmap favicon) { + if (getActivity() == null) { + return; + } + + parent.fireWebEvent("onStart", new ActionEvent(url)); + super.onPageStarted(view, url, favicon); + dismissProgress(); + //show the progress only if there is no ActionBar + if(!hideProgress && !isNativeTitle()){ + progressBar = ProgressDialog.show(getActivity(), null, "Loading..."); + //if the page hasn't finished for more the 10 sec, dismiss + //the dialog + Timer t= new Timer(); + t.schedule(new TimerTask() { + @Override + public void run() { + dismissProgress(); + } + }, 10000); + } + } + + public void onPageFinished(WebView view, String url) { + parent.fireWebEvent("onLoad", new ActionEvent(url)); + super.onPageFinished(view, url); + setShouldCalcPreferredSize(true); + dismissProgress(); + } + + private void dismissProgress() { + if (progressBar != null && progressBar.isShowing()) { + progressBar.dismiss(); + Display.getInstance().callSerially(new Runnable() { + + public void run() { + setVisible(true); + repaint(); + } + }); + } + } + + public void onReceivedError(WebView view, int errorCode, String description, String failingUrl) { + parent.fireWebEvent("onError", new ActionEvent(description, errorCode)); + super.onReceivedError(view, errorCode, description, failingUrl); + super.shouldOverrideKeyEvent(view, null); + dismissProgress(); + } + + public boolean shouldOverrideKeyEvent(WebView view, KeyEvent event) { + int keyCode = event.getKeyCode(); + if (keyCode == KeyEvent.KEYCODE_BACK || keyCode == KeyEvent.KEYCODE_MENU) { + return true; + } + + return super.shouldOverrideKeyEvent(view, event); + } + + public boolean shouldOverrideUrlLoading(WebView view, String url) { + if (url.startsWith("jar:")) { + setURL(url, null); + return true; + } + + // this will fail if dial permission isn't declared + if(url.startsWith("tel:")) { + if(parent.fireBrowserNavigationCallbacks(url)) { + try { + Intent dialer = new Intent(android.content.Intent.ACTION_DIAL, Uri.parse(url)); + getContext().startActivity(dialer); + } catch(Throwable t) {} + } + return true; + } + // this will fail if dial permission isn't declared + if(url.startsWith("mailto:")) { + if(parent.fireBrowserNavigationCallbacks(url)) { + try { + Intent emailIntent = new Intent(Intent.ACTION_SENDTO, Uri.parse(url)); + getContext().startActivity(emailIntent); + } catch(Throwable t) {} + } + return true; + } + return !parent.fireBrowserNavigationCallbacks(url); + } + + + }); + + web.setWebChromeClient(new WebChromeClient(){ + // For 3.0+ Devices (Start) + // onActivityResult attached before constructor + protected void openFileChooser(ValueCallback uploadMsg, String acceptType) + { + mUploadMessage = uploadMsg; + Intent i = new Intent(Intent.ACTION_GET_CONTENT); + i.addCategory(Intent.CATEGORY_OPENABLE); + i.setType(acceptType); + AndroidNativeUtil.getActivity().startActivityForResult(Intent.createChooser(i, "File Browser"), FILECHOOSER_RESULTCODE); + } + + + // For Lollipop 5.0+ Devices + public boolean onShowFileChooser(WebView mWebView, ValueCallback filePathCallback, WebChromeClient.FileChooserParams fileChooserParams) + { + if (uploadMessage != null) { + uploadMessage.onReceiveValue(null); + uploadMessage = null; + } + + uploadMessage = filePathCallback; + + Intent intent = fileChooserParams.createIntent(); + try + { + AndroidNativeUtil.getActivity().startActivityForResult(intent, REQUEST_SELECT_FILE); + } catch (ActivityNotFoundException e) + { + uploadMessage = null; + Toast.makeText(getActivity().getApplicationContext(), "Cannot Open File Chooser", Toast.LENGTH_LONG).show(); + return false; + } + return true; + } + + //For Android 4.1 only + protected void openFileChooser(ValueCallback uploadMsg, String acceptType, String capture) + { + mUploadMessage = uploadMsg; + Intent intent = new Intent(Intent.ACTION_GET_CONTENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType(acceptType); + + AndroidNativeUtil.getActivity().startActivityForResult(Intent.createChooser(intent, "File Browser"), FILECHOOSER_RESULTCODE); + } + + protected void openFileChooser(ValueCallback uploadMsg) + { + mUploadMessage = uploadMsg; + Intent i = new Intent(Intent.ACTION_GET_CONTENT); + i.addCategory(Intent.CATEGORY_OPENABLE); + i.setType("image/*"); + AndroidNativeUtil.getActivity().startActivityForResult(Intent.createChooser(i, "File Chooser"), FILECHOOSER_RESULTCODE); + } + + + @Override + public boolean onConsoleMessage(ConsoleMessage consoleMessage) { + com.codename1.io.Log.p("["+consoleMessage.messageLevel()+"] "+consoleMessage.message()+" On line "+consoleMessage.lineNumber()+" of "+consoleMessage.sourceId()); + return true; + } + + @Override + public void onProgressChanged(WebView view, int newProgress) { + parent.fireWebEvent("Progress", new ActionEvent(parent, ActionEvent.Type.Progress, newProgress)); + if(!hideProgress && isNativeTitle() && getCurrentForm() != null && getCurrentForm().getTitle() != null && getCurrentForm().getTitle().length() > 0 ){ + if(getActivity() != null){ + try{ + getActivity().setProgressBarVisibility(true); + getActivity().setProgress(newProgress * 100); + if(newProgress == 100){ + getActivity().setProgressBarVisibility(false); + } + }catch(Throwable t){ + } + } + } + } + + @Override + public void onGeolocationPermissionsShowPrompt(String origin, + GeolocationPermissions.Callback callback) { + // Always grant permission since the app itself requires location + // permission and the user has therefore already granted it + callback.invoke(origin, true, false); + } + + @Override + public void onPermissionRequest(final PermissionRequest request) { + + Log.d("Codename One", "onPermissionRequest"); + getActivity().runOnUiThread(new Runnable() { + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + @Override + public void run() { + String allowedOrigins = Display.getInstance().getProperty("android.WebView.grantPermissionsFrom", null); + if (allowedOrigins != null) { + String[] origins = Util.split(allowedOrigins, " "); + boolean allowed = false; + for (String origin : origins) { + if (request.getOrigin().toString().equals(origin)) { + allowed = true; + break; + } + } + if (allowed) { + Log.d("Codename One", "Allowing permission for "+Arrays.toString(request.getResources())+" in web view for origin "+request.getOrigin()); + request.grant(request.getResources()); + } else { + Log.d("Codename One", "Denying permission for "+Arrays.toString(request.getResources())+" in web view for origin "+request.getOrigin()); + request.deny(); + } + } + + } + }); + } + }); + } + + @Override + protected void initComponent() { + if(android.os.Build.VERSION.SDK_INT == 21 && web.getLayerType() != layerType){ + act.runOnUiThread(new Runnable() { + @Override + public void run() { + web.setLayerType(layerType, null); //setting layer type to original state + } + }); + } + super.initComponent(); + blockNativeFocus(false); + setPeerImage(null); + } + + + @Override + protected Image generatePeerImage() { + try { + final Bitmap nativeBuffer = Bitmap.createBitmap( + getWidth(), getHeight(), Bitmap.Config.ARGB_8888); + Image image = new AndroidImplementation.NativeImage(nativeBuffer); + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + try { + Canvas canvas = new Canvas(nativeBuffer); + web.draw(canvas); + } catch(Throwable t) { + t.printStackTrace(); + } + } + }); + return image; + } catch(Throwable t) { + t.printStackTrace(); + return Image.createImage(5, 5); + } + } + + protected boolean shouldRenderPeerImage() { + return lightweightMode || !isInitialized(); + } + + protected void setLightweightMode(boolean l) { + doSetVisibility(!l); + if (lightweightMode == l) { + return; + } + lightweightMode = l; + } + + + + public void setScrollingEnabled(final boolean enabled){ + this.scrollingEnabled = enabled; + act.runOnUiThread(new Runnable() { + public void run() { + web.setHorizontalScrollBarEnabled(enabled); + web.setVerticalScrollBarEnabled(enabled); + if ( !enabled ){ + web.setOnTouchListener(new View.OnTouchListener(){ + + @Override + public boolean onTouch(View view, MotionEvent me) { + return (me.getAction() == MotionEvent.ACTION_MOVE); + } + + }); + } else { + web.setOnTouchListener(null); + } + } + }); + + } + + public boolean isScrollingEnabled(){ + return scrollingEnabled; + } + + public void setProperty(final String key, final Object value) { + act.runOnUiThread(new Runnable() { + public void run() { + WebSettings s = web.getSettings(); + if(key.equalsIgnoreCase("useragent")) { + s.setUserAgentString((String)value); + return; + } + try { + s.setMixedContentMode(WebSettings.MIXED_CONTENT_ALWAYS_ALLOW); + } catch(Throwable t) { + // the method isn't available in Android 4.x + } + String methodName = "set" + key; + for (Method m : s.getClass().getMethods()) { + if (m.getName().equalsIgnoreCase(methodName) && m.getParameterTypes().length == 1) { + try { + m.invoke(s, value); + } catch (Exception ex) { + ex.printStackTrace(); + } + return; + } + } + } + }); + } + + public String getTitle() { + final String[] retVal = new String[1]; + final boolean[] complete = new boolean[1]; + act.runOnUiThread(new Runnable() { + public void run() { + try { + + retVal[0] = web.getTitle(); + } finally { + complete[0] = true; + } + } + }); + while (!complete[0]) { + Display.getInstance().invokeAndBlock(new Runnable() { + @Override + public void run() { + if (!complete[0]) { + try { + Thread.sleep(20); + } catch (InterruptedException ex) { + } + } + } + }); + } + return retVal[0]; + } + + public String getURL() { + final String[] retVal = new String[1]; + final boolean[] complete = new boolean[1]; + act.runOnUiThread(new Runnable() { + public void run() { + try { + retVal[0] = web.getUrl(); + } finally { + complete[0] = true; + } + } + }); + while (!complete[0]) { + Display.getInstance().invokeAndBlock(new Runnable() { + @Override + public void run() { + if (!complete[0]) { + try { + Thread.sleep(20); + } catch (InterruptedException ex) { + } + } + } + }); + } + return retVal[0]; + } + + public void setURL(final String url, final Map headers) { + act.runOnUiThread(new Runnable() { + public void run() { + if(headers != null) { + web.loadUrl(url, headers); + } else { + web.loadUrl(url); + } + } + }); + } + + public void reload() { + act.runOnUiThread(new Runnable() { + public void run() { + web.reload(); + } + }); + } + + public boolean hasBack() { + final Boolean [] retVal = new Boolean[1]; + final boolean[] complete = new boolean[1]; + + act.runOnUiThread(new Runnable() { + public void run() { + try { + retVal[0] = web.canGoBack(); + } finally { + complete[0] = true; + } + } + }); + while (!complete[0]) { + Display.getInstance().invokeAndBlock(new Runnable() { + @Override + public void run() { + if (!complete[0]) { + try { + Thread.sleep(20); + } catch (InterruptedException ex) { + } + } + } + }); + } + return retVal[0].booleanValue(); + } + + public boolean hasForward() { + final Boolean [] retVal = new Boolean[1]; + final boolean[] complete = new boolean[1]; + + act.runOnUiThread(new Runnable() { + public void run() { + try { + retVal[0] = web.canGoForward(); + } finally { + complete[0] = true; + } + } + }); + + while (!complete[0]) { + Display.getInstance().invokeAndBlock(new Runnable() { + @Override + public void run() { + if (!complete[0]) { + try { + Thread.sleep(20); + } catch (InterruptedException ex) { + } + } + } + }); + } + return retVal[0].booleanValue(); + } + + public void back() { + act.runOnUiThread(new Runnable() { + public void run() { + web.goBack(); + } + }); + } + + public void forward() { + act.runOnUiThread(new Runnable() { + public void run() { + web.goForward(); + } + }); + } + + public void clearHistory() { + act.runOnUiThread(new Runnable() { + public void run() { + web.clearHistory(); + } + }); + } + + public void stop() { + act.runOnUiThread(new Runnable() { + public void run() { + web.stopLoading(); + } + }); + } + + public void destroy() { + act.runOnUiThread(new Runnable() { + public void run() { + web.destroy(); + } + }); + } + + public void setPage(final String html, final String baseUrl) { + act.runOnUiThread(new Runnable() { + public void run() { + web.loadDataWithBaseURL(baseUrl, html, "text/html", "UTF-8", null); + } + }); + } + + public void exposeInJavaScript(final Object o, final String name) { + act.runOnUiThread(new Runnable() { + public void run() { + web.addJavascriptInterface(o, name); + } + }); + } + + public void setPinchZoomEnabled(final boolean e) { + act.runOnUiThread(new Runnable() { + public void run() { + web.getSettings().setSupportZoom(e); + web.getSettings().setBuiltInZoomControls(e); + } + }); + } + + @Override + protected void deinitialize() { + act.runOnUiThread(new Runnable() { + @Override + public void run() { + if(android.os.Build.VERSION.SDK_INT == 21) { // bugfix for Android 5.0.x + web.setLayerType(View.LAYER_TYPE_SOFTWARE, null); //setting layer type to software to prevent the sigseg 11 crash + } + } + }); + super.deinitialize(); + } + } + + + + public Object connect(String url, boolean read, boolean write, int timeout) throws IOException { + URL u = new URL(url); + CookieHandler.setDefault(null); + URLConnection con = u.openConnection(); + if (con instanceof HttpURLConnection) { + HttpURLConnection c = (HttpURLConnection) con; + c.setUseCaches(false); + c.setDefaultUseCaches(false); + c.setInstanceFollowRedirects(false); + if(timeout > -1) { + c.setConnectTimeout(timeout); + } + + if (android.os.Build.VERSION.SDK_INT > 13) { + c.setRequestProperty("Connection", "close"); + } + } + con.setDoInput(read); + con.setDoOutput(write); + return con; + } + + @Override + public void setReadTimeout(Object connection, int readTimeout) { + if (connection instanceof URLConnection) { + ((URLConnection)connection).setReadTimeout(readTimeout); + } + } + + + + @Override + public boolean isReadTimeoutSupported() { + return true; + } + + @Override + public void setInsecure(Object connection, boolean insecure) { + if (insecure) { + if (connection instanceof HttpsURLConnection) { + HttpsURLConnection conn = (HttpsURLConnection)connection; + try { + TrustModifier.relaxHostChecking(conn); + } catch (Exception ex) { + com.codename1.io.Log.e(ex); + } + } + } + } + + + /** + * @inheritDoc + */ + public Object connect(String url, boolean read, boolean write) throws IOException { + return connect(url, read, write, timeout); + } + + + private static final char[] HEX_CHARS = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'}; + + private static String dumpHex(byte[] data) { + final int n = data.length; + final StringBuilder sb = new StringBuilder(n * 3 - 1); + for (int i = 0; i < n; i++) { + if (i > 0) { + sb.append(' '); + } + sb.append(HEX_CHARS[(data[i] >> 4) & 0x0F]); + sb.append(HEX_CHARS[data[i] & 0x0F]); + } + return sb.toString(); + } + + @Override + public String[] getSSLCertificates(Object connection, String url) throws IOException { + if (connection instanceof HttpsURLConnection) { + HttpsURLConnection conn = (HttpsURLConnection)connection; + + try { + conn.connect(); + java.security.cert.Certificate[] certs = conn.getServerCertificates(); + String[] out = new String[certs.length * 2]; + int i=0; + for (java.security.cert.Certificate cert : certs) { + { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update(cert.getEncoded()); + out[i++] = "SHA-256:" + dumpHex(md.digest()); + } + { + MessageDigest md = MessageDigest.getInstance("SHA1"); + md.update(cert.getEncoded()); + out[i++] = "SHA1:" + dumpHex(md.digest()); + } + + } + return out; + } catch (Exception ex) { + ex.printStackTrace(); + } + } + return new String[0]; + + } + + @Override + public boolean canGetSSLCertificates() { + return true; + } + + @Override + public boolean canGetPublicKeyDigests() { + return true; + } + + @Override + public String[] getSSLCertificatesEx(Object connection, String url) throws IOException { + if (connection instanceof HttpsURLConnection) { + HttpsURLConnection conn = (HttpsURLConnection) connection; + try { + conn.connect(); + java.security.cert.Certificate[] certs = conn.getServerCertificates(); + java.util.List out = new java.util.ArrayList(); + for (int i = 0; i < certs.length; i++) { + java.security.cert.Certificate cert = certs[i]; + out.add("CHAIN:" + i); + MessageDigest sha256 = MessageDigest.getInstance("SHA-256"); + sha256.update(cert.getEncoded()); + out.add("SHA-256:" + dumpHex(sha256.digest())); + MessageDigest sha1 = MessageDigest.getInstance("SHA1"); + sha1.update(cert.getEncoded()); + out.add("SHA1:" + dumpHex(sha1.digest())); + // getPublicKey().getEncoded() is already the DER SubjectPublicKeyInfo, + // which is exactly what a public-key pin is computed over. + java.security.PublicKey pk = cert.getPublicKey(); + if (pk != null && pk.getEncoded() != null) { + MessageDigest spki = MessageDigest.getInstance("SHA-256"); + spki.update(pk.getEncoded()); + out.add("SPKI-SHA-256:" + + com.codename1.util.Base64.encodeNoNewline(spki.digest())); + } + } + return out.toArray(new String[out.size()]); + } catch (Exception ex) { + ex.printStackTrace(); + } + } + return new String[0]; + } + + /** + * @inheritDoc + */ + public void setHeader(Object connection, String key, String val) { + ((URLConnection) connection).setRequestProperty(key, val); + } + + @Override + public void setChunkedStreamingMode(Object connection, int bufferLen){ + HttpURLConnection con = ((HttpURLConnection) connection); + con.setChunkedStreamingMode(bufferLen); + } + + + + /** + * @inheritDoc + */ + public OutputStream openOutputStream(Object connection) throws IOException { + if (connection instanceof String) { + String con = (String)connection; + if (con.startsWith("file://")) { + con = con.substring(7); + } + + OutputStream fc = createFileOuputStream((String) con); + BufferedOutputStream o = new BufferedOutputStream(fc, (String) con); + return o; + } + return new BufferedOutputStream(((URLConnection) connection).getOutputStream(), connection.toString()); + } + + /** + * @inheritDoc + */ + public OutputStream openOutputStream(Object connection, int offset) throws IOException { + String con = (String) connection; + con = removeFilePrefix(con); + RandomAccessFile rf = new RandomAccessFile(con, "rw"); + rf.seek(offset); + FileOutputStream fc = new FileOutputStream(rf.getFD()); + BufferedOutputStream o = new BufferedOutputStream(fc, con); + o.setConnection(rf); + return o; + } + + /** + * @inheritDoc + */ + public void cleanup(Object o) { + try { + super.cleanup(o); + if (o != null) { + if (o instanceof RandomAccessFile) { + ((RandomAccessFile) o).close(); + } + } + } catch (Throwable ex) { + ex.printStackTrace(); + } + } + + /** + * @inheritDoc + */ + public InputStream openInputStream(Object connection) throws IOException { + if (connection instanceof String) { + String con = (String) connection; + if (con.startsWith("file://")) { + con = con.substring(7); + } + InputStream fc = createFileInputStream(con); + BufferedInputStream o = new BufferedInputStream(fc, con); + return o; + } + if(connection instanceof HttpURLConnection) { + HttpURLConnection ht = (HttpURLConnection)connection; + if(ht.getResponseCode() < 400) { + return new BufferedInputStream(ht.getInputStream()); + } + return new BufferedInputStream(ht.getErrorStream()); + } else { + return new BufferedInputStream(((URLConnection) connection).getInputStream()); + } + } + + /** + * @inheritDoc + */ + public void setHttpMethod(Object connection, String method) throws IOException { + if(method.equalsIgnoreCase("patch")) { + allowPatch((HttpURLConnection) connection); + } + ((HttpURLConnection) connection).setRequestMethod(method); + } + + // the following block is based on a few suggestions in this stack overflow + // answer https://stackoverflow.com/questions/25163131/httpurlconnection-invalid-http-method-patch + private static boolean enabledPatch; + private static boolean patchFailed; + private static void allowPatch(HttpURLConnection connection) { + if(enabledPatch) { + return; + } + if(patchFailed) { + connection.setRequestProperty("X-HTTP-Method-Override", "PATCH"); + return; + } + try { + Field methodsField = HttpURLConnection.class.getDeclaredField("methods"); + + Field modifiersField = Field.class.getDeclaredField("modifiers"); + modifiersField.setAccessible(true); + modifiersField.setInt(methodsField, methodsField.getModifiers() & ~Modifier.FINAL); + + methodsField.setAccessible(true); + + String[] oldMethods = (String[]) methodsField.get(null); + Set methodsSet = new LinkedHashSet(Arrays.asList(oldMethods)); + methodsSet.addAll(Arrays.asList("PATCH")); + String[] newMethods = methodsSet.toArray(new String[0]); + + methodsField.set(null/*static field*/, newMethods); + enabledPatch = true; + } catch (NoSuchFieldException e) { + patchFailed = true; + connection.setRequestProperty("X-HTTP-Method-Override", "PATCH"); + } catch(IllegalAccessException ee) { + patchFailed = true; + connection.setRequestProperty("X-HTTP-Method-Override", "PATCH"); + } + } + + /** + * @inheritDoc + */ + public void setPostRequest(Object connection, boolean p) { + try { + if (p) { + ((HttpURLConnection) connection).setRequestMethod("POST"); + } else { + ((HttpURLConnection) connection).setRequestMethod("GET"); + } + } catch (IOException err) { + // an exception here doesn't make sense + err.printStackTrace(); + } + } + + /** + * @inheritDoc + */ + public int getResponseCode(Object connection) throws IOException { + // workaround for Android bug discussed here: http://stackoverflow.com/questions/17638398/androids-httpurlconnection-throws-eofexception-on-head-requests + HttpURLConnection con = (HttpURLConnection) connection; + if("head".equalsIgnoreCase(con.getRequestMethod())) { + con.setDoOutput(false); + con.setRequestProperty( "Accept-Encoding", "" ); + } + return ((HttpURLConnection) connection).getResponseCode(); + } + + /** + * @inheritDoc + */ + public String getResponseMessage(Object connection) throws IOException { + return ((HttpURLConnection) connection).getResponseMessage(); + } + + /** + * @inheritDoc + */ + public int getContentLength(Object connection) { + return ((HttpURLConnection) connection).getContentLength(); + } + + /** + * @inheritDoc + */ + public String getHeaderField(String name, Object connection) throws IOException { + return ((HttpURLConnection) connection).getHeaderField(name); + } + + /** + * @inheritDoc + */ + public String[] getHeaderFieldNames(Object connection) throws IOException { + Set s = ((HttpURLConnection) connection).getHeaderFields().keySet(); + String[] resp = new String[s.size()]; + s.toArray(resp); + return resp; + } + + /** + * @inheritDoc + */ + public String[] getHeaderFields(String name, Object connection) throws IOException { + HttpURLConnection c = (HttpURLConnection) connection; + List headers = new ArrayList(); + + // we need to merge headers with differing case since this should be case insensitive + for(String key : c.getHeaderFields().keySet()) { + if(key != null && key.equalsIgnoreCase(name)) { + headers.addAll(c.getHeaderFields().get(key)); + } + } + if (headers.size() > 0) { + List v = new ArrayList(); + v.addAll(headers); + Collections.reverse(v); + String[] s = new String[v.size()]; + v.toArray(s); + return s; + } + // workaround for a bug in some android devices + String f = c.getHeaderField(name); + if(f != null && f.length() > 0) { + return new String[] {f}; + } + return null; + + + + } + + /** + * Directory holding storage writes still in progress. + * + *

A sibling of the files dir rather than something inside it. Every name is a + * legal storage key, so no name reserved inside that namespace can be kept clear + * of the application: a key called after the scratch area would either be + * unstorable or, if it already existed as a file, would stop the directory being + * created and fail every write from then on. Outside the namespace there is + * nothing to collide with. It stays on the same filesystem as the entries, which + * is what lets a write be published by renaming.

+ */ + private static final String STORAGE_SCRATCH_DIR = "cn1-storage-scratch"; + + /** + * Suffix of the file each process locks for as long as it is running, so that the + * others can tell whether the writes it left behind are still being written. + * + *

This replaces judging a scratch file by its age. An application may run more + * than one process, each with its own copy of this class and so its own idea of + * what is open, and age was the only thing they all agreed on -- but + * {@code lastModified} is a wall clock reading, and a clock that jumps forward + * makes a file being written this moment look arbitrarily old. A lock says + * whether the writer is there, and the system drops it when a process ends + * however it ends, so it cannot outlive the process it stands for.

+ */ + private static final String STORAGE_LIVE_SUFFIX = ".live"; + + /** + * How long to leave between sweeps. A rate limit rather than a judgement about + * any file, measured on the monotonic clock so that setting the wall clock cannot + * disturb it. + */ + private static final long STORAGE_SWEEP_INTERVAL = 5L * 60L * 1000L; + + /** + * Distinguishes the scratch files of concurrent writes. Paired with the process + * id, since a second process counts from the beginning as well. + */ + private static final AtomicLong storageScratchCounter = new AtomicLong(); + + /** + * Guards the instant at which a write is published or abandoned, and the set of + * writes that are still open. Deleting an entry and publishing one have to take + * turns: otherwise a write that renames its scratch file just after another + * thread deleted the entry brings the deleted entry back. + */ + private static final Object storagePublishLock = new Object(); + + /** + * Name of the file whose lock serializes storage writes between processes. + */ + private static final String STORAGE_LOCK_FILE = ".lock"; + + /** + * The cross process lock, and the handle it is taken on, while this process holds + * it. Guarded by {@link #storagePublishLock}, so only one thread here ever has it. + */ + private static RandomAccessFile storageLockHandle; + private static FileLock storageLockAcrossProcesses; + + /** + * The lock this process holds for as long as it runs, saying that the scratch + * files bearing its process id are still being written. Never released: the + * system takes it back when the process ends. + */ + private static RandomAccessFile storageLiveHandle; + private static FileLock storageLiveLock; + + /** + * How many nested claims this process has on the cross process lock. A + * {@code FileLock} is held by the whole VM and cannot be taken twice, and + * clearStorage claims it and then calls deleteStorageFile for every entry. + */ + private static int storageLockDepth; + + /** + * Claims the storage for this process, so that creating a scratch file, deleting + * an entry and publishing a write cannot interleave between processes. + * + *

Unlinking a writer's scratch file is what cancels it, and that only reaches + * the writes that exist when the deletion looks. Without this a second process + * could create its scratch file just after a deletion had scanned for them, and + * publish over the entry that deletion went on to remove. A lock the filesystem + * arbitrates is the only thing both processes can see; the system drops it when a + * process ends however it ends, so it cannot be left held by a crash.

+ * + *

Best effort: if the lock cannot be taken the work still goes ahead, since a + * storage that stops writing would be worse than one exposed to a race that only + * an application with more than one process can reach at all.

+ * + *

The caller must hold {@link #storagePublishLock}.

+ */ + private static void lockStorageAcrossProcesses() { + if (storageLockDepth == 0) { + try { + File dir = storageScratchDir(); + if (dir.isDirectory() || dir.mkdirs() || dir.isDirectory()) { + // kept before the lock is attempted rather than after it succeeds, + // so that a lock which throws still leaves releaseStorageLock + // something to close. Otherwise a filesystem that refuses to lock + // leaks a descriptor on every storage operation until unrelated + // files stop opening. + storageLockHandle = + new RandomAccessFile(new File(dir, STORAGE_LOCK_FILE), "rw"); + storageLockAcrossProcesses = storageLockHandle.getChannel().lock(); + } + } catch (Throwable t) { + // android's log, not ours: the default log writer is a storage stream, + // so reporting this through it would come back through here with the + // depth still at zero and fail the same way, again and again + Log.e("CodenameOne", "Could not lock the storage", t); + releaseStorageLock(); + } + } + storageLockDepth++; + } + + /** + * Gives up this process's claim on the storage. + * + *

The caller must hold {@link #storagePublishLock}.

+ */ + private static void unlockStorageAcrossProcesses() { + storageLockDepth--; + if (storageLockDepth == 0) { + releaseStorageLock(); + } + } + + /** + * Drops the cross process lock and the handle it was taken on, whichever of them + * this process actually got. + */ + private static void releaseStorageLock() { + try { + if (storageLockAcrossProcesses != null) { + storageLockAcrossProcesses.release(); + } + } catch (Throwable t) { + Log.e("CodenameOne", "Could not release the storage lock", t); + } + storageLockAcrossProcesses = null; + try { + if (storageLockHandle != null) { + storageLockHandle.close(); + } + } catch (Throwable t) { + Log.e("CodenameOne", "Could not close the storage lock", t); + } + storageLockHandle = null; + } + + /** + * The writes that are currently open, so that deleting an entry can cancel them. + * Guarded by {@link #storagePublishLock}. + */ + private static final List openStorageWrites = + new ArrayList(); + + /** + * When the scratch area is next worth looking at, on the monotonic clock. Keeps + * the sweep from running on every write without ever being the thing that decides + * whether a file is abandoned. Guarded by {@link #storagePublishLock}. + */ + private static long nextStorageScratchSweep; + + /** + * @inheritDoc + */ + public void deleteStorageFile(String name) { + synchronized (storagePublishLock) { + lockStorageAcrossProcesses(); + try { + // cancelled before the entry goes, and under the same lock the + // publishing rename takes, so a write that is already mid close + // cannot put the entry back afterwards. + for (int iter = 0; iter < openStorageWrites.size(); iter++) { + openStorageWrites.get(iter).cancel(name); + } + // the same for writes in another process, which the monitor above + // knows nothing about. Unlinking a scratch file cancels it: the + // writer keeps a working descriptor on an inode with no name, exactly + // as it used to keep one on an entry deleted underneath it, and the + // rename that would have published it can no longer find anything to + // rename. Scratch files go first, so a publish that slips through + // between the two still leaves an entry for the delete to remove. + discardScratchFilesFor(name); + getContext().deleteFile(name); + } finally { + unlockStorageAcrossProcesses(); + } + } + } + + /** + * Unlinks every scratch file being written for the given entry, in this process + * or any other, which is what cancels those writes. + * + * @param name the storage entry + */ + private static void discardScratchFilesFor(String name) { + try { + String prefix = storageScratchPrefix(name); + File[] scratch = storageScratchDir().listFiles(); + if (scratch == null) { + return; + } + for (int iter = 0; iter < scratch.length; iter++) { + if (scratch[iter].getName().startsWith(prefix) && !scratch[iter].delete()) { + com.codename1.io.Log.p("Could not cancel the storage write " + + scratch[iter]); + } + } + } catch (IOException err) { + com.codename1.io.Log.e(err); + } + } + + /** + * @inheritDoc + */ + public void clearStorage() { + synchronized (storagePublishLock) { + // every open write, not just the ones for entries that exist. A write to + // an entry that is not there yet is absent from listStorageEntries, so the + // inherited implementation never reaches it, and it would publish a new + // entry moments after the storage was supposedly emptied. + lockStorageAcrossProcesses(); + try { + for (int iter = 0; iter < openStorageWrites.size(); iter++) { + openStorageWrites.get(iter).cancel(); + } + discardAllScratchFiles(); + super.clearStorage(); + } finally { + unlockStorageAcrossProcesses(); + } + } + } + + /** + * @inheritDoc + */ + public boolean abandonStorageWrite(String name, OutputStream writing) { + // this write and no other. Every write to the entry used to be given up + // together, so a second thread writing the same entry had its value quietly + // discarded and was told the write had succeeded. + if (writing instanceof StorageOutputStream) { + synchronized (storagePublishLock) { + ((StorageOutputStream) writing).cancel(); + } + // such a write leaves the entry untouched until it is published, so + // whatever was stored is still there + return true; + } + // a stream that never opened cannot have touched anything either. Anything + // else wrote into the entry itself and the caller has to clear up after it. + return writing == null; + } + + /** + * @inheritDoc + * + *

Writes into the entry, as it always has. A caller may hold this open and + * expect what it flushes to be readable meanwhile -- the log writer keeps one for + * the life of the application and sendLog reads the entry behind its back -- so + * an entry that appeared only on close would leave the log unreadable and lose + * everything written since the process started. What can be given here without + * changing when the entry appears is the flush that Android does not do on + * close.

+ */ + public OutputStream createStorageOutputStream(String name) throws IOException { + return new SyncingStorageOutputStream(getContext().openFileOutput(name, 0)); + } + + /** + * @inheritDoc + */ + public OutputStream createStorageOutputStream(String name, boolean replaceWhenClosed) + throws IOException { + if (!replaceWhenClosed) { + return createStorageOutputStream(name); + } + sweepStorageScratchFiles(); + return new StorageOutputStream(name); + } + + /** + * Forces a stream onto the device as it closes, which Android does not do by + * itself, without changing anything about when what is written becomes visible. + */ + private static final class SyncingStorageOutputStream extends OutputStream { + private final FileOutputStream out; + private boolean closed; + + SyncingStorageOutputStream(FileOutputStream out) { + this.out = out; + } + + @Override + public void write(int b) throws IOException { + out.write(b); + } + + @Override + public void write(byte[] b) throws IOException { + out.write(b); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + out.write(b, off, len); + } + + @Override + public void flush() throws IOException { + out.flush(); + } + + @Override + public void close() throws IOException { + if (closed) { + return; + } + closed = true; + try { + out.flush(); + out.getFD().sync(); + } finally { + out.close(); + } + } + } + + /** + * @inheritDoc + */ + public InputStream createStorageInputStream(String name) throws IOException { + return getContext().openFileInput(name); + } + + /** + * @inheritDoc + */ + public boolean storageFileExists(String name) { + String[] fileList = getContext().fileList(); + for (int iter = 0; iter < fileList.length; iter++) { + if (fileList[iter].equals(name)) { + return true; + } + } + return false; + } + + /** + * @inheritDoc + */ + public String[] listStorageEntries() { + return getContext().fileList(); + } + + /** + * @inheritDoc + */ + public int getStorageEntrySize(String name) { + return (int)new File(getContext().getFilesDir(), name).length(); + } + + /** + * Removes the scratch files left behind by a run that died mid write, once they + * are old enough that nothing can still be writing them. + */ + private void sweepStorageScratchFiles() { + synchronized (storagePublishLock) { + long now = android.os.SystemClock.elapsedRealtime(); + if (now < nextStorageScratchSweep) { + return; + } + nextStorageScratchSweep = now + STORAGE_SWEEP_INTERVAL; + // under the lock the other processes take to start a write or to say they + // are running. Finding an owner gone and then deleting its files are two + // steps, and a process id is handed out again the moment its holder is + // gone: without this a process could be given the id just examined, say so + // and start writing, and have this sweep delete the write it had only just + // begun -- or the very file it had said it was alive with, after which + // every later sweep would take it for gone. + lockStorageAcrossProcesses(); + try { + File dir = storageScratchDir(); + File[] files = dir.listFiles(); + if (files == null) { + return; + } + int mine = android.os.Process.myPid(); + for (int iter = 0; iter < files.length; iter++) { + if (isStorageLockFile(files[iter])) { + continue; + } + int owner = storageScratchOwner(files[iter].getName()); + // this process knows what it is doing without asking, and never + // tries to lock its own liveness file, which it already holds + if (owner < 0 || owner == mine || isProcessWriting(dir, owner)) { + continue; + } + if (!files[iter].delete()) { + com.codename1.io.Log.p("Could not remove the abandoned storage " + + "scratch file " + files[iter]); + } + } + } catch (Throwable t) { + // a sweep that fails costs disk space, never correctness + com.codename1.io.Log.e(t); + } finally { + unlockStorageAcrossProcesses(); + } + } + } + + /** + * The process a file in the scratch directory belongs to. + * + * @param fileName the name of the file + * @return the process id, or -1 if the name does not carry one + */ + private static int storageScratchOwner(String fileName) { + String pid; + if (fileName.endsWith(STORAGE_LIVE_SUFFIX)) { + pid = fileName.substring(0, fileName.length() - STORAGE_LIVE_SUFFIX.length()); + } else { + int digest = fileName.indexOf('-'); + int counter = digest < 0 ? -1 : fileName.indexOf('-', digest + 1); + if (counter < 0) { + return -1; + } + pid = fileName.substring(digest + 1, counter); + } + try { + return Integer.parseInt(pid); + } catch (NumberFormatException err) { + return -1; + } + } + + /** + * Whether the given process is still running, and so may still be writing the + * scratch files that carry its id. + * + *

Asked of the filesystem rather than of {@code /proc}, which since Android 9 + * shows a process only itself. A lock that can be taken is one nobody is holding. + * Anything unexpected counts as running, since deleting another process's work on + * a guess is the one outcome worth avoiding here.

+ * + * @param dir the scratch directory + * @param pid the process to ask about + * @return true if that process appears to be running + */ + private static boolean isProcessWriting(File dir, int pid) { + File live = new File(dir, pid + STORAGE_LIVE_SUFFIX); + if (!live.exists()) { + return false; + } + RandomAccessFile handle = null; + FileLock held = null; + try { + handle = new RandomAccessFile(live, "rw"); + held = handle.getChannel().tryLock(); + return held == null; + } catch (Throwable t) { + return true; + } finally { + try { + if (held != null) { + held.release(); + } + if (handle != null) { + handle.close(); + } + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + } + + /** + * Says, for as long as this process runs, that the scratch files carrying its + * process id are still being written. + * + * @param dir the scratch directory + */ + private static void claimStorageLiveness(File dir) { + synchronized (storagePublishLock) { + if (storageLiveLock != null) { + return; + } + // under the same lock the sweep takes, so that saying this process is + // running and clearing what the last holder of its id left behind cannot + // land in the middle of another process deciding that id is gone + lockStorageAcrossProcesses(); + try { + try { + storageLiveHandle = new RandomAccessFile( + new File(dir, android.os.Process.myPid() + STORAGE_LIVE_SUFFIX), "rw"); + storageLiveLock = storageLiveHandle.getChannel().lock(); + } catch (Throwable t) { + // android's log for the same reason as above + Log.e("CodenameOne", "Could not claim the storage liveness file", t); + try { + if (storageLiveHandle != null) { + storageLiveHandle.close(); + } + } catch (Throwable ignored) { + Log.e("CodenameOne", "Could not close the liveness file", ignored); + } + // the lock as well as the handle: closing the handle gives up the + // lock, and a lock this process still believed it held is one it + // would never take again, which leaves every other process reading + // it as gone and free to delete the writes it has in flight + storageLiveHandle = null; + storageLiveLock = null; + return; + } + try { + discardEarlierIncarnation(dir); + } catch (Throwable t) { + // separately, because the claim above has already succeeded and + // clearing up after whoever held this id last is not worth giving + // it up for. The leftovers keep until a later sweep. + Log.e("CodenameOne", "Could not clear the earlier incarnation", t); + } + } finally { + unlockStorageAcrossProcesses(); + } + } + } + + /** + * Unlinks every scratch file there is, cancelling every write in progress in any + * process. + */ + private static void discardAllScratchFiles() { + try { + File[] scratch = storageScratchDir().listFiles(); + if (scratch == null) { + return; + } + for (int iter = 0; iter < scratch.length; iter++) { + if (!isStorageMarkerFile(scratch[iter]) && !scratch[iter].delete()) { + com.codename1.io.Log.p("Could not cancel the storage write " + + scratch[iter]); + } + } + } catch (IOException err) { + com.codename1.io.Log.e(err); + } + } + + /** + * Whether the given file is the one whose lock serializes the processes, rather + * than a write in progress. + * + *

It has to survive both the clear and the sweep. Linux lets a locked file be + * unlinked, and the lock goes with the inode rather than the name, so a process + * that removed it while holding it would leave the next process free to create + * the name afresh and take a lock on a different inode: both would then hold + * "the" lock and neither would wait for the other. Nothing writes to it either, + * so its age says nothing about whether it is in use.

+ * + * @param file a file in the scratch directory + * @return true if the file is the lock + */ + private static boolean isStorageLockFile(File file) { + return STORAGE_LOCK_FILE.equals(file.getName()); + } + + /** + * Removes whatever a previous process left behind under this process's id. + * + *

Android hands out a process id again once the process holding it is gone, so + * after a crash or a reboot the files an earlier incarnation abandoned can be + * sitting under the id this one has just been given. The sweep passes over + * anything bearing its own id, on the grounds that a process knows its own work, + * which would leave those files where they are for good.

+ * + *

Usually this runs before the first write, when the process owns nothing and + * everything under its id must belong to the incarnation before it. That is not + * guaranteed: a claim that fails is retried by the next write, by which time this + * process may have writes of its own open. Those are known exactly and are left + * alone -- deleting one would fail a write that had already been serialized.

+ * + *

The caller must hold {@link #storagePublishLock}.

+ * + * @param dir the scratch directory + */ + private static void discardEarlierIncarnation(File dir) { + File[] files = dir.listFiles(); + if (files == null) { + return; + } + int mine = android.os.Process.myPid(); + for (int iter = 0; iter < files.length; iter++) { + if (!isStorageMarkerFile(files[iter]) + && storageScratchOwner(files[iter].getName()) == mine + && !isOpenStorageWrite(files[iter]) + && !files[iter].delete()) { + com.codename1.io.Log.p("Could not remove the abandoned storage scratch " + + "file " + files[iter]); + } + } + } + + /** + * Whether the given scratch file belongs to a write this process has open. + * + *

The caller must hold {@link #storagePublishLock}.

+ * + * @param file a file in the scratch directory + * @return true if a write in this process is using it + */ + private static boolean isOpenStorageWrite(File file) { + for (int iter = 0; iter < openStorageWrites.size(); iter++) { + if (openStorageWrites.get(iter).scratch.equals(file)) { + return true; + } + } + return false; + } + + /** + * Whether the given file is one of the markers the processes keep about + * themselves, rather than a write in progress. + * + *

Clearing the storage throws away the writes, and nothing else. A process + * whose liveness file was taken from underneath it goes on holding the lock, so + * it never notices and never makes the name again, and from then on every other + * process reads it as gone and feels free to delete the writes it has in flight. + * The sweep is the one place a liveness file is removed, and only once its owner + * is known to be gone.

+ * + * @param file a file in the scratch directory + * @return true if the file is a marker rather than a pending write + */ + private static boolean isStorageMarkerFile(File file) { + return isStorageLockFile(file) || file.getName().endsWith(STORAGE_LIVE_SUFFIX); + } + + /** + * The start of the name of every scratch file for the given entry. + * + *

A digest rather than the entry itself: an entry name may be as long as the + * filesystem allows on its own, so anything built by appending to one would be + * refused. Fixed width, and specific enough that one entry's deletion does not + * cancel another's write.

+ * + * @param name the storage entry + * @return the prefix shared by that entry's scratch files + * @throws IOException if the digest is unavailable + */ + private static String storageScratchPrefix(String name) throws IOException { + try { + byte[] digest = java.security.MessageDigest.getInstance("SHA-256") + .digest(name.getBytes("UTF-8")); + StringBuilder b = new StringBuilder(digest.length * 2); + for (int iter = 0; iter < digest.length; iter++) { + b.append(Character.forDigit((digest[iter] >> 4) & 0xf, 16)); + b.append(Character.forDigit(digest[iter] & 0xf, 16)); + } + return b.append('-').toString(); + } catch (java.security.NoSuchAlgorithmException err) { + throw new IOException("No SHA-256 to name storage scratch files with", err); + } + } + + /** + * Resolves a storage entry to its file, refusing anything that would land outside + * the storage directory. + * + *

{@code openFileOutput} used to make this check on our behalf and reject any + * name holding a path separator. Publishing by rename does not: with name + * normalization turned off a key like {@code ../shared_prefs/settings.xml} + * reaches here as it was written, and {@code File} resolves it, which would put + * the rename anywhere in the application's private data and leave behind an entry + * that Storage itself could no longer read or delete.

+ * + * @param name the storage entry + * @return the file the entry is stored in + * @throws IOException if the name does not name an entry in the storage directory + */ + private static File storageEntryFile(String name) throws IOException { + File dir = getContext().getFilesDir(); + if (name.indexOf('/') >= 0 || name.indexOf(File.separatorChar) >= 0) { + throw new IOException("Storage entry " + name + " contains a path separator"); + } + File entry = new File(dir, name); + if (!dir.equals(entry.getParentFile())) { + throw new IOException("Storage entry " + name + " resolves outside " + dir); + } + return entry; + } + + /** + * The directory holding the writes that are in progress. + * + * @return the scratch directory, which is not guaranteed to exist yet + * @throws IOException if the application has no data directory to put it in + */ + private static File storageScratchDir() throws IOException { + File files = getContext().getFilesDir(); + File data = files.getParentFile(); + if (data == null) { + throw new IOException("No application data directory above " + files); + } + return new File(data, STORAGE_SCRATCH_DIR); + } + + /** + * Writes a storage entry to a scratch file, forces the bytes onto the device and + * only then renames that file over the entry. + * + *

{@code openFileOutput} truncates the entry as it opens it, and Android does + * not flush a file on close. Writing the entry in place therefore left a window + * on every single write in which the entry was empty or half written on disk, and + * left the bytes of a completed write sitting in the page cache for as long as + * the kernel felt like holding them. An abrupt end to the process or to the + * device inside either window -- a low memory kill, a force stop, a battery pull, + * a panic -- lost the entry, and on a filesystem that journals the truncation + * ahead of the data it came back as a zero length file. How wide those windows + * are is a property of the filesystem and of how eagerly the vendor kills + * background processes, which is why this only ever showed up on some devices.

+ * + *

The entry now changes in a single rename, which the filesystem cannot show + * half done, and the bytes reach the device before that rename is made.

+ */ + private static final class StorageOutputStream extends OutputStream { + private final String name; + private final File target; + private final File scratch; + private final FileOutputStream out; + private boolean closed; + private boolean cancelled; + + StorageOutputStream(String name) throws IOException { + this.name = name; + this.target = storageEntryFile(name); + File dir = storageScratchDir(); + if (!dir.isDirectory() && !dir.mkdirs() && !dir.isDirectory()) { + throw new IOException("Could not create the storage scratch directory " + + dir); + } + // the write goes ahead whether or not that succeeded. A claim can only + // fail where the filesystem will not lock, and refusing to write would + // turn that into an application that cannot store anything -- far worse + // than what it costs, which is that another process sweeping at that + // moment may take this write for abandoned and unlink it. That fails the + // write, honestly, and leaves what was already stored where it is; the + // next write claims again. Same trade the cross process lock makes. + claimStorageLiveness(dir); + // the digest of the entry lets another process find and cancel this write. + // The process id separates concurrent processes, whose counters both start + // from the beginning, and the counter separates writes within one. + this.scratch = new File(dir, storageScratchPrefix(name) + + android.os.Process.myPid() + "-" + + storageScratchCounter.incrementAndGet()); + // created and registered as one step under the lock a deletion takes. + // Registering afterwards would leave a write whose scratch file already + // exists but which a concurrent deleteStorageFile cannot see to cancel, + // and that write would rename itself over the entry that was deleted. + synchronized (storagePublishLock) { + lockStorageAcrossProcesses(); + try { + this.out = new FileOutputStream(scratch); + openStorageWrites.add(this); + } finally { + unlockStorageAcrossProcesses(); + } + } + } + + /** + * Marks this write as one that must not be published, whatever entry it is + * for. Called holding {@link #storagePublishLock}. + */ + void cancel() { + cancelled = true; + } + + /** + * Marks this write as one that must not be published, because the entry it + * would publish over has been deleted since it opened. Called holding + * {@link #storagePublishLock}. + * + * @param entry the entry being deleted + */ + void cancel(String entry) { + if (name.equals(entry)) { + cancelled = true; + } + } + + @Override + public void write(int b) throws IOException { + out.write(b); + } + + @Override + public void write(byte[] b) throws IOException { + out.write(b); + } + + @Override + public void write(byte[] b, int off, int len) throws IOException { + out.write(b, off, len); + } + + @Override + public void flush() throws IOException { + out.flush(); + } + + @Override + public void close() throws IOException { + if (closed) { + return; + } + closed = true; + try { + try { + out.flush(); + out.getFD().sync(); + } finally { + out.close(); + } + publish(); + } finally { + synchronized (storagePublishLock) { + openStorageWrites.remove(this); + } + if (scratch.exists() && !scratch.delete()) { + com.codename1.io.Log.p("Could not remove the storage scratch file " + + scratch); + } + } + } + + /** + * Renames the scratch file over the entry, which is the point at which the + * write becomes visible. + * + * @throws IOException if the entry could not be replaced, so that the caller + * that wrote it hears about it rather than being told the write succeeded + */ + private void publish() throws IOException { + synchronized (storagePublishLock) { + lockStorageAcrossProcesses(); + try { + // the one case where not publishing is not a failure: this + // process cancelled the write itself, so the caller either asked + // for the entry to go or is already abandoning the write. Failing + // here would only log noise over an outcome that is already known. + if (cancelled) { + return; + } + if (scratch.renameTo(target)) { + syncStorageDirectory(target.getParentFile()); + return; + } + // A missing scratch file is not reported as a success. Another + // process unlinking it does mean this entry was deleted, and + // failing here reaches the same place -- writeObject deletes the + // entry on a failed write -- while still telling the caller that + // what it wrote did not land. Anything else that removed the file + // gets the same honest answer, where calling it a success would + // leave the caller believing in a value the storage never took. + throw new IOException("Could not store " + name); + } finally { + unlockStorageAcrossProcesses(); + } + } + } + } + + /** + * Forces a rename in the given directory onto the device, so that a completed + * write does not fall back to its previous contents after an abrupt shutdown. + * Best effort: without it a crash can still only cost the newest write, never the + * integrity of an entry. + * + * @param dir the directory holding the storage entries + */ + private static void syncStorageDirectory(File dir) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + return; + } + try { + DirectorySync.sync(dir); + } catch (Throwable t) { + // some filesystems refuse to sync a directory handle + } + } + + /** + * Isolates the API 21 syscalls, so that verifying {@code AndroidImplementation} + * on an older device never has to resolve them. + */ + private static final class DirectorySync { + private DirectorySync() { + } + + static void sync(File dir) throws android.system.ErrnoException { + java.io.FileDescriptor fd = android.system.Os.open(dir.getPath(), + android.system.OsConstants.O_RDONLY, 0); + try { + android.system.Os.fsync(fd); + } finally { + android.system.Os.close(fd); + } + } + } + + private String addFile(String s) { + // I explicitly don't create a "proper URL" since code might rely on the fact that the file isn't encoded + if(s != null && s.startsWith("/")) { + return "file://" + s; + } + return s; + } + + /** + * @inheritDoc + */ + public String[] listFilesystemRoots() { + + if(!checkForPermission(Manifest.permission.READ_EXTERNAL_STORAGE, "This is required to browse the file system")){ + return new String[]{}; + } + + String [] storageDirs = getStorageDirectories(); + if(storageDirs != null){ + String [] roots = new String[storageDirs.length + 1]; + System.arraycopy(storageDirs, 0, roots, 0, storageDirs.length); + roots[roots.length - 1] = addFile(Environment.getRootDirectory().getAbsolutePath()); + return roots; + } + return new String[]{addFile(Environment.getRootDirectory().getAbsolutePath())}; + } + + @Override + public boolean hasCachesDir() { + return true; + } + + @Override + public String getCachesDir() { + return getContext().getCacheDir().getAbsolutePath(); + } + + + + private String[] getStorageDirectories() { + String [] storageDirs = null; + + String storageDev = Environment.getExternalStorageDirectory().getPath(); + String storageRoot = storageDev.substring(0, storageDev.length() - 1); + BufferedReader bufReader = null; + + try { + bufReader = new BufferedReader(new InputStreamReader(new FileInputStream("/proc/mounts"), StandardCharsets.UTF_8)); + ArrayList list = new ArrayList(); + String line; + + while ((line = bufReader.readLine()) != null) { + if (line.contains("vfat") || line.contains("/mnt") || line.contains("/storage")) { + StringTokenizer tokens = new StringTokenizer(line, " "); + String s = tokens.nextToken(); + s = tokens.nextToken(); // Take the second token, i.e. mount point + + if (s.indexOf("secure") != -1) { + continue; + } + + if (s.startsWith(storageRoot) == true) { + list.add(s); + continue; + } + + if (line.contains("vfat") && line.contains("/mnt")) { + list.add(s); + continue; + } + } + } + + int count = list.size(); + + if (count < 2) { + storageDirs = new String[] { + storageDev + }; + } + else { + storageDirs = new String[count]; + + for (int i = 0; i < count; i++) { + storageDirs[i] = (String) list.get(i); + } + } + } + catch (FileNotFoundException e) {} + catch (IOException e) {} + finally { + if (bufReader != null) { + try { + bufReader.close(); + } + catch (IOException e) {} + } + + return storageDirs; + } + } + + /** + * @inheritDoc + */ + public String getAppHomePath() { + return addFile(getContext().getFilesDir().getAbsolutePath() + "/"); + } + + @Override + public String toNativePath(String path) { + return removeFilePrefix(path); + } + + + + /** + * @inheritDoc + */ + public String[] listFiles(String directory) throws IOException { + directory = removeFilePrefix(directory); + return new File(directory).list(); + } + + /** + * @inheritDoc + */ + public long getRootSizeBytes(String root) { + return -1; + } + + /** + * @inheritDoc + */ + public long getRootAvailableSpace(String root) { + return -1; + } + + /** + * @inheritDoc + */ + public void mkdir(String directory) { + directory = removeFilePrefix(directory); + new File(directory).mkdir(); + } + + /** + * @inheritDoc + */ + public void deleteFile(String file) { + file = removeFilePrefix(file); + File f = new File(file); + f.delete(); + } + + /** + * @inheritDoc + */ + public boolean isHidden(String file) { + file = removeFilePrefix(file); + return new File(file).isHidden(); + } + + /** + * @inheritDoc + */ + public void setHidden(String file, boolean h) { + } + + /** + * @inheritDoc + */ + public long getFileLength(String file) { + file = removeFilePrefix(file); + return new File(file).length(); + } + + /** + * @inheritDoc + */ + public long getFileLastModified(String file) { + file = removeFilePrefix(file); + return new File(file).lastModified(); + } + + /** + * @inheritDoc + */ + public boolean isDirectory(String file) { + file = removeFilePrefix(file); + return new File(file).isDirectory(); + } + + /** + * @inheritDoc + */ + public char getFileSystemSeparator() { + return File.separatorChar; + } + + /** + * @inheritDoc + */ + public OutputStream openFileOutputStream(String file) throws IOException { + file = removeFilePrefix(file); + OutputStream os = null; + try{ + os = createFileOuputStream(file); + }catch(FileNotFoundException fne){ + //It is impossible to know if a path is considered an external + //storage on the various android's versions. + //So we try to open the path and if failed due to permission we will + //ask for the permission from the user + if(fne.getMessage().contains("Permission denied")){ + + if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to access the file")){ + //The user refused to give access. + return null; + }else{ + //The user gave permission try again to access the path + return createFileOuputStream(file); + } + + }else{ + throw fne; + } + } + + return os; + } + + static String removeFilePrefix(String file) { + if (file.startsWith("file://")) { + return file.substring(7); + } + if (file.startsWith("file:/")) { + return file.substring(5); + } + return file; + } + + /** + * @inheritDoc + */ + public InputStream openFileInputStream(String file) throws IOException { + file = removeFilePrefix(file); + InputStream is = null; + try{ + is = createFileInputStream(file); + }catch(FileNotFoundException fne){ + //It is impossible to know if a path is considered an external + //storage on the various android's versions. + //So we try to open the path and if failed due to permission we will + //ask for the permission from the user + if(fne.getMessage().contains("Permission denied")){ + + if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to access the file")){ + //The user refused to give access. + return null; + }else{ + //The user gave permission try again to access the path + return openFileInputStream(file); + } + + }else{ + throw fne; + } + } + + return is; + } + + @Override + public boolean isMultiTouch() { + return true; + } + + /** + * @inheritDoc + */ + public boolean exists(String file) { + file = removeFilePrefix(file); + return new File(file).exists(); + } + + /** + * @inheritDoc + */ + public void rename(String file, String newName) { + file = removeFilePrefix(file); + new File(file).renameTo(new File(new File(file).getParentFile(), newName)); + } + + protected File createFileObject(String fileName) { + return new File(fileName); + } + + protected InputStream createFileInputStream(String fileName) throws FileNotFoundException { + return new FileInputStream(removeFilePrefix(fileName)); + } + + protected InputStream createFileInputStream(File f) throws FileNotFoundException { + return new FileInputStream(f); + } + + protected OutputStream createFileOuputStream(String fileName) throws FileNotFoundException { + return new FileOutputStream(removeFilePrefix(fileName)); + } + + protected OutputStream createFileOuputStream(java.io.File f) throws FileNotFoundException { + return new FileOutputStream(f); + } + + /** + * @inheritDoc + */ + public boolean shouldWriteUTFAsGetBytes() { + return true; + } + + + /** + * @inheritDoc + */ + public void closingOutput(OutputStream s) { + // For some reasons the Android guys chose not doing this by default: + // http://android-developers.blogspot.com/2010/12/saving-data-safely.html + // this seems to be a mistake of sacrificing stability for minor performance + // gains which will only be noticeable on a server. + if (s != null) { + if (s instanceof FileOutputStream) { + try { + FileDescriptor fd = ((FileOutputStream) s).getFD(); + if (fd != null) { + fd.sync(); + } + } catch (IOException ex) { + // this exception doesn't help us + ex.printStackTrace(); + } + } + } + } + + /** + * @inheritDoc + */ + public void printStackTraceToStream(Throwable t, Writer o) { + PrintWriter p = new PrintWriter(o); + t.printStackTrace(p); + } + + private AndroidBiometrics biometrics; + private AndroidSecureStorage secureStorage; + private AndroidNfc nfc; + private AndroidBluetooth bluetooth; + + @Override + public com.codename1.security.Biometrics getBiometrics() { + if (biometrics == null) { + biometrics = new AndroidBiometrics(); + } + return biometrics; + } + + @Override + public com.codename1.security.SecureStorage getSecureStorage() { + if (secureStorage == null) { + secureStorage = new AndroidSecureStorage(); + } + return secureStorage; + } + + @Override + public com.codename1.nfc.Nfc getNfc() { + if (nfc == null) { + nfc = new AndroidNfc(this); + } + return nfc; + } + + @Override + public com.codename1.bluetooth.Bluetooth getBluetooth() { + if (bluetooth == null) { + bluetooth = new AndroidBluetooth(); + } + return bluetooth; + } + + private com.codename1.health.Health health; + + /// Returns the Health Connect-backed health entry point. The store + /// degrades to reporting itself unsupported when no bridge has been + /// injected, which is the case for apps that never reference + /// com.codename1.health. + @Override + public com.codename1.health.Health getHealth() { + // Guarded because everything the store serializes is per-instance: + // the authorization queue, the subscription registry, drain + // coalescing and the persisted-cursor lock. Two threads racing this + // getter each got their own store, and two stores coordinate on + // nothing -- they would launch overlapping permission flows despite + // the queue inside each one being correct. + synchronized (AndroidImplementation.class) { + if (health == null) { + health = new AndroidHealth(); + } + return health; + } + } + + /** + * This method returns the platform Location Control + * + * @return LocationControl Object + */ + public LocationManager getLocationManager() { + String permissionMessage = "This is required to get the location"; + if ( + !checkForPermission( Manifest.permission.ACCESS_FINE_LOCATION, permissionMessage) + ) { + return null; + } + if ( + Build.VERSION.SDK_INT >= 29 + && "true".equals(Display.getInstance().getProperty("android.requiresBackgroundLocationPermissionForAPI29", "false")) + ) { + if ( + !checkForPermission( + "android.permission.ACCESS_BACKGROUND_LOCATION", + permissionMessage + ) + ) { + com.codename1.io.Log.e(new RuntimeException("Background location permission denied")); + } + } + + boolean includesPlayServices = Display.getInstance().getProperty("IncludeGPlayServices", "false").equals("true"); + if (includesPlayServices && hasAndroidMarket()) { + try { + Class clazz = Class.forName("com.codename1.location.AndroidLocationPlayServiceManager"); + return (com.codename1.location.LocationManager)clazz.getMethod("getInstance").invoke(null); + } catch (Exception e) { + return AndroidLocationManager.getInstance(getContext()); + } + } else { + return AndroidLocationManager.getInstance(getContext()); + } + } + + private AndroidMotionSensorManager motionSensorManager; + + @Override + public com.codename1.sensors.MotionSensorManager getMotionSensorManager() { + if (motionSensorManager == null) { + Context ctx = getContext(); + if (ctx == null) { + return null; + } + motionSensorManager = new AndroidMotionSensorManager(ctx); + } + return motionSensorManager; + } + + private String fixAttachmentPath(String attachment) { + com.codename1.io.File cn1File = new com.codename1.io.File(attachment); + File mediaStorageDir = new File(new File(getContext().getCacheDir(), "intent_files"), "Attachment"); + + // Create the storage directory if it does not exist + if (!mediaStorageDir.exists()) { + if (!mediaStorageDir.mkdirs()) { + Log.d(Display.getInstance().getProperty("AppName", "CodenameOne"), "failed to create directory"); + return null; + } + } + + File newFile = new File(mediaStorageDir.getPath() + File.separator + + cn1File.getName()); + if (newFile.exists()) { + if (Display.getInstance().getProperty("DeleteCachedFileAfterShare", "false").equals("true")) { + newFile.delete(); + } else { + // Create a media file name + String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date()); + newFile = new File(mediaStorageDir.getPath() + File.separator + + "IMG_" + timeStamp + "_" + cn1File.getName()); + } + } + + + //Uri fileUri = Uri.fromFile(newFile); + newFile.getParentFile().mkdirs(); + //Uri imageUri = Uri.fromFile(newFile); + Uri fileUri = FileProvider.getUriForFile(getContext(), getContext().getPackageName()+".provider", newFile); + + try { + InputStream is = FileSystemStorage.getInstance().openInputStream(attachment); + OutputStream os = new FileOutputStream(newFile); + byte [] buf = new byte[1024]; + int len; + while((len = is.read(buf)) > -1){ + os.write(buf, 0, len); + } + is.close(); + os.close(); + } catch (IOException ex) { + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + } + + return fileUri.toString(); + } + + /** + * @inheritDoc + */ + public void sendMessage(String[] recipients, String subject, Message msg) { + if(editInProgress()) { + stopEditing(true); + } + Intent emailIntent; + String attachment = msg.getAttachment(); + boolean hasAttachment = (attachment != null && attachment.length() > 0) || msg.getAttachments().size() > 0; + + if(msg.getMimeType().equals(Message.MIME_TEXT) && !hasAttachment){ + StringBuilder to = new StringBuilder(); + for (int i = 0; i < recipients.length; i++) { + to.append(recipients[i]); + to.append(";"); + } + emailIntent = new Intent(Intent.ACTION_SENDTO, + Uri.parse( + "mailto:" + to.toString() + + "?subject=" + Uri.encode(subject) + + "&body=" + Uri.encode(msg.getContent()))); + }else{ + if (hasAttachment) { + if(msg.getAttachments().size() > 1) { + emailIntent = new Intent(android.content.Intent.ACTION_SEND_MULTIPLE); + emailIntent.putExtra(android.content.Intent.EXTRA_EMAIL, recipients); + emailIntent.putExtra(android.content.Intent.EXTRA_SUBJECT, subject); + emailIntent.setType(msg.getMimeType()); + ArrayList uris = new ArrayList(); + + for(String path : msg.getAttachments().keySet()) { + uris.add(Uri.parse(fixAttachmentPath(path))); + } + + emailIntent.putParcelableArrayListExtra(Intent.EXTRA_STREAM, uris); + } else { + emailIntent = new Intent(android.content.Intent.ACTION_SEND); + emailIntent.putExtra(android.content.Intent.EXTRA_EMAIL, recipients); + emailIntent.putExtra(android.content.Intent.EXTRA_SUBJECT, subject); + emailIntent.setType(msg.getMimeType()); + emailIntent.setType(msg.getAttachmentMimeType()); + //if the attachment is in the uder home dir we need to copy it + //to an accessible dir + attachment = fixAttachmentPath(attachment); + emailIntent.putExtra(Intent.EXTRA_STREAM, Uri.parse(attachment)); + } + } else { + emailIntent = new Intent(android.content.Intent.ACTION_SEND); + emailIntent.putExtra(android.content.Intent.EXTRA_EMAIL, recipients); + emailIntent.putExtra(android.content.Intent.EXTRA_SUBJECT, subject); + emailIntent.setType(msg.getMimeType()); + } + if (msg.getMimeType().equals(Message.MIME_HTML)) { + emailIntent.putExtra(android.content.Intent.EXTRA_TEXT, Html.fromHtml(msg.getContent())); + emailIntent.putExtra("android.intent.extra.HTML_TEXT", msg.getContent()); + }else{ + /* + // Attempted this workaround to fix the ClassCastException that occurs on android when + // there are multiple attachments. Unfortunately, this fixes the stack trace, but + // has the unwanted side-effect of producing a blank message body. + // Same workaround for HTML mimetype also fails the same way. + // Conclusion, Just live with the stack trace. It doesn't seem to affect the + // execution of the program... treat it as a warning. + // See https://github.com/codenameone/CodenameOne/issues/1782 + if (msg.getAttachments().size() > 1) { + ArrayList contentArr = new ArrayList(); + contentArr.add(msg.getContent()); + emailIntent.putStringArrayListExtra(android.content.Intent.EXTRA_TEXT, contentArr); + } else { + emailIntent.putExtra(android.content.Intent.EXTRA_TEXT, msg.getContent()); + + }*/ + emailIntent.putExtra(android.content.Intent.EXTRA_TEXT, msg.getContent()); + } + + } + final String attach = attachment; + AndroidNativeUtil.startActivityForResult(Intent.createChooser(emailIntent, "Send mail..."), new IntentResultListener() { + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + if(attach != null && attach.length() > 0 && attach.contains("tmp")){ + FileSystemStorage.getInstance().delete(attach); + } + } + }); + } + + /** + * @inheritDoc + */ + public void dial(String phoneNumber) { + Intent dialer = new Intent(android.content.Intent.ACTION_DIAL, Uri.parse("tel:" + phoneNumber)); + getContext().startActivity(dialer); + } + + @Override + public int getSMSSupport() { + if(canDial()) { + return Display.SMS_INTERACTIVE; + } + return Display.SMS_NOT_SUPPORTED; + } + + /** + * @inheritDoc + */ + public void sendSMS(final String phoneNumber, final String message, boolean i) throws IOException { + /*if(!checkForPermission(Manifest.permission.SEND_SMS, "This is required to send a SMS")){ + return; + }*/ + if(!checkForPermission(Manifest.permission.READ_PHONE_STATE, "This is required to send a SMS")){ + return; + } + if(i) { + Intent smsIntent = null; + if(android.os.Build.VERSION.SDK_INT < 19){ + smsIntent = new Intent(Intent.ACTION_VIEW); + smsIntent.setType("vnd.android-dir/mms-sms"); + smsIntent.putExtra("address", phoneNumber); + smsIntent.putExtra("sms_body",message); + }else{ + smsIntent = new Intent(Intent.ACTION_SENDTO); + smsIntent.setData(Uri.parse("smsto:" + Uri.encode(phoneNumber))); + smsIntent.putExtra("sms_body", message); + } + getContext().startActivity(smsIntent); + + } /*else { + SmsManager sms = SmsManager.getDefault(); + ArrayList parts = sms.divideMessage(message); + sms.sendMultipartTextMessage(phoneNumber, null, parts, null, null); + }*/ + } + + @Override + public void dismissNotification(Object o) { + NotificationManager notificationManager = (NotificationManager) getContext().getSystemService(Activity.NOTIFICATION_SERVICE); + if(o != null){ + Integer n = (Integer)o; + notificationManager.cancel("CN1", n.intValue()); + }else{ + notificationManager.cancelAll(); + } + } + + @Override + public boolean isNotificationSupported() { + return true; + } + + /** + * Keys of display properties that need to be made available to Services + * i.e. must be accessible even if CN1 is not initialized. + * + * This is accomplished by setting them inside init(). Then they + * are written to file so that they can be accessed inside a service + * like push notification service. + */ + private static final String[] servicePropertyKeys = new String[]{ + "android.NotificationChannel.id", + "android.NotificationChannel.name", + "android.NotificationChannel.description", + "android.NotificationChannel.importance", + "android.NotificationChannel.enableLights", + "android.NotificationChannel.lightColor", + "android.NotificationChannel.enableVibration", + "android.NotificationChannel.vibrationPattern", + "android.NotoficationChannel.soundUri" + }; + + /** + * Flag to indicate if any of the service properties have been changed. + */ + private static boolean servicePropertiesDirty() { + for (String key : servicePropertyKeys) { + if (Display.getInstance().getProperty(key, null) != null) { + return true; + } + } + return false; + } + + /** + * Stores properties that need to be accessible to services. + * i.e. must be accessible even if CN1 is not initialized. + * + * This is accomplished by setting them inside init(). Then they + * are written to file so that they can be accessed inside a service + * like push notification service. + */ + private static Map serviceProperties; + + /** + * Gets the service properties. Will read properties from file so that + * they are available even if CN1 is not initialized. + * @param a + * @return + */ + public static Map getServiceProperties(Context a) { + if (serviceProperties == null) { + InputStream i = null; + try { + serviceProperties = new HashMap(); + try { + i = a.openFileInput("CN1$AndroidServiceProperties"); + if(i == null) { + return serviceProperties; + } + } catch (FileNotFoundException notFoundEx){ + return serviceProperties; + } + DataInputStream is = new DataInputStream(i); + int count = is.readInt(); + for (int idx=0; idx out = getServiceProperties(a); + + + for (String key : servicePropertyKeys) { + + String val = Display.getInstance().getProperty(key, null); + if (val != null) { + out.put(key, val); + } + if ("true".equals(Display.getInstance().getProperty(key+"#delete", null))) { + out.remove(key); + + } + } + + OutputStream os = null; + try { + os = a.openFileOutput("CN1$AndroidServiceProperties", 0); + if (os == null) { + System.out.println("Failed to save service properties null output stream"); + return; + } + DataOutputStream dos = new DataOutputStream(os); + dos.writeInt(out.size()); + for (String key : out.keySet()) { + dos.writeUTF(key); + dos.writeUTF((String)out.get(key)); + } + serviceProperties = null; + } catch (FileNotFoundException ex) { + System.out.println("Service properties file not found. This is normal for the first run. On subsequent runs, the file should exist."); + } catch (IOException ex) { + + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + } finally { + try { + if (os != null) os.close(); + } catch (Throwable ex) { + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + } + } + } + } + + /** + * Gets a "service" display property. This is a property that is available + * even if CN1 is not initialized. They are written to file after init() so that + * they are available thereafter to services like push notification services. + * @param key THe key + * @param defaultValue The default value + * @param context Context + * @return The value. + */ + public static String getServiceProperty(String key, String defaultValue, Context context) { + if (Display.isInitialized()) { + return Display.getInstance().getProperty(key, defaultValue); + } + String val = getServiceProperties(context).get(key); + return val == null ? defaultValue : val; + } + + /** + * Sets the notification channel on a notification builder. Uses service properties to + * set properties of channel. + * @param nm The notification manager. + * @param mNotifyBuilder The notify builder + * @param context The context + * @since 7.0 + */ + public static void setNotificationChannel(NotificationManager nm, NotificationCompat.Builder mNotifyBuilder, Context context) { + setNotificationChannel(nm, mNotifyBuilder, context, (String)null); + + } + + /** + * Sets the notification channel on a notification builder. Uses service properties to + * set properties of channel. + * @param nm The notification manager. + * @param mNotifyBuilder The notify builder + * @param context The context + * @param soundName The name of the sound to use for notifications on this channel. E.g. mysound.mp3. This feature is not yet implemented, but + * parameter is added now to scaffold compatibility with build daemon until implementation is complete. + * @since 7.0 + */ + public static void setNotificationChannel(NotificationManager nm, NotificationCompat.Builder mNotifyBuilder, Context context, String soundName) { + if (android.os.Build.VERSION.SDK_INT >= 26) { + try { + NotificationManager mNotificationManager = nm; + + String id = getServiceProperty("android.NotificationChannel.id", "cn1-channel", context); + + CharSequence name = getServiceProperty("android.NotificationChannel.name", "Notifications", context); + + String description = getServiceProperty("android.NotificationChannel.description", "Remote notifications", context); + + // NotificationManager.IMPORTANCE_LOW = 2 + // NotificationManager.IMPORTANCE_HIGH = 4 // <-- Minimum level to produce sound. + int importance = Integer.parseInt(getServiceProperty("android.NotificationChannel.importance", "4", context)); + // Note: Currently we use a single notification channel for the app, but if the app uses different kinds of + // push notifications, then this may not be sufficient. E.g. The app may send both silent push notifications + // and regular notifications - but their settings (e.g. sound) are all managed through one channel with + // same settings. + // TODO Add support for multiple channels. + // See https://github.com/codenameone/CodenameOne/issues/2583 + + Class clsNotificationChannel = Class.forName("android.app.NotificationChannel"); + //android.app.NotificationChannel mChannel = new android.app.NotificationChannel(id, name, importance); + Constructor constructor = clsNotificationChannel.getConstructor(java.lang.String.class, java.lang.CharSequence.class, int.class); + Object mChannel = constructor.newInstance(new Object[]{id, name, importance}); + + Method method = clsNotificationChannel.getMethod("setDescription", java.lang.String.class); + method.invoke(mChannel, new Object[]{description}); + //mChannel.setDescription(description); + + method = clsNotificationChannel.getMethod("enableLights", boolean.class); + method.invoke(mChannel, new Object[]{Boolean.parseBoolean(getServiceProperty("android.NotificationChannel.enableLights", "true", context))}); + //mChannel.enableLights(Boolean.parseBoolean(getServiceProperty("android.NotificationChannel.enableLights", "true", context))); + + method = clsNotificationChannel.getMethod("setLightColor", int.class); + method.invoke(mChannel, new Object[]{Integer.parseInt(getServiceProperty("android.NotificationChannel.lightColor", "" + android.graphics.Color.RED, context))}); + //mChannel.setLightColor(Integer.parseInt(getServiceProperty("android.NotificationChannel.lightColor", "" + android.graphics.Color.RED, context))); + + method = clsNotificationChannel.getMethod("enableVibration", boolean.class); + method.invoke(mChannel, new Object[]{Boolean.parseBoolean(getServiceProperty("android.NotificationChannel.enableVibration", "false", context))}); + //mChannel.enableVibration(Boolean.parseBoolean(getServiceProperty("android.NotificationChannel.enableVibration", "false", context))); + String vibrationPatternStr = getServiceProperty("android.NotificationChannel.vibrationPattern", null, context); + if (vibrationPatternStr != null) { + String[] parts = vibrationPatternStr.split(","); + int len = parts.length; + long[] pattern = new long[len]; + for (int i = 0; i < len; i++) { + pattern[i] = Long.parseLong(parts[i].trim()); + } + method = clsNotificationChannel.getMethod("setVibrationPattern", long[].class); + method.invoke(mChannel, new Object[]{pattern}); + //mChannel.setVibrationPattern(pattern); + } + + String soundUri = getServiceProperty("android.NotificationChannel.soundUri", null, context); + if (soundUri != null) { + Uri uri= android.net.Uri.parse(soundUri); + + android.media.AudioAttributes audioAttributes = new android.media.AudioAttributes.Builder() + .setContentType(android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(android.media.AudioAttributes.USAGE_NOTIFICATION) + .build(); + method = clsNotificationChannel.getMethod("setSound", android.net.Uri.class, android.media.AudioAttributes.class); + method.invoke(mChannel, new Object[]{uri, audioAttributes}); + } + + method = NotificationManager.class.getMethod("createNotificationChannel", clsNotificationChannel); + method.invoke(mNotificationManager, new Object[]{mChannel}); + //mNotificationManager.createNotificationChannel(mChannel); + try { + // For some reason I can't find the app-support-v4.jar for + // API 26 that includes this method so that I can compile in netbeans. + // So we use reflection... If someone coming after can find a newer version + // that has setChannelId(), please rip out this ugly reflection hack and + // replace it with a proper call to mNotifyBuilder.setChannelId(id) + mNotifyBuilder.getClass().getMethod("setChannelId", new Class[]{String.class}).invoke(mNotifyBuilder, new Object[]{id}); + } catch (Exception ex) { + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + } + //mNotifyBuilder.setChannelId(id); + } catch (ClassNotFoundException ex) { + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + } catch (NoSuchMethodException ex) { + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + } catch (SecurityException ex) { + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + } catch (IllegalAccessException ex) { + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + } catch (IllegalArgumentException ex) { + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + } catch (InvocationTargetException ex) { + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + } catch (InstantiationException ex) { + Logger.getLogger(AndroidImplementation.class.getName()).log(Level.SEVERE, null, ex); + } + //mNotifyBuilder.setChannelId(id); + } + + } + + public Object notifyStatusBar(String tickerText, String contentTitle, + String contentBody, boolean vibrate, boolean flashLights, Hashtable args) { + int id = getContext().getResources().getIdentifier("icon", "drawable", getContext().getApplicationInfo().packageName); + + NotificationManager notificationManager = (NotificationManager) getContext().getSystemService(Activity.NOTIFICATION_SERVICE); + + Intent notificationIntent = new Intent(); + notificationIntent.setComponent(activityComponentName); + PendingIntent contentIntent = createPendingIntent(getContext(), 0, notificationIntent); + + + NotificationCompat.Builder builder = new NotificationCompat.Builder(getContext()) + .setContentIntent(contentIntent) + .setSmallIcon(id) + .setContentTitle(contentTitle) + .setTicker(tickerText); + if(flashLights){ + builder.setLights(0, 1000, 1000); + } + if(vibrate){ + builder.setVibrate(new long[]{0, 100, 1000}); + } + if(args != null) { + Boolean b = (Boolean)args.get("persist"); + if(b != null && b.booleanValue()) { + builder.setAutoCancel(false); + builder.setOngoing(true); + } else { + builder.setAutoCancel(false); + } + } else { + builder.setAutoCancel(true); + } + Notification notification = builder.build(); + int notifyId = 10001; + notificationManager.notify("CN1", notifyId, notification); + return new Integer(notifyId); + } + + public boolean isContactsPermissionGranted() { + if (android.os.Build.VERSION.SDK_INT < 23) { + return true; + } + + if (android.support.v4.content.ContextCompat.checkSelfPermission(getContext(), + Manifest.permission.READ_CONTACTS) + != PackageManager.PERMISSION_GRANTED) { + return false; + } + return true; + } + + + @Override + public String[] getAllContacts(boolean withNumbers) { + if(!checkForPermission(Manifest.permission.READ_CONTACTS, "This is required to get the contacts")){ + return new String[]{}; + } + return AndroidContactsManager.getInstance().getContacts(getContext(), withNumbers); + } + + @Override + public com.codename1.calendar.LocalCalendarSource getLocalCalendarSource() { + if (calendarSource == null) { + calendarSource = new AndroidCalendarSource(getContext()); + } + return calendarSource; + } + + @Override + public Contact getContactById(String id) { + if(!checkForPermission(Manifest.permission.READ_CONTACTS, "This is required to get the contacts")){ + return null; + } + return AndroidContactsManager.getInstance().getContact(getContext(), id); + } + + @Override + public Contact getContactById(String id, boolean includesFullName, boolean includesPicture, + boolean includesNumbers, boolean includesEmail, boolean includeAddress){ + if(!checkForPermission(Manifest.permission.READ_CONTACTS, "This is required to get the contacts")){ + return null; + } + return AndroidContactsManager.getInstance().getContact(getContext(), id, includesFullName, includesPicture, + includesNumbers, includesEmail, includeAddress); + } + + @Override + public Contact[] getAllContacts(boolean withNumbers, boolean includesFullName, boolean includesPicture, boolean includesNumbers, boolean includesEmail, boolean includeAddress) { + if(!checkForPermission(Manifest.permission.READ_CONTACTS, "This is required to get the contacts")){ + return new Contact[]{}; + } + return AndroidContactsManager.getInstance().getAllContacts(getContext(), withNumbers, includesFullName, includesPicture, includesNumbers, includesEmail, includeAddress); + } + + @Override + public boolean isGetAllContactsFast() { + return true; + } + + @Override + public boolean isContactPickerSupported() { + // Both paths behind AndroidContactPicker exist on every version this + // port runs on: the system picker from Android 17, ACTION_PICK + // against the contacts provider before that. A device with no + // contacts app answers with ActivityNotFoundException, which the + // picker reports as an empty selection -- the same thing a cancelled + // pick reports, so callers need no separate case for it. + // + // Deliberately NOT PackageManager.resolveActivity. Review asked for + // it, to catch the kiosk device that has no contacts app at all, and + // it would answer the wrong question on every ordinary one: from + // Android 11 a resolve query is filtered by package visibility, so an + // app without a matching entry is told nothing handles the + // intent even where the picker works perfectly. LAUNCHING an implicit + // intent is not filtered, which is why the picker itself needs no + // and works regardless. Trading a false yes on a stripped + // device -- whose cost is a pick that reports empty, exactly as a + // cancelled one does -- for a false no on every modern device, whose + // cost is a working feature hidden with no way to find out why, is a + // bad trade. + return getActivity() != null; + } + + @Override + public void pickContacts(int requestedFields, boolean multiSelect, + int selectionLimit, boolean requireAllRequestedFields, + ActionListener response) { + if (getActivity() == null) { + fireContactPickerResult(response, new Contact[0]); + return; + } + if (editInProgress()) { + stopEditing(true); + } + // Deliberately no checkForPermission call. The whole point of the + // picker is that neither path needs READ_CONTACTS, and asking for it + // here would put the permission back into the manifest and in front + // of the user for a flow that does not need it. + AndroidContactPicker.pick(getContext(), requestedFields, multiSelect, + selectionLimit, requireAllRequestedFields, + new ContactPickerResult(response)); + } + + /** + * Hands a picker selection back to the listener that asked for it. + */ + private final class ContactPickerResult implements AndroidContactPicker.Result { + private final ActionListener response; + + ContactPickerResult(ActionListener response) { + this.response = response; + } + + @Override + public void picked(Contact[] picked) { + fireContactPickerResult(response, picked); + } + } + + public String createContact(String firstName, String surname, String officePhone, String homePhone, String cellPhone, String email) { + if(!checkForPermission(Manifest.permission.WRITE_CONTACTS, "This is required to create a contact")){ + return null; + } + return AndroidContactsManager.getInstance().createContact(getContext(), firstName, surname, officePhone, homePhone, cellPhone, email); + } + + public boolean deleteContact(String id) { + if(!checkForPermission(Manifest.permission.WRITE_CONTACTS, "This is required to delete a contact")){ + return false; + } + return AndroidContactsManager.getInstance().deleteContact(getContext(), id); + } + + @Override + public boolean isNativeShareSupported() { + return true; + } + + @Override + public boolean isNativeInAppReviewSupported() { + // True only when the Play In-App Review library was bundled, which the + // AndroidGradleBuilder does when the app references the app-review API. + return getActivity() != null && AppReviewSupport.isSupported(); + } + + @Override + public void requestNativeInAppReview(final SuccessCallback done) { + final CodenameOneActivity activity = getActivity(); + if (activity == null || !AppReviewSupport.isSupported()) { + if (done != null) { + done.onSucess(Boolean.FALSE); + } + return; + } + activity.runOnUiThread(new Runnable() { + public void run() { + AppReviewSupport.requestReview(activity, done); + } + }); + } + + @Override + public void share(String text, String image, String mimeType, Rectangle sourceRect){ + share(text, image, mimeType, sourceRect, null); + } + + @Override + public void share(String text, String image, String mimeType, Rectangle sourceRect, final com.codename1.share.ShareResultListener listener) { + /*if(!checkForPermission(Manifest.permission.READ_PHONE_STATE, "This is required to perform share")){ + return; + }*/ + Intent shareIntent = new Intent(android.content.Intent.ACTION_SEND); + if(image == null){ + if (text.startsWith("file:") && mimeType != null && new com.codename1.io.File(text).exists()) { + shareIntent.setType(mimeType); + shareIntent.putExtra(Intent.EXTRA_STREAM, Uri.parse(fixAttachmentPath(text))); + } else { + shareIntent.setType("text/plain"); + shareIntent.putExtra(android.content.Intent.EXTRA_TEXT, text); + } + }else{ + shareIntent.setType(mimeType); + shareIntent.putExtra(Intent.EXTRA_STREAM, Uri.parse(fixAttachmentPath(image))); + shareIntent.putExtra(Intent.EXTRA_TEXT, text); + } + + Intent chooser; + try { + if (listener != null && android.os.Build.VERSION.SDK_INT >= 22) { + chooser = buildShareChooserWithCallback(shareIntent, listener); + } else { + chooser = Intent.createChooser(shareIntent, "Share with..."); + } + } catch (Throwable t) { + // Fall back to the plain chooser, then synthesize a listener + // result so the app doesn't hang on an unfulfilled callback. + chooser = Intent.createChooser(shareIntent, "Share with..."); + if (listener != null) { + listener.onResult(com.codename1.share.ShareResult.sharedTo(null)); + } + } + getContext().startActivity(chooser); + } + + // ONE receiver for the process, and one listener held at a time. + // + // A receiver per share leaked every cancelled one. It is unregistered from + // inside onReceive, and Android sends nothing when the chooser is + // dismissed -- there is no public dismissal signal -- so a cancelled share + // left its receiver registered on the application context, holding the + // listener and, through it, the button and the form it is on. Each cancel + // added another, for the life of the process, and a share button is + // exactly the kind of control a user opens and backs out of repeatedly. + // + // Reusing one receiver bounds that at a single retained listener: the next + // share replaces the one a dismissal left behind. It cannot be driven to + // zero from here, because knowing the chooser was dismissed is the thing + // Android does not tell us. + // + // Instance fields, not static: there is one implementation per process, + // the receiver belongs to it, and a lazily initialised static is a + // different claim -- one SpotBugs reads as a threading bug, correctly, + // because nothing here would make it safe if it were true. + // + // pendingShareListener is written from the Codename One EDT and read on + // the Android main thread, which is why it is volatile. That is a native + // boundary crossing, not core framework code. + private BroadcastReceiver shareChooserReceiver; + + private String shareChooserAction; + + private volatile com.codename1.share.ShareResultListener pendingShareListener; + + @TargetApi(22) + private Intent buildShareChooserWithCallback(Intent shareIntent, final com.codename1.share.ShareResultListener listener) { + final Context appCtx = getContext().getApplicationContext(); + // The listener this chooser is for. Set before the receiver can + // possibly fire, and replacing whatever a dismissed chooser left. + pendingShareListener = listener; + if (shareChooserReceiver != null) { + // Already registered and listening on the same action, so there is + // nothing to build but the PendingIntent below. + return chooserFor(appCtx, shareIntent, shareChooserAction); + } + final String action = appCtx.getPackageName() + ".CN1_SHARE_CHOSEN"; + shareChooserAction = action; + // The receiver fires once when the user picks a target. Android + // does not expose a dismissal signal for the chooser, so the + // listener simply does not fire on user-cancel (see comment + // further down). + BroadcastReceiver receiver = new BroadcastReceiver() { + @Override + public void onReceive(Context ctx, Intent intent) { + // Taken, so a repeat broadcast cannot deliver twice. The + // receiver stays registered for the next share. + com.codename1.share.ShareResultListener target = pendingShareListener; + pendingShareListener = null; + if (target == null) { + return; + } + String pkg = null; + try { + android.content.ComponentName cn = intent.getParcelableExtra(Intent.EXTRA_CHOSEN_COMPONENT); + if (cn != null) pkg = cn.getPackageName(); + } catch (Throwable ignore) {} + target.onResult(com.codename1.share.ShareResult.sharedTo(pkg)); + } + }; + IntentFilter filter = new IntentFilter(action); + boolean registered = false; + if (android.os.Build.VERSION.SDK_INT >= 33) { + // RECEIVER_EXPORTED = 0x2 -- constant exists at runtime on + // API 33+ but is not present in older android.jar build deps, + // so call the 3-arg overload via reflection to stay source- + // compatible. + try { + java.lang.reflect.Method m = Context.class.getMethod( + "registerReceiver", BroadcastReceiver.class, IntentFilter.class, int.class); + m.invoke(appCtx, receiver, filter, Integer.valueOf(0x2)); + registered = true; + } catch (Throwable ignore) {} + } + if (!registered) { + appCtx.registerReceiver(receiver, filter); + } + // Recorded only once it is really listening, so a registration that + // threw is retried by the next share rather than skipped for ever. + shareChooserReceiver = receiver; + // Android's chooser IntentSender callback never fires on + // dismissal: there is no public API to observe a user-cancel. + // Apps that need a dismissal signal must use Activity-resume. + + return chooserFor(appCtx, shareIntent, action); + } + + /// The chooser Intent itself, wrapping a broadcast PendingIntent on this + /// action. + /// + /// Split out because it is built on every share while the receiver behind + /// it is built once. FLAG_UPDATE_CURRENT is what makes the fixed action + /// safe to reuse: the same PendingIntent is handed back with this + /// chooser's extras, and only one chooser is ever up at a time. + @TargetApi(22) + private Intent chooserFor(Context appCtx, Intent shareIntent, String action) { + Intent pi = new Intent(action).setPackage(appCtx.getPackageName()); + int piFlags = PendingIntent.FLAG_UPDATE_CURRENT; + if (android.os.Build.VERSION.SDK_INT >= 31) { + // FLAG_MUTABLE was introduced in API 31; its numeric value + // (0x02000000) is referenced here directly so the source + // still compiles against pre-31 android.jar build deps. + piFlags |= 0x02000000; + } + PendingIntent pendingIntent = PendingIntent.getBroadcast(appCtx, 0, pi, piFlags); + return Intent.createChooser(shareIntent, "Share with...", pendingIntent.getIntentSender()); + } + + /// Printing uses the Android print framework which requires API 19 + /// and a foreground activity to host the print dialog. + @Override + public boolean isPrintingSupported() { + return android.os.Build.VERSION.SDK_INT >= 19 && getActivity() != null; + } + + /// Print through the Android print framework. PDF files are streamed + /// verbatim into a `android.print.PrintDocumentAdapter`; images go + /// through the support library `PrintHelper` which scales them to the + /// page. + /// + /// Outcome reporting is best effort: the PDF path polls the returned + /// `android.print.PrintJob` and treats a queued/started job as + /// completed since Android offers no callback for the terminal job + /// state once it was handed to the print service. The image path + /// reports completed when `PrintHelper` finishes because it can't + /// distinguish a dismissed dialog from a printed page. + @Override + public void print(final String filePath, final String mimeType, final com.codename1.printing.PrintResultListener listener) { + final PrintResultDispatcher dispatcher = new PrintResultDispatcher(listener); + if (!isPrintingSupported()) { + dispatcher.fire(com.codename1.printing.PrintResult.failed( + "Printing requires Android 4.4 or newer and a foreground activity")); + return; + } + if (filePath == null) { + dispatcher.fire(com.codename1.printing.PrintResult.failed("No file to print")); + return; + } + final File file = new File(removeFilePrefix(filePath)); + if (!file.exists()) { + dispatcher.fire(com.codename1.printing.PrintResult.failed("File not found: " + filePath)); + return; + } + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + try { + // PrintSupport touches android.print which only exists + // on API 19+; the isPrintingSupported() gate above keeps + // the class from loading on older devices. + PrintSupport.startPrint(getActivity(), file, mimeType, dispatcher); + } catch (Throwable t) { + dispatcher.fire(com.codename1.printing.PrintResult.failed( + "Failed to start print job: " + t)); + } + } + }); + } + + /// Delivers a [com.codename1.printing.PrintResult] to the listener at + /// most once. The listener may be null and results may arrive from any + /// thread; `Display` moves the callback onto the EDT. + private static final class PrintResultDispatcher { + private final com.codename1.printing.PrintResultListener listener; + private boolean fired; + + PrintResultDispatcher(com.codename1.printing.PrintResultListener listener) { + this.listener = listener; + } + + void fire(com.codename1.printing.PrintResult result) { + synchronized (this) { + if (fired) { + return; + } + fired = true; + } + if (listener != null) { + listener.onResult(result); + } + } + } + + /// All android.print framework access lives in this class so the + /// classes it references are only loaded behind the API 19 check in + /// [#print]. + @TargetApi(19) + private static final class PrintSupport { + + private static final int JOB_PENDING = 0; + private static final int JOB_COMPLETED = 1; + private static final int JOB_CANCELLED = 2; + private static final int JOB_FAILED = 3; + + /// How long the poller waits for the print dialog/job to reach a + /// terminal state before giving up. + private static final long POLL_TIMEOUT = 15 * 60 * 1000L; + private static final long POLL_INTERVAL = 500; + + /// Must run on the UI thread: `PrintManager.print` and + /// `PrintHelper.printBitmap` both require it. + static void startPrint(Activity activity, File file, String mimeType, PrintResultDispatcher dispatcher) { + String jobName = file.getName(); + if ("application/pdf".equalsIgnoreCase(mimeType)) { + android.print.PrintManager printManager = + (android.print.PrintManager) activity.getSystemService(Context.PRINT_SERVICE); + if (printManager == null) { + dispatcher.fire(com.codename1.printing.PrintResult.failed("Print service unavailable")); + return; + } + android.print.PrintJob job = printManager.print(jobName, + new PdfFilePrintAdapter(jobName, file), null); + pollPrintJob(activity, job, dispatcher); + } else if (mimeType != null && mimeType.startsWith("image/")) { + printImage(activity, file, jobName, dispatcher); + } else { + dispatcher.fire(com.codename1.printing.PrintResult.failed( + "Unsupported print document type: " + mimeType)); + } + } + + private static void printImage(Activity activity, File file, String jobName, + final PrintResultDispatcher dispatcher) { + Bitmap bitmap = BitmapFactory.decodeFile(file.getAbsolutePath()); + if (bitmap == null) { + dispatcher.fire(com.codename1.printing.PrintResult.failed( + "Unable to decode image for printing")); + return; + } + android.support.v4.print.PrintHelper helper = new android.support.v4.print.PrintHelper(activity); + helper.setScaleMode(android.support.v4.print.PrintHelper.SCALE_MODE_FIT); + helper.printBitmap(jobName, bitmap, new android.support.v4.print.PrintHelper.OnPrintFinishCallback() { + @Override + public void onFinish() { + // PrintHelper fires onFinish when the print flow ends + // without exposing whether the user printed or + // dismissed the dialog; report completed best effort. + dispatcher.fire(com.codename1.printing.PrintResult.completed()); + } + }); + } + + /// Watches the print job from a background thread and reports the + /// first terminal state. The job object must only be queried on + /// the UI thread, so every tick bounces through `runOnUiThread`. + private static void pollPrintJob(final Activity activity, final android.print.PrintJob job, + final PrintResultDispatcher dispatcher) { + Thread poller = new Thread(new Runnable() { + @Override + public void run() { + long deadline = System.currentTimeMillis() + POLL_TIMEOUT; + while (System.currentTimeMillis() < deadline) { + try { + Thread.sleep(POLL_INTERVAL); + } catch (InterruptedException ignore) { + } + final int[] state = new int[]{JOB_PENDING}; + final boolean[] done = new boolean[1]; + final Object lock = new Object(); + activity.runOnUiThread(new Runnable() { + @Override + public void run() { + int s = JOB_PENDING; + try { + if (job.isCancelled()) { + s = JOB_CANCELLED; + } else if (job.isFailed()) { + s = JOB_FAILED; + } else if (job.isCompleted()) { + s = JOB_COMPLETED; + } else if (job.isQueued() || job.isStarted() || job.isBlocked()) { + // The dialog phase is over and the + // job belongs to the print service; + // that is as "completed" as Android + // lets us observe reliably. + s = JOB_COMPLETED; + } + } catch (Throwable t) { + s = JOB_FAILED; + } + synchronized (lock) { + state[0] = s; + done[0] = true; + lock.notifyAll(); + } + } + }); + synchronized (lock) { + long waitUntil = System.currentTimeMillis() + 5000; + while (!done[0] && System.currentTimeMillis() < waitUntil) { + try { + lock.wait(POLL_INTERVAL); + } catch (InterruptedException ignore) { + } + } + if (!done[0]) { + // UI thread didn't get to us; try again on + // the next tick until the deadline passes. + continue; + } + } + switch (state[0]) { + case JOB_COMPLETED: + dispatcher.fire(com.codename1.printing.PrintResult.completed()); + return; + case JOB_CANCELLED: + dispatcher.fire(com.codename1.printing.PrintResult.cancelled()); + return; + case JOB_FAILED: + dispatcher.fire(com.codename1.printing.PrintResult.failed("Print job failed")); + return; + default: + // still in the dialog phase, keep polling + } + } + dispatcher.fire(com.codename1.printing.PrintResult.failed( + "Timed out waiting for the print job status")); + } + }, "CN1PrintJobPoller"); + poller.setDaemon(true); + poller.start(); + } + + /// Streams an existing PDF file into the print system unchanged. + /// Layout/write failures are routed through the framework + /// callbacks which fail the print job; the poller in + /// [#pollPrintJob] then reports the failure to the listener, so + /// the dispatcher still fires exactly once. + private static final class PdfFilePrintAdapter extends android.print.PrintDocumentAdapter { + private final String jobName; + private final File file; + + PdfFilePrintAdapter(String jobName, File file) { + this.jobName = jobName; + this.file = file; + } + + @Override + public void onLayout(android.print.PrintAttributes oldAttributes, + android.print.PrintAttributes newAttributes, + android.os.CancellationSignal cancellationSignal, + LayoutResultCallback callback, Bundle extras) { + if (cancellationSignal != null && cancellationSignal.isCanceled()) { + callback.onLayoutCancelled(); + return; + } + try { + android.print.PrintDocumentInfo info = new android.print.PrintDocumentInfo.Builder(jobName) + .setContentType(android.print.PrintDocumentInfo.CONTENT_TYPE_DOCUMENT) + .setPageCount(android.print.PrintDocumentInfo.PAGE_COUNT_UNKNOWN) + .build(); + callback.onLayoutFinished(info, !newAttributes.equals(oldAttributes)); + } catch (Throwable t) { + callback.onLayoutFailed(t.toString()); + } + } + + @Override + public void onWrite(android.print.PageRange[] pages, + android.os.ParcelFileDescriptor destination, + android.os.CancellationSignal cancellationSignal, + WriteResultCallback callback) { + FileInputStream in = null; + FileOutputStream out = null; + try { + in = new FileInputStream(file); + out = new FileOutputStream(destination.getFileDescriptor()); + byte[] buffer = new byte[8192]; + int count; + while ((count = in.read(buffer)) > -1) { + if (cancellationSignal != null && cancellationSignal.isCanceled()) { + callback.onWriteCancelled(); + return; + } + out.write(buffer, 0, count); + } + callback.onWriteFinished(new android.print.PageRange[]{android.print.PageRange.ALL_PAGES}); + } catch (Throwable t) { + callback.onWriteFailed(t.toString()); + } finally { + if (in != null) { + try { + in.close(); + } catch (Throwable ignore) { + } + } + if (out != null) { + try { + out.close(); + } catch (Throwable ignore) { + } + } + } + } + } + } + + /** + * @inheritDoc + */ + public String getPlatformName() { + return "and"; + } + + /** + * Snapshot of the recent process logcat for crash protection. Since + * Android 4.1 (API 16) apps can only read their own process log + * without the READ_LOGS permission, which is exactly what we want. + * Returns the last ~200 lines (capped at 32 KB). + */ + @Override + public String getNativeLogSnapshot() { + java.io.BufferedReader reader = null; + Process proc = null; + try { + proc = Runtime.getRuntime().exec(new String[]{ + "logcat", "-d", "-t", "200", "-v", "threadtime"}); + reader = new java.io.BufferedReader( + new java.io.InputStreamReader(proc.getInputStream(), "UTF-8")); + StringBuilder sb = new StringBuilder(8192); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append('\n'); + if (sb.length() > 32 * 1024) { + break; + } + } + return sb.length() == 0 ? null : sb.toString(); + } catch (Throwable ignored) { + // logcat unavailable (very old Android, locked-down ROM, + // etc.) -- crash protection still works, just without the + // device log context. + return null; + } finally { + if (reader != null) { + try { reader.close(); } catch (java.io.IOException ignored) { } + } + if (proc != null) { + try { proc.destroy(); } catch (Throwable ignored) { } + } + } + } + + /** + * @inheritDoc + */ + public String[] getPlatformOverrides() { + if (isWatch()) { + return new String[]{"watch", "android", "android-watch"}; + } + if (isTV()) { + return new String[]{"tv", "android", "android-tv"}; + } + if (isTablet()) { + return new String[]{"tablet", "android", "android-tab"}; + } else { + return new String[]{"phone", "android", "android-phone"}; + } + } + + /** + * @inheritDoc + */ + public void copyToClipboard(final Object obj) { + super.copyToClipboard(obj); + if (getActivity() == null) { + return; + } + getActivity().runOnUiThread(new Runnable() { + @Override + public void run() { + int sdk = android.os.Build.VERSION.SDK_INT; + if (sdk < 11) { + android.text.ClipboardManager clipboard = (android.text.ClipboardManager) getActivity().getSystemService(Context.CLIPBOARD_SERVICE); + clipboard.setText(obj.toString()); + // Afterwards, as in the branch below: a clip that was never published has + // not replaced the one the system is still holding, and unpinning that one + // first left its files reclaimable while it was still there to be pasted. + clipboardHolds(0); + } else { + android.content.ClipboardManager clipboard = (android.content.ClipboardManager) getActivity().getSystemService(Context.CLIPBOARD_SERVICE); + android.content.ClipData clip; + long staged = 0; + boolean assembled = false; + if (obj instanceof ClipboardContent) { + AssembledClip built = clipDataFor((ClipboardContent) obj); + clip = built == null ? null : built.getData(); + staged = built == null ? 0 : built.getClip(); + assembled = true; + if (clip == null) { + // A copy of nothing is an empty clipboard, which is a thing the user + // asked for and can paste. A *drag* of nothing is not: there the null + // refuses to start, because a drag that carries nothing still lands + // somewhere and tells that receiver it succeeded. + clip = ClipData.newPlainText("Codename One", ""); + } + } else { + // Nothing of ours is staged for a plain text clip. + clip = ClipData.newPlainText("Codename One", obj.toString()); + } + watchPrimaryClip(clipboard); + // Pinned for the length of the call, held only if it returns. setPrimaryClip + // can throw -- a payload past the Binder transaction limit is the usual way + // -- and switching the hold beforehand handed the *old* clip's files to + // reclamation while the system was still holding that clip, pinned the ones + // that never reached the clipboard in their place, and left a callback + // counted that would never arrive. The pin in between is what keeps the new + // clip's own files from being reclaimed in the window this opens. + clipboardPublishing(staged); + boolean published = false; + try { + clipboard.setPrimaryClip(clip); + published = true; + } finally { + clipboardPublished(staged, published); + if (assembled) { + // Taken over by the clipboard, or given up on. Either way this + // assembly is no longer one nothing has claimed. + endStagingClip(staged); + } + } + } + } + }); + } + + /// Builds the Android clip that publishes a `ClipboardContent`, for a clipboard copy and + /// for a native drag alike -- both hand another application the same thing, so both go + /// through the same conversion, including the file provider URIs that let the receiving + /// application read generated image bytes. + /// + /// #### Parameters + /// + /// - `content`: the representations to publish + /// + /// #### Returns + /// + /// the clip, or null when the content produced no representation at all + AssembledClip clipDataFor(ClipboardContent content) { + // Held here and handed down, never read back off the field. A clipboard copy runs + // on the Android UI thread and a drag on the Codename One event dispatch thread, so + // two assemblies can overlap -- and one reading the field mid-way filed its + // remaining files under the other's id, which split one clip across two and left + // the half nobody pinned free to be deleted while the clip still referenced it. + final long clip = beginStagingClip(); + // Every read this assembly makes goes through here; see Assembly for why it is not the + // content's own memory of what its providers produced. + Assembly assembly = new Assembly(content); + int sdk = android.os.Build.VERSION.SDK_INT; + List mimeTypes = new ArrayList(); + List items = new ArrayList(); + String plain = assembly.text(ClipboardContent.MIME_TEXT); + String html = assembly.text(ClipboardContent.MIME_HTML); + // A clip carries one text payload. Where the content has no text/plain but does have + // some other text representation -- markdown, AsciiDoc, a URI list -- that one is the + // payload, since publishing an empty clip instead would lose it outright. + String primaryTextMime = plain != null ? ClipboardContent.MIME_TEXT : null; + // Not when there is HTML: that is already the payload, and the plain text beside it is + // derived from the markup below rather than searched for among the other + // representations, which would put an unrelated one under the HTML. + if (plain == null && html == null) { + String[] advertised = content.getMimeTypes(); + for (int iter = 0; iter < advertised.length && plain == null; iter++) { + if (!advertised[iter].startsWith("text/")) { + // Text types only, however the value happens to be carried. A String under + // application/json -- or under an application's own type -- is that type's + // encoding and not a reading the source offered as text, and publishing it + // as the clip's text let a text-only application paste a representation + // nobody advertised to it. Nothing is lost by refusing: a String under a + // type that is not text travels as a typed content URI like any other + // representation, under its own name. The file list is covered by the same + // test, since that is not a text type either. + // + // The types getMimeTypes answers with are normalized to lower case, so this + // is an ASCII comparison against an ASCII constant and no locale enters it. + continue; + } + String value = assembly.text(advertised[iter]); + if (value != null) { + plain = value; + primaryTextMime = advertised[iter]; + } + } + } + // The types are recorded here, but the text does not become an item of its own yet. A + // clip item is a dragged *object*, so a text item beside a file item is two things + // being dragged at once, and a receiver that imports everything takes the document + // *and* a stray piece of text instead of choosing the best form of one thing. Where + // the clip carries a URI, the text rides on it -- see attachCarriedText below. + boolean carriesHtml = sdk >= 16 && html != null; + if (carriesHtml && plain == null) { + // Android *requires* it: ClipData.Item refuses HTML with no plain text beside it, + // and threw IllegalArgumentException out of the thread that was building the clip + // -- so content offering nothing but MIME_HTML crashed a copy and silently failed + // a drag. Rendered from the markup rather than being the markup, which would show + // every receiver the tags. + plain = htmlToPlainText(html); + } + if (carriesHtml) { + mimeTypes.add(ClipboardContent.MIME_TEXT); + mimeTypes.add(ClipboardContent.MIME_HTML); + } else if (plain != null) { + mimeTypes.add(ClipboardContent.MIME_TEXT); + if (primaryTextMime != null && !mimeTypes.contains(primaryTextMime)) { + mimeTypes.add(primaryTextMime); + } + } + // One pass at a time. Together under a single catch, a failure in the first abandoned + // the two after it as well, so a clip whose image could not be written went out + // without the document and the typed representations it also had. + try { + addBinaryContent(assembly, mimeTypes, items, clip); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + try { + addPublishedUris(assembly, mimeTypes, items, clip); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + try { + addRemainingRepresentations(assembly, plain, mimeTypes, items, clip); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + if (carriesHtml || plain != null) { + attachCarriedText(items, plain, carriesHtml ? html : null); + } + if (items.isEmpty()) { + // Nothing was produced. Every representation this content offered is a provider that + // answered null or threw, which ClipboardDataProvider explicitly permits -- so there + // is no clip, and the callers decide what that means. Answering with empty text + // instead replaced the payload with a different one: a drag offering only + // application/pdf reported success and let another application accept blank text. + return new AssembledClip(null, clip); + } + // Built from the union of the types, not by appending to a text clip. ClipData.addItem + // does not add the item's type to the description, so a clip assembled that way + // describes itself as text only -- and both a Codename One drop target filtering on + // MIME_FILE and an external receiver choosing a representation read the description. + ClipData data = new ClipData("Codename One", + mimeTypes.toArray(new String[mimeTypes.size()]), items.get(0)); + for (int iter = 1; iter < items.size(); iter++) { + data.addItem(items.get(iter)); + } + return new AssembledClip(data, clip); + } + + /// A clip and the assembly that built it. + /// + /// The id travels with the clip because that is the only way its caller can say which + /// assembly the clipboard or the drag now holds: a field read afterwards answers about + /// whichever assembly began most recently, and two of them can be in flight at once. + static final class AssembledClip { + /// The clip, or null when the content produced nothing that could be published. + private final ClipData data; + private final long clip; + + AssembledClip(ClipData data, long clip) { + this.data = data; + this.clip = clip; + } + + ClipData getData() { + return data; + } + + long getClip() { + return clip; + } + } + + // ------------------------------------------------------------------------------------ + // Native drag and drop. See AndroidNativeDragAndDrop; the payload is the same ClipData a + // copy publishes, which is why a drag out of the application lands in another application + // exactly as a paste would. + // ------------------------------------------------------------------------------------ + + @Override + public boolean isNativeDragAndDropSupported() { + return AndroidNativeDragAndDrop.isSupported(); + } + + @Override + public boolean isNativeDragOutsideApplicationSupported() { + return AndroidNativeDragAndDrop.isOutsideApplicationSupported(); + } + + @Override + public boolean startNativeDrag(com.codename1.ui.NativeDragOperation op) { + return AndroidNativeDragAndDrop.startDrag(this, op); + } + + @Override + public void cancelNativeDrag() { + AndroidNativeDragAndDrop.cancelDrag(); + } + + /** + * Collects the image bytes and file references carried by the ClipboardContent as items and + * MIME types, exposing binary content as FileProvider content:// URIs. The caller assembles + * the ClipData from the union of everything collected here and the text types, because + * ClipData.addItem cannot widen a description that already exists. + */ + private void addBinaryContent(Assembly assembly, List mimeTypes, + List items, long clip) throws IOException { + String authority = getContext().getPackageName() + ".provider"; + + // The files first, then the byte-backed representations. Android's ClipData.Item holds + // exactly one Uri, so two representations that are both bytes cannot be one item -- the + // platform has no way to say "another reading of the same object" for them, only for + // the text and markup that attachCarriedText rides on the item below. Publishing them + // is still right: they are what the description advertises, and dropping them would + // refuse the very target that accepted the hover on one. What order fixes is which + // object a receiver reading only the first item takes -- the document, not its + // thumbnail. + // + // It is also what puts the carried text on the document rather than on the thumbnail. + + // File references: MIME_FILE may be a single String or a String[] + Object fileData = assembly.value(ClipboardContent.MIME_FILE); + if (fileData != null) { + String[] paths; + if (fileData instanceof String[]) { + paths = (String[]) fileData; + } else { + paths = new String[]{ fileData.toString() }; + } + for (int i = 0; i < paths.length; i++) { + String pathOrUri = paths[i]; + if (pathOrUri == null || pathOrUri.length() == 0) { + continue; + } + // Each file on its own. A path outside the roots the file provider was + // configured with throws, and one throwing on the second of three used to + // abandon the third as well *and* skip every representation after the file + // loop -- so the clip went out holding one file, silently, and the drag + // reported success. + try { + Uri u; + if (hasScheme(pathOrUri, "content:")) { + u = Uri.parse(pathOrUri); + } else { + File file = hasScheme(pathOrUri, "file:") + ? new File(Uri.parse(pathOrUri).getPath()) + : new File(pathOrUri); + u = shareableUriFor(file, authority, clip); + } + if (!mimeTypes.contains("text/uri-list")) { + mimeTypes.add("text/uri-list"); + } + // And whatever the document actually is. A receiver in another application + // reads the description and nothing else while the drag hovers, so a PDF + // dragged out of here described only as a URI list was refused by every + // target that filters on application/pdf -- the type was there for the + // asking on the URI, and only this side can ask it in time. The alias the + // hover adds locally cannot help them; it never leaves this process. + // + // Only a type the resolver actually knows. octet-stream is what a provider + // answers when it has nothing to say, and advertising that would tell a + // receiver the clip holds a type it cannot use. + String resolved = bareMimeType( + getContext().getContentResolver().getType(u)); + if (resolved != null && resolved.length() > 0 + && !"application/octet-stream".equals(resolved) + && !mimeTypes.contains(resolved)) { + mimeTypes.add(resolved); + } + items.add(new ClipData.Item(u)); + } catch (Throwable t) { + // Absent rather than advertised: nothing named it a type of its own, so + // no receiver is told the clip holds a file it does not. + com.codename1.io.Log.e(t); + } + } + } + + // Image bytes: prefer PNG, then JPEG, then GIF + String imageMime = null; + byte[] imageBytes = null; + String imageExt = null; + imageBytes = assembly.bytes(ClipboardContent.MIME_PNG); + if (imageBytes != null) { + imageMime = ClipboardContent.MIME_PNG; + imageExt = "png"; + } else { + imageBytes = assembly.bytes(ClipboardContent.MIME_JPEG); + if (imageBytes != null) { + imageMime = ClipboardContent.MIME_JPEG; + imageExt = "jpg"; + } else { + imageBytes = assembly.bytes(ClipboardContent.MIME_GIF); + if (imageBytes != null) { + imageMime = ClipboardContent.MIME_GIF; + imageExt = "gif"; + } + } + } + if (imageBytes != null) { + try { + Uri imageUri = writeAsProviderUri(imageBytes, imageExt, imageMime, clip); + if (imageUri != null) { + if (!mimeTypes.contains(imageMime)) { + mimeTypes.add(imageMime); + } + items.add(new ClipData.Item(imageUri)); + } + } catch (Throwable t) { + // On its own, so a picture that cannot be written does not take the files + // and the other representations with it. + com.codename1.io.Log.e(t); + } + } + } + + /// The text of an HTML fragment, for the plain text Android requires beside it. + /// + /// Empty rather than null when the markup renders to nothing: an item may carry empty text + /// with its HTML, and may not carry none. + private static String htmlToPlainText(String html) { + try { + CharSequence text = android.os.Build.VERSION.SDK_INT >= 24 + ? android.text.Html.fromHtml(html, android.text.Html.FROM_HTML_MODE_LEGACY) + : android.text.Html.fromHtml(html); + return text == null ? "" : text.toString(); + } catch (Throwable t) { + // Markup this platform will not parse still has to travel; the HTML is the payload + // and the text beside it is what Android asks for, not what the clip is for. + com.codename1.io.Log.e(t); + return ""; + } + } + + /// Puts the URIs a text/uri-list names on the clip as URIs. + /// + /// A URI is what an Android receiver reads off `ClipData.Item#getUri()`, and a link has + /// nothing else to be read off. Left to the passes around this one a uri-list became + /// carried text, or -- where the clip had text already -- a content URI holding the list + /// as a document; either way a receiver that took the clip because it advertised + /// text/uri-list found no URI on it at all. + /// + /// One item per URI, because an item is a dragged object and a list of three links is + /// three of them. The clip's text still rides on the first, as it does on a file. + private void addPublishedUris(Assembly assembly, List mimeTypes, + List items, long clip) { + String list = assembly.text(ClipboardContent.MIME_URI_LIST); + if (list == null) { + return; + } + // The files the source published, which the clip is already carrying: each went onto + // it as a content URI this application minted, so the list's own spelling of the same + // document -- a path, or a file: URI of it -- would drag that document a second time. + // + // Compared against those paths rather than against the minted URIs, which are not + // equal to anything the source wrote. Entry by entry, too: returning on the first file + // threw away every *other* line, so a document published beside its own web address + // advertised text/uri-list and delivered the document alone. + List alreadyCarried = new ArrayList(); + Object files = assembly.value(ClipboardContent.MIME_FILE); + if (files instanceof String[]) { + String[] paths = (String[]) files; + for (int iter = 0; iter < paths.length; iter++) { + if (paths[iter] != null) { + alreadyCarried.add(publishedUriKey(paths[iter])); + } + } + } else if (files instanceof String) { + alreadyCarried.add(publishedUriKey((String) files)); + } + boolean carriesPublishedFile = false; + for (int iter = 0; iter < items.size(); iter++) { + Uri carried = items.get(iter).getUri(); + // A *generated* URI is not one of the source's. It carries a representation's + // bytes -- an image, a document this application encoded -- and a reader filters + // it out precisely because the source never published it as a URI. + if (carried != null && !isGeneratedClipFile(carried)) { + carriesPublishedFile = true; + break; + } + } + boolean any = false; + String[] lines = list.split("\n"); + for (int iter = 0; iter < lines.length; iter++) { + String line = lines[iter].trim(); + // RFC 2483: a line opening with a hash is a comment, not a URI. + if (line.length() == 0 || line.charAt(0) == '#') { + continue; + } + if (alreadyCarried.contains(publishedUriKey(line))) { + continue; + } + Uri published = publishableUri(line, clip); + if (published == null) { + continue; + } + items.add(new ClipData.Item(published)); + any = true; + } + // Declared when the clip can produce one: the entries just added, the published files + // a reader builds the list back out of, or both. + if (any || carriesPublishedFile) { + declareUriList(mimeTypes); + } + } + + /// One entry of a URI list, in a form the clip may leave this process with, or null when + /// it cannot be published at all. + /// + /// A file: URI is the case that needs the work. Android refuses to let a clip carrying one + /// cross the application boundary -- prepareToLeaveProcess throws FileUriExposedException + /// from API 24 -- so a copy of a list naming a local document threw out of the UI thread it + /// was made on, and a global drag of one never started. It goes through the file provider + /// exactly as the file representation does, which is also what makes it *readable* by the + /// receiver rather than merely legal. + /// + /// Anything else -- an http address, a mailto:, another application's content URI -- is + /// already publishable and travels as it was written. + private Uri publishableUri(String line, long clip) { + if (!hasScheme(line, "file:")) { + return Uri.parse(line); + } + String path = Uri.parse(line).getPath(); + if (path == null || path.length() == 0) { + return null; + } + try { + return shareableUriFor(new File(path), + getContext().getPackageName() + ".provider", clip); + } catch (Throwable t) { + // Absent rather than advertised, as the file representation does it: a document + // outside the roots the provider was configured with cannot be handed over, and + // naming it anyway tells the receiver the clip holds something it will not get. + com.codename1.io.Log.e(t); + return null; + } + } + + /// What two spellings of one file have in common. + /// + /// ClipboardContent's file representation permits a raw path, and a URI list beside it + /// commonly names the same document as a file: URI -- percent encoded, as a URI is. They + /// are one document, and putting both on the clip drags it twice. + private static String publishedUriKey(String value) { + if (hasScheme(value, "file:")) { + String path = Uri.parse(value).getPath(); + return path == null ? value : path; + } + return value; + } + + private static void declareUriList(List mimeTypes) { + if (!mimeTypes.contains(ClipboardContent.MIME_URI_LIST)) { + mimeTypes.add(ClipboardContent.MIME_URI_LIST); + } + } + + /// Puts the clip's text on the first item that carries a URI, or makes an item of it when + /// there is none. + /// + /// Android has no notion of "an alternative reading of this object": every item is another + /// thing being dragged. A file and its text fallback therefore have to be one item, or a + /// receiver importing the clip gets two objects where the source published one. The same + /// mistake on the iOS side made a receiver import a document and a stray piece of text. + private static void attachCarriedText(List items, String plain, String html) { + for (int iter = 0; iter < items.size(); iter++) { + Uri uri = items.get(iter).getUri(); + if (uri != null) { + items.set(iter, html != null + ? new ClipData.Item(plain, html, null, uri) + : new ClipData.Item(plain, null, uri)); + return; + } + } + // Nothing to ride on, so the text is the object. First, as it was before there was + // anything else in the clip at all. + items.add(0, html != null ? new ClipData.Item(plain, html) : new ClipData.Item(plain)); + } + + /// Adds the representations neither the text nor the binary pass above has taken. + /// + /// Byte-backed types -- a PDF, an archive, an application's own format -- become typed + /// content URIs, which is the only labelled way an Android clip carries bytes. Text types + /// are advertised only when their value *is* the text the clip already carries: a clip has + /// one text payload, so advertising a second, different reading of it would tell a receiver + /// the clip holds something it cannot then produce, and a Codename One target would accept + /// the hover and be refused at the drop. + private void addRemainingRepresentations(Assembly assembly, String carriedText, + List mimeTypes, List items, long clip) throws IOException { + String[] advertised = assembly.content().getMimeTypes(); + for (int iter = 0; iter < advertised.length; iter++) { + String mime = advertised[iter]; + if (mimeTypes.contains(mime) || ClipboardContent.MIME_FILE.equals(mime)) { + continue; + } + // Each representation on its own: a provider that throws is one type absent, not + // every type after it. ClipboardDataProvider permits it to fail. + Object value = assembly.value(mime); + byte[] bytes = null; + if (value instanceof String) { + if (carriedText != null && carriedText.equals(value)) { + // The same text the clip already carries, so naming the type is enough. + mimeTypes.add(mime); + continue; + } + // A *different* reading -- Markdown source beside its plain rendering, say. + // A clip carries one text payload, so this one travels as a typed content URI + // the way binary does. Dropping it instead, which is what this did, lost a + // representation the application deliberately published. + bytes = ((String) value).getBytes("UTF-8"); + } else if (value instanceof byte[]) { + bytes = (byte[]) value; + } + if (bytes != null) { + try { + Uri uri = writeAsProviderUri(bytes, extensionForMime(mime), mime, clip); + if (uri != null) { + mimeTypes.add(mime); + items.add(new ClipData.Item(uri)); + } + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + } + } + + /// A content URI another application can read for this file. + /// + /// The file provider is configured with a fixed set of roots -- the application's files + /// directory and cache/intent_files -- and getUriForFile throws for anything outside them. + /// Plenty of perfectly good paths are outside them: FileSystemStorage lists external + /// storage roots, and a file there used to throw, be logged, and be left out of the clip + /// entirely -- taking the whole drag with it when it was the only thing being dragged. + /// + /// So it is copied where the provider can reach, under its own name, which is what a + /// receiver sees. Not through writeAsProviderUri: that names and records what it mints as + /// transport for a representation's bytes, and this is a file the source published. + private static final long MAX_STAGED_SHARE_BYTES = 8L * 1024 * 1024; + private static final String SHARED_COPY_PREFIX = "cn1-shared-"; + + private Uri shareableUriFor(File file, String authority, long clip) throws IOException { + try { + Uri direct = FileProvider.getUriForFile(getContext(), authority, file); + getContext().grantUriPermission("android", direct, + Intent.FLAG_GRANT_READ_URI_PERMISSION); + return direct; + } catch (Throwable outsideTheRoots) { + com.codename1.io.Log.e(outsideTheRoots); + } + // The copy runs on the thread that started the drag, which is the event dispatch + // thread, and a drag has to begin while the finger is still down -- so this cannot be + // moved off it and cannot be allowed to take long. Android stops waiting for input after + // five seconds; a few megabytes is far below that on any storage, and a file bigger than + // this has no business being copied at all. It belongs under a provider root, which is + // where the roots above now put the external storage such files actually live on. + if (file.length() > MAX_STAGED_SHARE_BYTES) { + throw new IOException("refusing to copy " + file.length() + " bytes on the event " + + "dispatch thread to share " + file); + } + File dir = new File(getContext().getCacheDir(), "intent_files"); + dir.mkdirs(); + // Its own directory, so the copy keeps the original name without colliding with + // another file of the same name in the same drag. + File holder = File.createTempFile(SHARED_COPY_PREFIX, "", dir); + if (!holder.delete() || !holder.mkdirs()) { + throw new IOException("could not stage " + file + " for sharing"); + } + File copy = new File(holder, file.getName()); + boolean registered = false; + try { + InputStream in = new FileInputStream(file); + try { + OutputStream os = new FileOutputStream(copy); + try { + byte[] buffer = new byte[8192]; + int read; + while ((read = in.read(buffer)) > 0) { + os.write(buffer, 0, read); + } + } finally { + os.close(); + } + } finally { + in.close(); + } + Uri shared = FileProvider.getUriForFile(getContext(), authority, copy); + getContext().grantUriPermission("android", shared, + Intent.FLAG_GRANT_READ_URI_PERMISSION); + // Remembered so it is cleaned up, but not as transport: this is a file the source + // published, and it has to read back as one. + rememberStagedClipFile(shared, copy, false, clip); + registered = true; + return shared; + } finally { + if (!registered) { + // A source that vanished, a read that failed, a disk that filled: the holder + // and whatever was written into it exist by now, and nothing has registered + // them for reclamation -- so every failed export left its partial copy in the + // cache for good. + // + // Registration, not the copy, is what ends the window. Naming the file to the + // provider can fail on its own -- a path the manifest's roots do not cover is + // refused there and nowhere else -- and with the flag set at the end of the + // copy, that failure leaked exactly what this was written to prevent. + copy.delete(); + holder.delete(); + } + } + } + + /// One clip assembly's reading of a content, kept to itself. + /// + /// A representation registered as a provider is resolved once per transfer, and the memory + /// of that lives on the ClipboardContent -- which is fine for a transfer that owns it and + /// wrong for two that overlap. A copy assembles on Android's UI thread and a drag on the + /// event dispatch thread, so one could reset the shared memo halfway through the other and + /// hand it a value produced for a different transfer: a clip built from two generations of + /// a payload that changes. + /// + /// So an assembly reads through this instead. The provider is asked at most once per type + /// *per assembly*, which is what the promise actually is, and neither assembly can disturb + /// the other because neither touches the content's own memory. + private static final class Assembly { + private final ClipboardContent content; + private final Map produced = new HashMap(); + + Assembly(ClipboardContent content) { + this.content = content; + } + + ClipboardContent content() { + return content; + } + + Object value(String mimeType) { + if (content == null || mimeType == null) { + return null; + } + if (produced.containsKey(mimeType)) { + return produced.get(mimeType); + } + Object value = null; + try { + value = com.codename1.ui.NativeDragAndDrop.produceTransferValue(content, mimeType); + } catch (Throwable err) { + // A provider that fails is one type absent, not a clip abandoned -- and the + // failure is remembered like any other answer, so a second read of the same + // type does not run it again. Same rule as clipboardValue. + com.codename1.io.Log.e(err); + } + produced.put(mimeType, value); + return value; + } + + String text(String mimeType) { + Object value = value(mimeType); + return value instanceof String ? (String) value : null; + } + + byte[] bytes(String mimeType) { + Object value = value(mimeType); + return value instanceof byte[] ? (byte[]) value : null; + } + } + + /// Writes bytes somewhere the application's file provider can serve them from and returns + /// the content URI, which is how an Android clip carries anything that is not text. + /// + /// AndroidGradleBuilder exposes cache/intent_files through the app's FileProvider, so + /// generated payloads stay inside that root and FileProvider can safely name them. + /// + /// The name carries `mime` so the read back is an answer rather than a guess -- see + /// `#decodeMimeFromFileName(java.lang.String)`. + private Uri writeAsProviderUri(byte[] bytes, String extension, String mime, long clip) + throws IOException { + if (bytes == null) { + return null; + } + // A zero length payload is still a payload: refusing it would leave the clip without a + // type it had advertised, and a target filtering on that type would accept the hover + // and be refused the drop. + File dir = new File(getContext().getCacheDir(), "intent_files"); + dir.mkdirs(); + // A name built from the clock and the payload's length collided: two representations of + // one payload that share an extension and a byte length are written within the same + // millisecond, and the second overwrote the first -- leaving both clip items pointing at + // the second one's bytes. createTempFile is the guarantee rather than a longer guess. + String encoded = encodeMimeForFileName(mime); + File file = File.createTempFile( + encoded == null ? CLIP_FILE_PREFIX : CLIP_FILE_PREFIX + encoded + "-", + "." + extension, dir); + boolean registered = false; + try { + OutputStream os = new FileOutputStream(file); + try { + os.write(bytes); + } finally { + os.close(); + } + Uri uri = FileProvider.getUriForFile(getContext(), + getContext().getPackageName() + ".provider", file); + // Grant broadly so any paste or drop target can read the content:// URI + getContext().grantUriPermission("android", uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); + rememberStagedClipFile(uri, file, true, clip); + registered = true; + return uri; + } finally { + if (!registered) { + // The file exists from createTempFile onwards, and reclamation only ever sees + // what was registered -- so a cache that fills mid-write, or a provider that + // refuses to name the file, left a partial cn1-clip- file behind that nothing + // would ever collect. The same window the published-file copy above closes. + file.delete(); + } + } + } + + /// The name every generated clip file starts with, and the alphabet + /// `#encodeMimeForFileName(java.lang.String)` writes the type in. + private static final String CLIP_FILE_PREFIX = "cn1-clip-"; + private static final String CLIP_MIME_HEX = "0123456789abcdef"; + + /// Writes a MIME type into something that is legal in a file name and reads back as itself. + /// + /// The extension cannot do this job. It is derived from the type and the derivation is + /// lossy -- `application/x-foo` and `application/x-foo+json` both reduce to `xfoo` -- so two + /// representations of one payload can produce URIs no reader can tell apart, and both are + /// then dropped rather than mispaired. Hex is unlovely for a file name nobody reads, and it + /// is exact: every byte of the type survives, and no character it produces means anything to + /// a file system, a URI or `#decodeMimeFromFileName(java.lang.String)`. + /// + /// Answers null for a type this cannot carry, and the file is then named without one. + private static String encodeMimeForFileName(String mime) { + if (mime == null || mime.length() == 0 || mime.length() > 60) { + return null; + } + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter < mime.length(); iter++) { + int c = mime.charAt(iter); + if (c > 0xff) { + return null; + } + out.append(CLIP_MIME_HEX.charAt((c >> 4) & 0xf)).append(CLIP_MIME_HEX.charAt(c & 0xf)); + } + return out.toString(); + } + + /// The MIME type `#encodeMimeForFileName(java.lang.String)` wrote into this name, or null + /// when the name did not come from there -- a clip another application published, or one + /// whose type was too long to carry. + private static String decodeMimeFromFileName(String name) { + if (name == null || !name.startsWith(CLIP_FILE_PREFIX)) { + return null; + } + int end = name.indexOf('-', CLIP_FILE_PREFIX.length()); + if (end < 0) { + return null; + } + String hex = name.substring(CLIP_FILE_PREFIX.length(), end); + if (hex.length() == 0 || (hex.length() & 1) != 0) { + return null; + } + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter < hex.length(); iter += 2) { + int hi = Character.digit(hex.charAt(iter), 16); + int lo = Character.digit(hex.charAt(iter + 1), 16); + if (hi < 0 || lo < 0) { + return null; + } + out.append((char) ((hi << 4) | lo)); + } + return asciiLower(out.toString()); + } + + /// A file extension for a MIME type, used to name the temporary file a content URI is + /// served from. + /// + /// Android's own table first, because a FileProvider derives the URI's type from the + /// extension: a synthesized one it does not recognize makes ContentResolver.getType answer + /// application/octet-stream, and the type the clip advertised is then unrecoverable when + /// the clip is read back. + private static String extensionForMime(String mime) { + try { + String known = android.webkit.MimeTypeMap.getSingleton().getExtensionFromMimeType(mime); + if (known != null && known.length() > 0) { + return known; + } + } catch (Throwable t) { + // Fall through to the synthesized extension below. + } + int slash = mime.indexOf('/'); + String sub = slash < 0 ? mime : mime.substring(slash + 1); + int plus = sub.indexOf('+'); + if (plus > 0) { + sub = sub.substring(0, plus); + } + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter < sub.length(); iter++) { + char c = sub.charAt(iter); + if ((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) { + out.append(c); + } + } + return out.length() == 0 ? "bin" : out.toString(); + } + + /// The MIME type to file an incoming image's bytes under: the framework's constant for the + /// three formats it names, and the type the content resolver reported for anything else. + /// + /// `#mimeForImageType(java.lang.String)` answers PNG for everything it does not recognize, + /// which for a WebP meant filing WebP bytes as a PNG -- undecodable by anything that + /// believed the label, and invisible to a target filtering on the type the drag advertised, + /// so the hover was accepted and the drop refused. + private static String imageMimeFor(String type) { + String lower = asciiLower(type); + if (lower.startsWith(ClipboardContent.MIME_PNG) + || lower.startsWith(ClipboardContent.MIME_JPEG) + || lower.startsWith(ClipboardContent.MIME_GIF)) { + return mimeForImageType(lower); + } + return lower; + } + + /** + * Maps a content resolver image MIME type to the corresponding ClipboardContent MIME constant, + * defaulting to PNG for unrecognized image types. + */ + private static String mimeForImageType(String type) { + if (type == null) { + return ClipboardContent.MIME_PNG; + } + if (type.startsWith(ClipboardContent.MIME_JPEG)) { + return ClipboardContent.MIME_JPEG; + } + if (type.startsWith(ClipboardContent.MIME_GIF)) { + return ClipboardContent.MIME_GIF; + } + return ClipboardContent.MIME_PNG; + } + + /** + * @inheritDoc + */ + public Object getPasteDataFromClipboard() { + if (getContext() == null) { + return null; + } + final Object[] response = new Object[1]; + runOnUiThreadAndBlock(new Runnable() { + @Override + public void run() { + int sdk = android.os.Build.VERSION.SDK_INT; + if (sdk < 11) { + android.text.ClipboardManager clipboard = (android.text.ClipboardManager) getActivity().getSystemService(Context.CLIPBOARD_SERVICE); + response[0] = clipboard.getText().toString(); + } else { + android.content.ClipboardManager clipboard = (android.content.ClipboardManager) getActivity().getSystemService(Context.CLIPBOARD_SERVICE); + ClipData clip = clipboard.getPrimaryClip(); + if (clip == null || clip.getItemCount() == 0) { + return; + } + // With the description, exactly as a drop is read. Without it the only + // types a paste could report were the ones an item produced by itself, + // so another application's text published under a type of its own -- + // text/markdown, an application's own format -- arrived as nothing but + // text/plain and the type it was published under was gone. + ClipboardContent content = contentFromClip(clip, clip.getDescription()); + String plain = content.getText(ClipboardContent.MIME_TEXT); + // What the clip actually holds, not how many types it happens to name. + // Counting worked only because every clip used to acquire a text/plain of + // its own, empty or not: with that padding gone an image-only clip counted + // as one type, fell through to the plain-text answer, and a paste that had + // a perfectly good PNG in it returned null. + String[] types = content.getMimeTypes(); + boolean textOnly = types.length == 0 + || (types.length == 1 && ClipboardContent.MIME_TEXT.equals(types[0])); + if (!textOnly) { + response[0] = content; + } else { + response[0] = plain != null && plain.length() > 0 ? plain : null; + } + } + } + }); + return response[0]; + } + + /// Reads an Android `android.content.ClipData` into the framework's `ClipboardContent`. + /// + /// Shared by paste and by a native drop, because Android describes both the same way: a + /// list of items that are each text, HTML or a URI, and a URI is either an image to be read + /// or a file reference to be passed along. The plain text representation is always present, + /// even when empty, so a caller can tell "nothing but text" from "something richer" by the + /// number of MIME types. + /// + /// #### Parameters + /// + /// - `clip`: the clip data, which may be null + /// + /// #### Returns + /// + /// the content, never null + ClipboardContent contentFromClip(ClipData clip) { + return contentFromClip(clip, clip == null ? null : clip.getDescription()); + } + + /// Reads a clip, and where a description is given also honours the MIME types it + /// advertises. + /// + /// A drag is filtered twice: once against the description while it hovers, and again + /// against the materialized content when it is dropped. If the second view is narrower than + /// the first, a target accepts the hover and is then refused the drop -- which is what + /// happened to a component filtering on `ClipboardContent#MIME_URI_LIST`, because a URI + /// item materializes as `MIME_FILE` alone. Nothing is invented here: an advertised type is + /// only filled from a value the clip actually produced. + /// + /// A paste is read the same way, from the primary clip's own description. It used to pass + /// none, on the reasoning that a paste should report only what the clip produced -- but + /// the description *is* what the clip says it holds, and without it a type another + /// application published its text under was simply lost. What is filled from it is still + /// only ever a value the clip produced. + /// + /// #### Parameters + /// + /// - `clip`: the clip data, which may be null + /// + /// - `description`: what the source advertised, or null to report only what was read -- + /// which no caller does any more, though a port that has no description to offer + /// still may + /// + /// #### Returns + /// + /// the content, never null + ClipboardContent contentFromClip(ClipData clip, ClipDescription description) { + ClipboardContent content = new ClipboardContent(); + if (clip == null) { + content.setData(ClipboardContent.MIME_TEXT, ""); + return content; + } + int sdk = android.os.Build.VERSION.SDK_INT; + String plain = null; + String html = null; + List fileUris = new ArrayList(); + // Every URI the clip carried that the source published, files or not. A link dragged out + // of a browser belongs here and not in fileUris: it is a URI, and it is not a document on + // disk. The two lists differ only by that, and by the transport URIs this exporter mints, + // which are in neither because the source never published them as URIs at all. + List publishedUris = new ArrayList(); + // URIs the content resolver could not name. An application defined type has no entry in + // Android's table, so a FileProvider serving it reports octet-stream or nothing at all. + List unnamedUris = new ArrayList(); + for (int i = 0; i < clip.getItemCount(); i++) { + ClipData.Item item = clip.getItemAt(i); + try { + Uri uri = item.getUri(); + if (uri != null) { + // Without the parameters, because a bare MIME type is what everything here + // compares against: a provider answering "text/plain; charset=utf-8" would + // file the document under a type no target asks for, and would slip past + // the MIME_TEXT check below that stops the synthesized empty text from + // overwriting it. + String type = bareMimeType(getContext().getContentResolver().getType(uri)); + if (type != null && type.startsWith("image/")) { + // Promised, not read. Reading it here opened the URI and pulled the + // whole image across on Android's own UI thread, before the drop was + // even queued -- so a photo dropped on a target that wanted nothing + // but getFiles() stalled the application, or ran it out of memory, + // for bytes nobody asked for. The same promise the typed branch below + // makes, and safe for the same reason: the grant this drop was given + // lasts as long as the activity, so a read a moment later on the + // event dispatch thread still succeeds. See uriBytesProvider. + String imageMime = imageMimeFor(type); + if (!content.hasMimeType(imageMime)) { + content.setDataProvider(imageMime, uriBytesProvider(uri)); + } + } else if (type != null && type.length() > 0 + && !"application/octet-stream".equals(type)) { + // A typed URI is a file reference *and* that type. Reducing it to a file + // alone let a target filtering on, say, application/pdf accept the hover + // -- the description advertised the type -- and then be refused the + // drop, because the content it is filtered against a second time no + // longer had it. The bytes are promised rather than read: a target that + // only wants the path should not pay for a document it never opens. + if (!content.hasMimeType(type)) { + content.setDataProvider(type, uriBytesProvider(uri)); + } + } else { + unnamedUris.add(uri); + } + // A URI item is a file reference as well as whatever its type made of it -- + // unless it is one this exporter minted to carry bytes. The image branch + // used to return before reaching this at all, so dragging a PNG *file* + // produced image bytes and no file, and a target filtering on MIME_FILE + // accepted the hover -- the description still advertised text/uri-list -- + // and was refused the drop. Adding every URI unconditionally is the other + // error: a payload of nothing but application/pdf bytes travels as a + // content URI without text/uri-list ever being advertised, and calling that + // a file both invents a representation the source never published and lets + // a nested file-only target take a drop the PDF-capable one was chosen for + // while it hovered. + // + // The two are told apart by the exporter's own record of what it minted, + // not by anything about the URI or its name -- an application may publish a + // file called anything at all. + if (!isGeneratedClipFile(uri) && mayCarryAcrossApplications(uri)) { + publishedUris.add(uri.toString()); + if (namesALocalFile(uri)) { + fileUris.add(uri.toString()); + } + } + // No continue: an item carrying a URI carries the clip's text too, because + // that is where this exporter puts it -- a text item of its own would be a + // second object being dragged. Returning here dropped the fallback the + // source published on its own round trip. + } + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + if (html == null && sdk >= 16) { + // Empty markup is a value, not an absence: getHtmlText answers null when the + // item carries no HTML at all, so anything else is what the source published. + // Discarding it left fillAdvertisedTypes to rebuild the advertised text/html + // from the plain text, handing the target something the source never wrote -- + // and this exporter publishes exactly that item for content whose HTML is empty. + html = item.getHtmlText(); + } + if (plain == null) { + // What the item literally carries first, and empty counts: getText answers + // null when the item holds no text at all, so anything else is what the + // source published -- the same reading getHtmlText gets above. Discarding an + // empty one left an advertised text/markdown with nothing to restore it + // from, and a target that took the hover on that type was refused the drop. + CharSequence literal = item.getText(); + if (literal != null) { + plain = literal.toString(); + } else if (item.getUri() == null) { + // Nothing literal, so it is derived -- and only for an item with no URI. + // coerceToText on one of those goes and reads the document behind it, + // which is a different value altogether and none of this branch's + // business. An empty derivation means the item had nothing to give + // rather than that the source published nothing, so it does not stop + // the search. + CharSequence derived = item.coerceToText(getContext()); + if (derived != null && derived.length() > 0) { + plain = derived.toString(); + } + } + } + } + if (html != null) { + // A value the clip's own item published, so it wins over a URI the resolver happened + // to type text/html -- an .html file being dragged. Same rule as the text below, + // and the reason that one needs a guard and this one does not: there is no + // synthesized empty HTML to write over a representation that already answered. + content.setData(ClipboardContent.MIME_HTML, html); + } + if (!fileUris.isEmpty()) { + content.setFiles(fileUris.toArray(new String[fileUris.size()])); + } + // Not when the clip named exactly one type and it is not text/plain. That type is what + // the text *is*: another application publishing a direct item of its own format -- + // application/json, say -- carries the value as the item's text, because an Android + // item has nowhere else to put a string. Calling it text/plain lost the name the clip + // gave it, and a target filtered to that name accepted the hover and was refused the + // drop; fillAdvertisedTypes below hands the value to the type instead. + if (plain != null && soleAdvertisedType(description) == null) { + content.setData(ClipboardContent.MIME_TEXT, plain); + } else if (plain == null && !content.hasMimeType(ClipboardContent.MIME_TEXT) + && description != null && description.hasMimeType(ClipboardContent.MIME_TEXT)) { + // The clip promised text and no item produced it, so the empty string keeps that + // promise: a target that accepted the hover on text/plain would otherwise be + // refused the drop it was told it could have. Only then, though -- a clip that + // never mentioned text does not acquire it here. findTarget runs again against the + // materialized content, so inventing text/plain let a nested text-only component + // take a drop the type-capable ancestor had been chosen for while it hovered, and + // that component never saw an enter event at all. + // + // Nor over a representation that answered: a URI the resolver typed text/plain, + // which is what a dragged .txt is, has already registered the document's own + // contents, and writing over that handed the target an empty document. + content.setData(ClipboardContent.MIME_TEXT, ""); + } + if (description != null) { + fillAdvertisedTypes(content, description, plain, publishedUris, unnamedUris); + } else if (!publishedUris.isEmpty() && !content.hasMimeType(ClipboardContent.MIME_URI_LIST)) { + // A paste is told nothing about what the clip advertises, so what it reports can + // only come from what the clip carried -- and what this one carried is URIs. + // Another application copying a link publishes exactly that, one item with a URI + // and no text at all: nothing above it produces a representation, so without this + // the read answered with an empty content and the paste with null. + // + // Nothing is invented by it either. These are the URIs the clip itself carried, + // minus the ones this exporter minted as transport, which is what a URI list is. + content.setData(ClipboardContent.MIME_URI_LIST, uriListOf(publishedUris)); + } + return content; + } + + /// The content URIs this exporter minted to carry bytes, oldest first. + /// + /// Remembered, not recognized. The file name cannot answer the question: an application may + /// publish a file of its own by any name it likes, and one called cn1-clip-roundtrip.txt is + /// exactly what the clipboard round trip publishes -- which a prefix test then threw away + /// as one of ours, losing the file reference it had just copied. The type cannot answer it + /// either, since a PDF published as bytes and a PDF published as a file both arrive as + /// application/pdf. Only the exporter knows, so the exporter records it. + /// + /// Bounded: a clip that has been replaced on the clipboard can no longer be pasted, so the + /// oldest entries are of no further use. A clip that outlives the process falls back to + /// being read as a file, which is what it was read as before any of this existed. + /// It also names the file, because every one of these is a file this application wrote + /// into its own cache and nothing else will ever come back for it. A clip that has been + /// replaced cannot be pasted, so when one falls off the end its file goes with it -- + /// otherwise copying documents or images repeatedly leaves every one of them on disk for + /// the life of the installation. + /// + /// Kept by the clip rather than one file at a time. A single payload can stage more files + /// than any per-file bound, and counting them individually deleted the earliest ones while + /// clipDataFor was still building the very clip that referenced them -- so the clip went + /// out pointing at files that were already gone. Whole clips are what is forgotten, never + /// the one being assembled. + /// + /// Bounded by bytes rather than by a count of clips. A receiver may hold a content URI + /// this application handed it and read it much later -- a queued upload does exactly that, + /// and the grant stays valid -- so counting clips deleted a file somebody was still + /// entitled to as soon as eight more copies had been made, however small. What can + /// actually fill a device is bytes: a hundred staged text fragments cost nothing and all + /// survive, while a few videos are reclaimed as soon as they add up. + /// + /// There is no signal that says a receiver is finished with one, and inventing one would + /// be a new public API every application had to adopt to keep behaving as it does today. + /// The same reasoning, and the same budget, as the dropped copies on iOS. + private static final long GENERATED_CLIP_BUDGET = 64L * 1024 * 1024; + private static final java.util.LinkedHashMap STAGED_CLIP_FILES = + new java.util.LinkedHashMap(); + + /// One file staged for a clip: where it is, and whether it carries a representation's + /// bytes rather than being a file the source published. + private static final class StagedClipFile { + private final String path; + private final boolean transport; + private final long clip; + /// What it occupies, for the budget above. Taken when it is staged, because by the + /// time it is reclaimed the file may be gone and a size of zero would make a large + /// clip look free. + private final long bytes; + + StagedClipFile(String path, boolean transport, long clip, long bytes) { + this.path = path; + this.transport = transport; + this.clip = clip; + this.bytes = bytes; + } + } + + /// The clip being assembled. Incremented as each one starts, so everything staged for it + /// is recognisable as belonging together. + private static long stagingClip; + + /// The clip the system clipboard is holding, and the clip a running drag is carrying. + /// + /// Neither is superseded by anything newer, which is what a window of recent clips would + /// otherwise assume. A clipboard holds its clip until something replaces it, and every + /// drag in between advances the count -- so nine drags after a copy deleted the files the + /// clipboard was still pointing at, and the paste the user eventually made produced a + /// content URI nothing could read. + private static long clipboardClip; + private static long draggingClip; + + /// The assembly a publication in progress is about to put on the clipboard, exempt from + /// reclamation until the attempt is over. Nothing holds it yet -- the clipboard has not + /// taken it -- and without this the window between assembling a clip and the system + /// accepting it was one in which its own files could be deleted. + private static long publishingClip; + + /// Changes to the primary clip this application is about to make itself, which the watcher + /// below hears about like any other and must not read as somebody else's copy. + /// + /// A count rather than a flag: a copy can be made while an earlier one's callback is still + /// queued, and a flag cleared by the first would have made the second look foreign. + private static int expectedClipChanges; + + /// True once the primary clip watcher is installed, which happens the first time this + /// application puts anything on the clipboard. + private static boolean clipboardWatched; + + /// The assemblies that have begun and whose caller has not yet taken them over. + /// + /// An assembly is exempt from reclamation while it is being built -- its files are being + /// referenced by a clip that does not exist yet -- and stays exempt until whoever asked for + /// it has put it on the clipboard or handed it to a drag. Exempting only the clip currently + /// growing was not enough: a copy assembles on Android's UI thread while a drag assembles + /// on the event dispatch thread, so one could finish and be waiting for its caller to claim + /// it while the other's staging triggered a reclamation that deleted its files. The caller + /// then published, or dragged, a clip of dead URIs. + private static final java.util.Set ASSEMBLING_CLIPS = new java.util.HashSet(); + + private static long beginStagingClip() { + synchronized (STAGED_CLIP_FILES) { + long clip = ++stagingClip; + ASSEMBLING_CLIPS.add(Long.valueOf(clip)); + return clip; + } + } + + /// Ends an assembly's exemption, because its caller has taken it over -- or has given up on + /// it, which is the same thing as far as its files are concerned. + /// + /// #### Parameters + /// + /// - `clip`: the assembly, or zero when there was none + static void endStagingClip(long clip) { + if (clip == 0) { + return; + } + synchronized (STAGED_CLIP_FILES) { + ASSEMBLING_CLIPS.remove(Long.valueOf(clip)); + reclaimStagedClipFiles(); + } + } + + /// Starts listening for the primary clip being replaced, once. + /// + /// A clip this application published is exempt from reclamation for as long as the + /// clipboard holds it, and nothing but another copy of our own used to end that -- so a + /// copy made in *another* application left ours pinned for good, and an oversized one then + /// sat in the cache above the budget with nothing able to reclaim it. + /// + /// Called on the Android UI thread, from the copy that is about to pin something. + /// + /// Android only delivers these callbacks to an application that has focus, so a copy made + /// elsewhere while this one is in the background is still missed. That leaves the hold in + /// place until the next copy either application makes, which is the behaviour this + /// replaces rather than a new failure -- and the files are in the cache directory, which + /// the system reclaims under pressure whatever this bookkeeping believes. + private static void watchPrimaryClip(android.content.ClipboardManager clipboard) { + synchronized (STAGED_CLIP_FILES) { + if (clipboardWatched) { + return; + } + clipboardWatched = true; + } + try { + clipboard.addPrimaryClipChangedListener( + new android.content.ClipboardManager.OnPrimaryClipChangedListener() { + @Override + public void onPrimaryClipChanged() { + synchronized (STAGED_CLIP_FILES) { + if (expectedClipChanges > 0) { + // Our own copy, which has already said what it holds. + expectedClipChanges--; + return; + } + } + // A clip somebody else published replaced ours, so what ours was carrying + // is nobody's to paste any more. + clipboardHolds(0); + } + }); + } catch (Throwable t) { + // A device that will not register the listener keeps the old behaviour, which is + // a hold that outlives the clip rather than a crash on copy. + com.codename1.io.Log.e(t); + synchronized (STAGED_CLIP_FILES) { + clipboardWatched = false; + // Nothing will consume what was counted for the copy this call belongs to. + expectedClipChanges = 0; + } + } + } + + /// Records that this application is about to replace the primary clip, so the watcher does + /// not mistake its own callback for another application's copy, and pins what the clip is + /// about to carry for the length of the attempt. + /// + /// #### Parameters + /// + /// - `clip`: the assembly being published, or zero for a clip with nothing staged + private static void clipboardPublishing(long clip) { + synchronized (STAGED_CLIP_FILES) { + if (clipboardWatched) { + expectedClipChanges++; + } + // Only while something is listening. Counting a copy no callback will ever arrive + // for -- a device that refused the listener -- left the count standing, and if a + // later copy did install the watcher, that phantom swallowed the first genuinely + // foreign clipboard change: the clip stayed pinned and its files stayed out of + // reach of the budget. + publishingClip = clip; + } + } + + /// Ends a publication, either committing it or putting back what it had provisionally + /// taken. + /// + /// #### Parameters + /// + /// - `clip`: the assembly that was being published + /// + /// - `published`: true when setPrimaryClip returned + private static void clipboardPublished(long clip, boolean published) { + synchronized (STAGED_CLIP_FILES) { + publishingClip = 0; + if (!published && expectedClipChanges > 0) { + // No callback is coming for a clip that never reached the clipboard. + expectedClipChanges--; + } + } + if (published) { + // Now, and only now, is the clip the clipboard's -- which is also what stops the + // one it replaced from being pinned. + clipboardHolds(clip); + } + } + + /// Records which clip the system clipboard now holds, or zero for a clip with nothing + /// staged for it. + /// + /// Called for every clip put on the clipboard, plain text included: what matters as much + /// is that the clip it held *before* is not the clipboard's any more, so its files may go + /// when they age out. + static void clipboardHolds(long clip) { + synchronized (STAGED_CLIP_FILES) { + clipboardClip = clip; + // Letting go is as good a moment to reconsider as staging is: a clip that was + // over the budget on its own could not be reclaimed while it was held, and + // nothing else would have looked at it again until some later transfer staged + // a file -- which for an application that drags one large payload and then + // stops is never. + reclaimStagedClipFiles(); + } + } + + /// The clip a drag is carrying right now, so a release queued for one drag can tell + /// whether it is still the drag whose hold it is about to end. + static long draggingClip() { + synchronized (STAGED_CLIP_FILES) { + return draggingClip; + } + } + + /// Ends the hold on one drag's clip, and only that one. + /// + /// A drop's release is queued onto the event dispatch thread, and a callback that enters a + /// nested event loop can let another drag start before it runs. Clearing the shared slot + /// unconditionally then let go of the *new* drag's clip, whose files a cache over budget + /// could delete while the receiving application was still to read them. + /// + /// #### Parameters + /// + /// - `clip`: the clip whose drag has finished, or zero to release whatever is held + static void releaseDragHold(long clip) { + synchronized (STAGED_CLIP_FILES) { + if (clip != 0 && draggingClip != clip) { + return; + } + // Compared and cleared without letting go of the lock in between. A completion + // listener on the event dispatch thread can start the next drag at any moment, and + // it claims this slot: reading it, releasing the lock and then clearing it let go + // of a drag that had begun after the comparison said it was safe. The body is + // dragHolds(0) written out for that reason and nothing else. + draggingClip = 0; + reclaimStagedClipFiles(); + } + } + + /// Records the clip a drag is carrying, or zero once it has ended. + static void dragHolds(long clip) { + synchronized (STAGED_CLIP_FILES) { + draggingClip = clip; + reclaimStagedClipFiles(); + } + } + + private static void rememberStagedClipFile(Uri uri, File file, boolean transport, + long clip) { + synchronized (STAGED_CLIP_FILES) { + STAGED_CLIP_FILES.remove(uri.toString()); + STAGED_CLIP_FILES.put(uri.toString(), + new StagedClipFile(file.getAbsolutePath(), transport, clip, file.length())); + reclaimStagedClipFiles(); + } + } + + /// Reclaims staged files, oldest first, until what is left fits the budget. + /// + /// Never an assembly whose caller has yet to take it over -- it is still growing, or + /// waiting to be handed to a clipboard or a drag -- and never the one the clipboard, a + /// running drag or a publication in progress is carrying, none of which are superseded by + /// anything however old they are. Called when a file is staged and again when any of those + /// is released, because a clip too large for the budget on its own can only be reclaimed + /// once nothing holds it any more. + private static void reclaimStagedClipFiles() { + synchronized (STAGED_CLIP_FILES) { + long held = 0; + for (StagedClipFile staged : STAGED_CLIP_FILES.values()) { + held += staged.bytes; + } + java.util.Iterator> entries = + STAGED_CLIP_FILES.entrySet().iterator(); + while (held > GENERATED_CLIP_BUDGET && entries.hasNext()) { + StagedClipFile staged = entries.next().getValue(); + if (ASSEMBLING_CLIPS.contains(Long.valueOf(staged.clip)) + || staged.clip == clipboardClip || staged.clip == draggingClip + || staged.clip == publishingClip) { + continue; + } + held -= staged.bytes; + entries.remove(); + deleteStagedClipFile(staged); + } + } + } + + /// Removes a staged file, and the directory it was given to itself when it had one. + /// + /// Best effort by design: a file that will not delete is one the cache directory will + /// eventually reclaim, which is what a cache directory is for -- and is also what bounds + /// the files left behind by a process that ended before it could let go of them. + private static void deleteStagedClipFile(StagedClipFile staged) { + try { + File file = new File(staged.path); + File holder = file.getParentFile(); + if (file.delete() && holder != null + && holder.getName().startsWith(SHARED_COPY_PREFIX)) { + holder.delete(); + } + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + /// True when this content URI is one `#writeAsProviderUri(byte[], java.lang.String, + /// java.lang.String)` minted to carry a representation's bytes, rather than a file the + /// source published. + private static boolean isGeneratedClipFile(Uri uri) { + synchronized (STAGED_CLIP_FILES) { + StagedClipFile staged = STAGED_CLIP_FILES.get(uri.toString()); + return staged != null && staged.transport; + } + } + + /// True when a URI another application put on a clip is one this application may carry. + /// + /// A file: URI, or a bare path, is not. Android has refused to let a clip carrying one + /// cross an application boundary since API 24 -- prepareToLeaveProcess throws for exactly + /// that -- so one arriving here was never published by a well behaved application, and it + /// comes with no grant that would make it readable in the first place. Taking it at its + /// word is worse than useless: the path is read with *this* application's permissions, and + /// republishing it -- a copy, a drag onward -- would hand somebody else a file the sender + /// could not open, named by the sender. A content: URI carries a grant and is the only + /// spelling a clip is entitled to use for a document; everything remote is carried as a + /// URI and never opened as a path. + /// + /// This is about what *arrives*. What the application itself publishes through + /// `ClipboardContent#setFiles(java.lang.String...)` is its own file and is unaffected. + private static boolean mayCarryAcrossApplications(Uri uri) { + String scheme = uri.getScheme(); + if (scheme == null) { + return false; + } + return !"file".equalsIgnoreCase(scheme); + } + + /// True when this URI names something on this device rather than somewhere on the web. + /// + /// A link dragged out of a browser arrives as a text/uri-list item whose URI is https, + /// and calling that a file handed a file-only target a URL through getFiles() as though + /// it were a document on disk. It is still carried, under MIME_URI_LIST, which is what + /// it actually is. + private static boolean namesALocalFile(Uri uri) { + String scheme = uri.getScheme(); + if (scheme == null) { + // A bare path, which is a local file by construction. + return true; + } + // equalsIgnoreCase rather than a fold: it compares character by character and is + // locale independent, which String.toLowerCase() is not. + return "content".equalsIgnoreCase(scheme) || "file".equalsIgnoreCase(scheme); + } + + /// Lowercases ASCII letters only, so the result never depends on the device locale. + /// + /// String.toLowerCase() is locale sensitive, and a Turkish or Azerbaijani default turns + /// I into a dotless i: IMAGE/PNG normalized under one of those locales stopped being + /// equal to image/png, so every check against the framework's own constants failed and + /// a port no longer recognized the representation at all. MIME types, schemes and file + /// extensions are ASCII by definition, which is what makes folding only ASCII correct + /// rather than merely safe. Codename One has no java.util.Locale to ask for the root + /// locale instead. + /// True when this value opens with that scheme, whatever case it was written in. + /// + /// A URI scheme is case insensitive by specification, and a case-sensitive prefix test + /// read FILE:///sdcard/report.pdf as a literal path -- a file that does not exist, so + /// the only representation a file-only clip had was quietly dropped. + /// + /// #### Parameters + /// + /// - `value`: the path or URI + /// + /// - `scheme`: the scheme to test for, colon included, in lower case + private static boolean hasScheme(String value, String scheme) { + return value.length() >= scheme.length() + && value.regionMatches(true, 0, scheme, 0, scheme.length()); + } + + static String asciiLower(String s) { + StringBuilder out = new StringBuilder(s.length()); + for (int iter = 0; iter < s.length(); iter++) { + char c = s.charAt(iter); + out.append(c >= 'A' && c <= 'Z' ? (char) (c + 32) : c); + } + return out.toString(); + } + + /// A MIME type without its parameters, lower case, or null when there is none. + private static String bareMimeType(String type) { + if (type == null) { + return null; + } + int semicolon = type.indexOf(';'); + String bare = asciiLower((semicolon < 0 ? type : type.substring(0, semicolon)).trim()); + return bare.length() == 0 ? null : bare; + } + + /// Reads a content URI's bytes when something actually asks for them. + /// + /// The drag-and-drop permission this drop was granted lasts for the life of the activity -- + /// nothing calls release() on it -- so a read that happens a moment later on the event + /// dispatch thread still succeeds. Once read the value is kept, so a target that reads + /// during the drop may hold the result for as long as it likes. + /// + /// What it does not survive is the activity: a representation *first* asked for after the + /// activity that received the drop has been destroyed reads through a grant that no + /// longer exists, and answers null. Copying every representation into this application's + /// own storage at drop time is the only way round that, and it is the wrong trade -- it + /// is the eager read that stalls the platform's thread with a document nobody asked for, + /// which is why this is a promise in the first place. Component.nativeDrop says so where + /// an application will read it. + private ClipboardDataProvider uriBytesProvider(final Uri uri) { + return new ClipboardDataProvider() { + @Override + public Object getClipboardData(String mimeType) { + try { + InputStream in = getContext().getContentResolver().openInputStream(uri); + if (in == null) { + return null; + } + byte[] bytes; + try { + bytes = Util.readInputStream(in); + } finally { + in.close(); + } + // A text type reads back as text: the framework's getText() answers null + // for a byte array, so a Markdown representation that went out as a typed + // URI would come back unreadable to the very API that asked for it. + if (bytes != null && mimeType != null && mimeType.startsWith("text/")) { + return new String(bytes, "UTF-8"); + } + return bytes; + } catch (Throwable t) { + com.codename1.io.Log.e(t); + return null; + } + } + }; + } + + /// The `text/uri-list` spelling of the URIs a clip carried: one per line, CRLF separated + /// as RFC 2483 has it. + private static String uriListOf(List uris) { + StringBuilder out = new StringBuilder(); + for (int iter = 0; iter < uris.size(); iter++) { + if (iter > 0) { + out.append("\r\n"); + } + out.append(uris.get(iter)); + } + return out.toString(); + } + + /// Fills the MIME types the drag advertised but the read did not produce, from what it did. + /// + /// An Android clip carries a single text payload and the description says what that text + /// is, so a type the description names and the clip did not otherwise yield is that text -- + /// `text/uri-list` excepted, which is the list of URIs the clip carried. A type with no + /// value to give it is left absent rather than advertised empty. + private void fillAdvertisedTypes(ClipboardContent content, ClipDescription description, + String plain, List publishedUris, List unnamedUris) { + List unsatisfiedBinary = new ArrayList(); + List unsatisfiedText = new ArrayList(); + for (int iter = 0; iter < description.getMimeTypeCount(); iter++) { + String mime = description.getMimeType(iter); + if (mime == null) { + continue; + } + mime = asciiLower(mime); + if (content.hasMimeType(mime)) { + continue; + } + if ("text/uri-list".equals(mime)) { + // Every URI, not only the ones that name files: a URI list is a URI list, and a + // link the source published belongs in it even though it is not a document. + if (!publishedUris.isEmpty()) { + content.setData(ClipboardContent.MIME_URI_LIST, uriListOf(publishedUris)); + } + continue; + } + // A text type is *not* assumed to be the carried text here. The exporter writes a + // text representation whose value differs from that text into a content URI exactly + // as it writes binary, so assuming made a target asking for an application's own + // text format receive the plain fallback instead of the value it published. + if (mime.startsWith("text/")) { + unsatisfiedText.add(mime); + } else { + unsatisfiedBinary.add(mime); + } + } + List unclaimed = new ArrayList(unnamedUris); + for (int iter = unclaimed.size() - 1; iter >= 0; iter--) { + Uri uri = unclaimed.get(iter); + String named = mimeForUnnamedUri(uri, unsatisfiedBinary, unsatisfiedText); + if (named != null) { + content.setDataProvider(named, uriBytesProvider(uri)); + unsatisfiedBinary.remove(named); + unsatisfiedText.remove(named); + unclaimed.remove(iter); + } + } + if (unclaimed.size() == 1) { + // One representation the clip promised and could not produce, and one URI whose + // type Android could not name: the pairing cannot be anything else. A byte backed + // type is taken first because bytes can only have come from a URI, where a text one + // may also be another reading of the text the clip carries. With more of either it + // could be, and inventing an association would tell a target it has something it + // may not -- which is the failure this whole path exists to avoid -- so those are + // left absent and the target correctly refuses. + String only = null; + if (unsatisfiedBinary.size() == 1) { + only = unsatisfiedBinary.remove(0); + } else if (unsatisfiedBinary.isEmpty() && unsatisfiedText.size() == 1) { + only = unsatisfiedText.remove(0); + } + if (only != null) { + content.setDataProvider(only, uriBytesProvider(unclaimed.get(0))); + } + } + if (plain != null) { + for (int iter = 0; iter < unsatisfiedText.size(); iter++) { + // What is left: an Android clip carries a single text payload, and a text type + // no URI accounted for is another name for that payload -- which is exactly how + // the exporter advertises a reading whose value *is* the carried text. + content.setData(unsatisfiedText.get(iter), plain); + } + if (unsatisfiedText.isEmpty() && unsatisfiedBinary.size() == 1 && unclaimed.isEmpty() + && !content.hasMimeType(ClipboardContent.MIME_TEXT)) { + // And a type that is not text, when it is the only thing left unaccounted for + // and the carried text was not published as text either -- which is the clip + // that named one format of its own and put the value in the item, and only + // that clip. The pairing cannot be anything else, the same reasoning the one + // unclaimed URI above is matched by. + content.setData(unsatisfiedBinary.get(0), plain); + } + } + } + + /// The one type a clip advertises when that is all it advertises and it is not plain + /// text, or null. + /// + /// A clip that names a single format of its own is the case where the item's text is that + /// format rather than a plain reading of it; anything advertising text/plain, or more than + /// one type, is read the way it always was. + private static String soleAdvertisedType(ClipDescription description) { + if (description == null || description.getMimeTypeCount() != 1) { + return null; + } + String mime = description.getMimeType(0); + if (mime == null) { + return null; + } + mime = asciiLower(mime); + return ClipboardContent.MIME_TEXT.equals(mime) ? null : mime; + } + + /// The type an untyped content URI was published as, recovered from the name of the file it + /// serves. + /// + /// ContentResolver could not name it -- MimeTypeMap has no entry for an application defined + /// type, so the FileProvider serving it reports octet-stream. What this application wrote + /// still says so in its own name, exactly, which is the answer; a clip from elsewhere gets + /// the extension read as a type, which is a good guess and is treated as one -- an extension + /// two advertised types share answers nothing. + private String mimeForUnnamedUri(Uri uri, List binary, List text) { + String name = displayNameFor(uri); + if (name == null) { + return null; + } + String declared = decodeMimeFromFileName(name); + if (declared != null) { + // Written by this application, which named the type outright. It answers even when + // it names a type that is not among the candidates -- that means the type is already + // satisfied, or was never advertised, and either way this URI is not the missing + // one. Guessing past an exact answer would be strictly worse. + return binary.contains(declared) || text.contains(declared) ? declared : null; + } + int dot = name.lastIndexOf('.'); + if (dot < 0 || dot == name.length() - 1) { + return null; + } + String extension = asciiLower(name.substring(dot + 1)); + String match = null; + for (int pass = 0; pass < 2; pass++) { + List candidates = pass == 0 ? binary : text; + for (int iter = 0; iter < candidates.size(); iter++) { + String candidate = candidates.get(iter); + if (extension.equals(extensionForMime(candidate))) { + if (match != null) { + return null; + } + match = candidate; + } + } + } + return match; + } + + /// The file name behind a content URI, which is where the extension an exporter chose + /// survives. A provider that will not answer OpenableColumns still has the name in its path. + private String displayNameFor(Uri uri) { + Cursor cursor = null; + try { + cursor = getContext().getContentResolver().query(uri, + new String[]{android.provider.OpenableColumns.DISPLAY_NAME}, + null, null, null); + if (cursor != null && cursor.moveToFirst()) { + int column = cursor.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME); + if (column >= 0) { + String name = cursor.getString(column); + if (name != null && name.length() > 0) { + return name; + } + } + } + } catch (Throwable t) { + // Fall through to the path below. + } finally { + if (cursor != null) { + cursor.close(); + } + } + return uri.getLastPathSegment(); + } + + public static MediaException createMediaException(int extra) { + MediaErrorType type; + String message; + switch (extra) { + + case MediaPlayer.MEDIA_ERROR_IO: + type = MediaErrorType.Network; + message = "IO error"; + break; + case MediaPlayer.MEDIA_ERROR_MALFORMED: + type = MediaErrorType.Decode; + message = "Media was malformed"; + break; + case MediaPlayer.MEDIA_ERROR_NOT_VALID_FOR_PROGRESSIVE_PLAYBACK: + type = MediaErrorType.SrcNotSupported; + message = "Not valie for progressive playback"; + break; + case MediaPlayer.MEDIA_ERROR_SERVER_DIED: + type = MediaErrorType.Network; + message = "Server died"; + break; + case MediaPlayer.MEDIA_ERROR_TIMED_OUT: + type = MediaErrorType.Network; + message = "Timed out"; + break; + + case MediaPlayer.MEDIA_ERROR_UNKNOWN: + type = MediaErrorType.Network; + message = "Unknown error"; + break; + case MediaPlayer.MEDIA_ERROR_UNSUPPORTED: + type = MediaErrorType.SrcNotSupported; + message = "Unsupported media"; + break; + default: + type = MediaErrorType.Network; + message = "Unknown error"; + } + return new MediaException(type, message); + } + + + public class Video extends AndroidImplementation.AndroidPeer implements AsyncMedia { + + private VideoView nativeVideo; + private Activity activity; + private boolean fullScreen = false; + private Rectangle bounds; + private boolean nativeController = true; + private boolean nativePlayer; + private Form curentForm; + private List completionHandlers; + private final EventDispatcher errorListeners = new EventDispatcher(); + + private final EventDispatcher stateChangeListeners = new EventDispatcher(); + private PlayRequest pendingPlayRequest; + private PauseRequest pendingPauseRequest; + private boolean androidSeekPreviewWorkaroundEnabled; + + @Override + public State getState() { + if (isPlaying()) { + return State.Playing; + } else { + return State.Paused; + } + } + + protected void fireMediaStateChange(State newState) { + if (stateChangeListeners.hasListeners() && newState != getState()) { + stateChangeListeners.fireActionEvent(new MediaStateChangeEvent(this, getState(), newState)); + } + } + + @Override + public void addMediaStateChangeListener(ActionListener l) { + + stateChangeListeners.addListener(l); + } + + @Override + public void removeMediaStateChangeListener(ActionListener l) { + + stateChangeListeners.removeListener(l); + } + + @Override + public void addMediaErrorListener(ActionListener l) { + errorListeners.addListener(l); + } + + @Override + public void removeMediaErrorListener(ActionListener l) { + errorListeners.removeListener(l); + } + + @Override + public PlayRequest playAsync() { + final PlayRequest out = new PlayRequest(); + out.ready(new SuccessCallback() { + @Override + public void onSucess(AsyncMedia value) { + if (out == pendingPlayRequest) { + pendingPlayRequest = null; + } + } + }).except(new SuccessCallback() { + @Override + public void onSucess(Throwable value) { + if (out == pendingPlayRequest) { + pendingPlayRequest = null; + } + } + }); + ; + if (pendingPlayRequest != null) { + pendingPlayRequest.ready(new SuccessCallback() { + @Override + public void onSucess(AsyncMedia value) { + if (!out.isDone()) { + out.complete(value); + } + } + }).except(new SuccessCallback() { + @Override + public void onSucess(Throwable value) { + if (!out.isDone()) { + out.error(value); + } + } + }); + return out; + } else { + pendingPlayRequest = out; + } + + ActionListener onStateChange = new ActionListener() { + @Override + public void actionPerformed(MediaStateChangeEvent evt) { + stateChangeListeners.removeListener(this); + if (!out.isDone()) { + if (evt.getNewState() == State.Playing) { + out.complete(Video.this); + } + } + + } + + }; + + stateChangeListeners.addListener(onStateChange); + play(); + + return out; + + } + + @Override + public PauseRequest pauseAsync() { + final PauseRequest out = new PauseRequest(); + out.ready(new SuccessCallback() { + @Override + public void onSucess(AsyncMedia value) { + if (out == pendingPauseRequest) { + pendingPauseRequest = null; + } + } + }).except(new SuccessCallback() { + @Override + public void onSucess(Throwable value) { + if (out == pendingPauseRequest) { + pendingPauseRequest = null; + } + } + }); + ; + if (pendingPauseRequest != null) { + pendingPauseRequest.ready(new SuccessCallback() { + @Override + public void onSucess(AsyncMedia value) { + if (!out.isDone()) { + out.complete(value); + } + } + }).except(new SuccessCallback() { + @Override + public void onSucess(Throwable value) { + if (!out.isDone()) { + out.error(value); + } + } + }); + return out; + } else { + pendingPauseRequest = out; + } + + ActionListener onStateChange = new ActionListener() { + @Override + public void actionPerformed(MediaStateChangeEvent evt) { + stateChangeListeners.removeListener(this); + if (!out.isDone()) { + if (evt.getNewState() == State.Paused) { + out.complete(Video.this); + } + } + + } + + }; + + stateChangeListeners.addListener(onStateChange); + play(); + + return out; + } + + + public Video(final VideoView nativeVideo, final Activity activity, final Runnable onCompletion) { + super(new RelativeLayout(activity)); + this.nativeVideo = nativeVideo; + RelativeLayout rl = (RelativeLayout)getNativePeer(); + + rl.addView(nativeVideo); + RelativeLayout.LayoutParams layout = new RelativeLayout.LayoutParams(getWidth(), getHeight()); + layout.addRule(RelativeLayout.CENTER_HORIZONTAL); + layout.addRule(RelativeLayout.CENTER_VERTICAL); + rl.setLayoutParams(layout); + rl.requestLayout(); + + this.activity = activity; + if (nativeController) { + MediaController mc = new AndroidImplementation.CN1MediaController(); + nativeVideo.setMediaController(mc); + } + + nativeVideo.setOnCompletionListener(new MediaPlayer.OnCompletionListener() { + @Override + public void onCompletion(MediaPlayer arg0) { + fireMediaStateChange(State.Paused); + + fireCompletionHandlers(); + } + }); + if (onCompletion != null) { + addCompletionHandler(onCompletion); + } + + nativeVideo.setOnErrorListener(new MediaPlayer.OnErrorListener() { + @Override + public boolean onError(MediaPlayer mp, int what, int extra) { + com.codename1.io.Log.p("Media player error: " + mp + " what: " + what + " extra: " + extra); + errorListeners.fireActionEvent(new MediaErrorEvent(Video.this, createMediaException(extra))); + fireMediaStateChange(State.Paused); + fireCompletionHandlers(); + return true; + } + }); + + } + + + + private void fireCompletionHandlers() { + if (completionHandlers != null && !completionHandlers.isEmpty()) { + Display.getInstance().callSerially(new Runnable() { + public void run() { + if (completionHandlers != null && !completionHandlers.isEmpty()) { + ArrayList toRun; + synchronized(Video.this) { + toRun = new ArrayList(completionHandlers); + } + for (Runnable r : toRun) { + r.run(); + } + } + } + }); + } + } + private void setNativeController(final boolean nativeController) { + if (nativeController != this.nativeController) { + this.nativeController = nativeController; + if (nativeVideo != null) { + Activity activity = getActivity(); + if (activity != null) { + activity.runOnUiThread(new Runnable() { + + @Override + public void run() { + if (nativeVideo != null) { + MediaController mc = new AndroidImplementation.CN1MediaController(); + nativeVideo.setMediaController(mc); + if (!nativeController) mc.setVisibility(View.GONE); + else mc.setVisibility(View.VISIBLE); + + } + } + + }); + } + + } + } + } + + @Override + public void init() { + super.init(); + setVisible(true); + } + + public void prepare() { + } + + @Override + public void play() { + Component cmp = getVideoComponent(); + if (cmp.getParent() == null && nativePlayer && curentForm == null) { + curentForm = Display.getInstance().getCurrent(); + Form f = new Form(); + f.setBackCommand(new Command("") { + @Override + public void actionPerformed(ActionEvent evt) { + Component cmp = getVideoComponent(); + if(cmp != null) { + cmp.remove(); + pause(); + } + curentForm.showBack(); + curentForm = null; + } + }); + f.setLayout(new BorderLayout()); + + if(cmp.getParent() != null) { + cmp.getParent().removeComponent(cmp); + } + f.addComponent(BorderLayout.CENTER, cmp); + f.show(); + } + nativeVideo.start(); + fireMediaStateChange(State.Playing); + } + + @Override + public void pause() { + if(nativeVideo != null && nativeVideo.canPause()){ + nativeVideo.pause(); + fireMediaStateChange(State.Paused); + } + } + + @Override + public void cleanup() { + if(nativeVideo != null) { + nativeVideo.stopPlayback(); + fireMediaStateChange(State.Paused); + } + nativeVideo = null; + if (nativePlayer && curentForm != null) { + curentForm.showBack(); + curentForm = null; + } + } + + @Override + public int getTime() { + if(nativeVideo != null){ + return nativeVideo.getCurrentPosition(); + } + return -1; + } + + @Override + public void setTime(int time) { + if(nativeVideo != null){ + final int seekTime = time; + activity.runOnUiThread(new Runnable() { + @Override + public void run() { + if (nativeVideo == null) { + return; + } + nativeVideo.seekTo(seekTime); + if (androidSeekPreviewWorkaroundEnabled && !nativeVideo.isPlaying()) { + final int refreshSeekTime = Math.max(0, seekTime - 1); + nativeVideo.postDelayed(new Runnable() { + @Override + public void run() { + if (nativeVideo != null && !nativeVideo.isPlaying()) { + nativeVideo.seekTo(refreshSeekTime); + nativeVideo.seekTo(seekTime); + nativeVideo.invalidate(); + } + } + }, 60); + } + } + }); + } + } + + @Override + public int getDuration() { + if(nativeVideo != null){ + return nativeVideo.getDuration(); + } + return -1; + } + + @Override + public void setVolume(int vol) { + // float v = ((float) vol) / 100.0F; + AudioManager am = (AudioManager) activity.getSystemService(Context.AUDIO_SERVICE); + int max = am.getStreamMaxVolume(AudioManager.STREAM_MUSIC); + am.setStreamVolume(AudioManager.STREAM_MUSIC, vol, 0); + } + + @Override + public int getVolume() { + AudioManager am = (AudioManager) activity.getSystemService(Context.AUDIO_SERVICE); + return am.getStreamVolume(AudioManager.STREAM_MUSIC); + } + + @Override + public boolean isVideo() { + return true; + } + + @Override + public boolean isFullScreen() { + return fullScreen || nativePlayer; + } + + @Override + public void setFullScreen(boolean fullScreen) { + this.fullScreen = fullScreen; + if (fullScreen) { + bounds = new Rectangle(getBounds()); + setX(0); + setY(0); + setWidth(Display.getInstance().getDisplayWidth()); + setHeight(Display.getInstance().getDisplayHeight()); + } else { + if (bounds != null) { + setX(bounds.getX()); + setY(bounds.getY()); + setWidth(bounds.getSize().getWidth()); + setHeight(bounds.getSize().getHeight()); + } + } + repaint(); + } + + @Override + public Component getVideoComponent() { + return this; + } + + @Override + protected Dimension calcPreferredSize() { + if(nativeVideo != null){ + return new Dimension(nativeVideo.getWidth(), nativeVideo.getHeight()); + } + return new Dimension(); + } + + @Override + public void setWidth(final int width) { + super.setWidth(width); + final int currH = getHeight(); + if(nativeVideo != null){ + activity.runOnUiThread(new Runnable() { + + public void run() { + float nh = nativeVideo.getHeight(); + float nw = nativeVideo.getWidth(); + float w = width; + float h = currH; + if (nh != 0 && nw != 0) { + h = width * nh / nw; + if (h > getHeight()) { + h = getHeight(); + w = h * nw / nh; + } + if (w > getWidth()) { + w = getWidth(); + h = w * nh / nw; + } + } + RelativeLayout.LayoutParams layout = new RelativeLayout.LayoutParams((int)w, (int)h); + layout.addRule(RelativeLayout.CENTER_HORIZONTAL); + layout.addRule(RelativeLayout.CENTER_VERTICAL); + nativeVideo.setLayoutParams(layout); + nativeVideo.requestLayout(); + nativeVideo.getHolder().setSizeFromLayout(); + } + }); + } + } + + @Override + public void setHeight(final int height) { + super.setHeight(height); + final int currW = getWidth(); + if(nativeVideo != null){ + activity.runOnUiThread(new Runnable() { + + public void run() { + float nh = nativeVideo.getHeight(); + float nw = nativeVideo.getWidth(); + float h = height; + float w = currW; + if (nh != 0 && nw != 0) { + w = h * nw / nh; + if (h > getHeight()) { + h = getHeight(); + w = h * nw / nh; + } + if (w > getWidth()) { + w = getWidth(); + h = w * nh / nw; + } + } + RelativeLayout.LayoutParams layout = new RelativeLayout.LayoutParams((int)w, (int)h); + layout.addRule(RelativeLayout.CENTER_HORIZONTAL); + layout.addRule(RelativeLayout.CENTER_VERTICAL); + nativeVideo.setLayoutParams(layout); + nativeVideo.requestLayout(); + nativeVideo.getHolder().setSizeFromLayout(); + } + }); + } + } + + @Override + public void setNativePlayerMode(boolean nativePlayer) { + this.nativePlayer = nativePlayer; + } + + @Override + public boolean isNativePlayerMode() { + return nativePlayer; + } + + @Override + public boolean isPlaying() { + if(nativeVideo != null){ + return nativeVideo.isPlaying(); + } + return false; + } + + public void setVariable(String key, Object value) { + if (nativeVideo != null && Media.VARIABLE_NATIVE_CONTRLOLS_EMBEDDED.equals(key) && value instanceof Boolean) { + setNativeController((Boolean)value); + return; + } + if (Media.VARIABLE_ANDROID_SEEK_PREVIEW_WORKAROUND.equals(key) && value instanceof Boolean) { + androidSeekPreviewWorkaroundEnabled = ((Boolean)value).booleanValue(); + } + } + + public Object getVariable(String key) { + return null; + } + + @Override + public void addMediaCompletionHandler(Runnable onComplete) { + addCompletionHandler(onComplete); + } + + + + private void addCompletionHandler(Runnable onCompletion) { + synchronized(this) { + if (completionHandlers == null) { + completionHandlers = new ArrayList(); + } + completionHandlers.add(onCompletion); + } + } + + private void removeCompletionHandler(Runnable onCompletion) { + synchronized(this) { + if (completionHandlers != null) { + completionHandlers.remove(onCompletion); + } + } + } + + + } + + + private String getImageFilePath(Uri uri) { + String scheme = uri.getScheme(); + String[] filePathColumn = {MediaStore.Images.Media.DATA}; + Cursor cursor = getContext().getContentResolver().query( + android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, + new String[]{ MediaStore.Images.Media.DATA}, + null, + null, + null + ); + // Some gallery providers may return an empty cursor on modern Android builds. + String filePath = null; + if (cursor != null) { + try { + int columnIndex = cursor.getColumnIndex(filePathColumn[0]); + if (columnIndex >= 0 && cursor.moveToFirst()) { + filePath = cursor.getString(columnIndex); + } + } finally { + cursor.close(); + } + } + + if (filePath == null || "content".equals(scheme)) { + //if the file is not on the filesystem download it and save it + //locally + InputStream inputStream = null; + OutputStream tmp = null; + try { + inputStream = getContext().getContentResolver().openInputStream(uri); + if (inputStream != null) { + String name = new File(uri.toString()).getName();//getContentName(getContext().getContentResolver(), uri); + if (name != null) { + String homePath = getAppHomePath(); + if (homePath.endsWith("/")) { + homePath = homePath.substring(0, homePath.length()-1); + } + filePath = homePath + + getFileSystemSeparator() + name; + File f = new File(removeFilePrefix(filePath)); + tmp = createFileOuputStream(f); + Util.copy(inputStream, tmp); + } + } + } catch (Exception e) { + com.codename1.io.Log.e(e); + } finally { + Util.cleanup(tmp); + Util.cleanup(inputStream); + } + } + return filePath; + } + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent intent) { + + if (requestCode == ZOOZ_PAYMENT) { + ((IntentResultListener) pur).onActivityResult(requestCode, resultCode, intent); + return; + } + + takePersistablePermissionsFromIntent(intent); + + if (requestCode == REQUEST_SELECT_FILE || requestCode == FILECHOOSER_RESULTCODE) { + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + if (requestCode == REQUEST_SELECT_FILE) { + if (uploadMessage == null) return; + Uri[] results = null; + + // Check that the response is a good one + if (resultCode == Activity.RESULT_OK) { + if (intent != null) { + // If there is not data, then we may have taken a photo + String dataString = intent.getDataString(); + ClipData clipData = intent.getClipData(); + + if (clipData != null) { + results = new Uri[clipData.getItemCount()]; + for (int i = 0; i < clipData.getItemCount(); i++) { + ClipData.Item item = clipData.getItemAt(i); + results[i] = item.getUri(); + } + } else if (dataString != null) { + results = new Uri[]{Uri.parse(dataString)}; + } + } + } + + uploadMessage.onReceiveValue(results); + uploadMessage = null; + } + } + else if (requestCode == FILECHOOSER_RESULTCODE) { + if (null == mUploadMessage) { + return; + } + // Use MainActivity.RESULT_OK if you're implementing WebView inside Fragment + // Use RESULT_OK only if you're implementing WebView inside an Activity + Uri result = intent == null || resultCode != Activity.RESULT_OK ? null : intent.getData(); + mUploadMessage.onReceiveValue(result); + mUploadMessage = null; + } + else { + + Toast.makeText(getActivity().getApplicationContext(), "Failed to Upload File", Toast.LENGTH_LONG).show(); + } + return; + } + + + if (resultCode == Activity.RESULT_OK) { + if (requestCode == CAPTURE_IMAGE) { + try { + String imageUri = (String) Storage.getInstance().readObject("imageUri"); + Vector pathandId = StringUtil.tokenizeString(imageUri, ";"); + String path = (String)pathandId.get(0); + String lastId = (String)pathandId.get(1); + Storage.getInstance().deleteStorageFile("imageUri"); + clearMediaDB(lastId, path); + callback.fireActionEvent(new ActionEvent(addFile(path))); + return; + } catch (Exception e) { + e.printStackTrace(); + } + } else if (requestCode == CAPTURE_VIDEO) { + String path = (String) Storage.getInstance().readObject("videoUri"); + Storage.getInstance().deleteStorageFile("videoUri"); + callback.fireActionEvent(new ActionEvent(addFile(path))); + return; + } else if (requestCode == CAPTURE_AUDIO) { + Uri data = intent.getData(); + String path = convertImageUriToFilePath(data, getContext()); + callback.fireActionEvent(new ActionEvent(addFile(path))); + return; + + } else if (requestCode == OPEN_GALLERY_MULTI) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) { + if(intent.getClipData() != null){ + // If it was a multi-request + ArrayList selectedPaths = new ArrayList(); + int count = intent.getClipData().getItemCount(); + for (int i=0; i= 0 && cursor.moveToFirst()) { + filePath = cursor.getString(columnIndex); + } + } finally { + cursor.close(); + } + } + boolean fileExists = false; + if (filePath != null) { + File file = new File(filePath); + fileExists = file.exists() && file.canRead(); + } + + if (!fileExists && "content".equals(scheme)) { + //if the file is not on the filesystem download it and save it + //locally + try { + InputStream inputStream = getContext().getContentResolver().openInputStream(selectedImage); + if (inputStream != null) { + String name = getContentName(getContext().getContentResolver(), selectedImage); + if (name != null) { + filePath = getAppHomePath() + + getFileSystemSeparator() + name; + File f = new File(removeFilePrefix(filePath)); + OutputStream tmp = createFileOuputStream(f); + byte[] buffer = new byte[1024]; + int read = -1; + while ((read = inputStream.read(buffer)) > -1) { + tmp.write(buffer, 0, read); + } + tmp.close(); + inputStream.close(); + } + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + if (filePath == null) { + callback.fireActionEvent(null); + return; + } + + callback.fireActionEvent(new ActionEvent(new String[]{filePath})); + return; + } else if (requestCode == OPEN_GALLERY) { + + Uri selectedImage = intent.getData(); + String scheme = intent.getScheme(); + + String[] filePathColumn = {MediaStore.Images.Media.DATA}; + Cursor cursor = getContext().getContentResolver().query(selectedImage, filePathColumn, null, null, null); + + // Some gallery providers may return an empty cursor on modern Android builds. + String filePath = null; + if (cursor != null) { + try { + int columnIndex = cursor.getColumnIndex(filePathColumn[0]); + if (columnIndex >= 0 && cursor.moveToFirst()) { + filePath = cursor.getString(columnIndex); + } + } finally { + cursor.close(); + } + } + boolean fileExists = false; + if (filePath != null) { + File file = new File(filePath); + fileExists = file.exists() && file.canRead(); + } + + if (!fileExists && "content".equals(scheme)) { + //if the file is not on the filesystem download it and save it + //locally + try { + InputStream inputStream = getContext().getContentResolver().openInputStream(selectedImage); + if (inputStream != null) { + String name = getContentName(getContext().getContentResolver(), selectedImage); + if (name != null) { + filePath = getAppHomePath() + + getFileSystemSeparator() + name; + File f = new File(removeFilePrefix(filePath)); + OutputStream tmp = createFileOuputStream(f); + byte[] buffer = new byte[1024]; + int read = -1; + while ((read = inputStream.read(buffer)) > -1) { + tmp.write(buffer, 0, read); + } + tmp.close(); + inputStream.close(); + } + } + } catch (Exception e) { + e.printStackTrace(); + } + } + + if (filePath == null) { + callback.fireActionEvent(null); + return; + } + + callback.fireActionEvent(new ActionEvent(filePath)); + return; + } else { + if(callback != null) { + callback.fireActionEvent(new ActionEvent("ok")); + } + return; + } + } + //clean imageUri + String imageUri = (String) Storage.getInstance().readObject("imageUri"); + if(imageUri != null){ + Storage.getInstance().deleteStorageFile("imageUri"); + } + + if(callback != null) { + callback.fireActionEvent(null); + } + } + + + + @Override + public void capturePhoto(ActionListener response) { + if (getActivity() == null) { + throw new RuntimeException("Cannot capture photo in background mode"); + } + if (PermissionsHelper.requiresExternalStoragePermissionForMediaAccess()) { + if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to take a picture")){ + return; + } + } + + if (getRequestedPermissions().contains(Manifest.permission.CAMERA)) { + // Normally we don't need to request the CAMERA permission since we use + // the ACTION_IMAGE_CAPTURE intent, which handles permissions itself. + // BUT: If the camera permission is included in the Manifest file, the + // intent will defer to the app's permissions, and on Android 6, + // the permission is denied unless we do the runtime check for permission. + // See https://github.com/codenameone/CodenameOne/issues/2409#issuecomment-391696058 + if(!checkForPermission(Manifest.permission.CAMERA, "This is required to take a picture")){ + return; + } + } + callback = new EventDispatcher(); + callback.addListener(response); + Intent intent = new Intent(android.provider.MediaStore.ACTION_IMAGE_CAPTURE); + + File newFile = getOutputMediaFile(false); + newFile.getParentFile().mkdirs(); + newFile.getParentFile().setWritable(true, false); + //Uri imageUri = Uri.fromFile(newFile); + Uri imageUri = FileProvider.getUriForFile(getContext(), getContext().getPackageName()+".provider", newFile); + intent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, imageUri); + + String lastImageID = getLastImageId(); + Storage.getInstance().writeObject("imageUri", newFile.getAbsolutePath() + ";" + lastImageID); + + intent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, imageUri); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + + if (Build.VERSION.SDK_INT < 21) { + List resInfoList = getContext().getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); + for (ResolveInfo resolveInfo : resInfoList) { + String packageName = resolveInfo.activityInfo.packageName; + getContext().grantUriPermission(packageName, imageUri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + } + + getActivity().startActivityForResult(intent, CAPTURE_IMAGE); + } + + @Override + public void captureVideo(ActionListener response) { + captureVideo(null, response); + } + + @Override + public void captureVideo(VideoCaptureConstraints cnst, ActionListener response) { + if (getActivity() == null) { + throw new RuntimeException("Cannot capture video in background mode"); + } + if (PermissionsHelper.requiresExternalStoragePermissionForMediaAccess()) { + if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to take a video")){ + return; + } + } + + if (getRequestedPermissions().contains(Manifest.permission.CAMERA)) { + // Normally we don't need to request the CAMERA permission since we use + // the ACTION_VIDEO_CAPTURE intent, which handles permissions itself. + // BUT: If the camera permission is included in the Manifest file, the + // intent will defer to the app's permissions, and on Android 6, + // the permission is denied unless we do the runtime check for permission. + // See https://github.com/codenameone/CodenameOne/issues/2409#issuecomment-391696058 + if(!checkForPermission(Manifest.permission.CAMERA, "This is required to take a video")){ + return; + } + } + callback = new EventDispatcher(); + callback.addListener(response); + Intent intent = new Intent(android.provider.MediaStore.ACTION_VIDEO_CAPTURE); + if (cnst != null) { + switch (cnst.getQuality()) { + case VideoCaptureConstraints.QUALITY_LOW: + intent.putExtra(MediaStore.EXTRA_VIDEO_QUALITY, 0); + break; + case VideoCaptureConstraints.QUALITY_HIGH: + intent.putExtra(MediaStore.EXTRA_VIDEO_QUALITY, 1); + break; + } + + if (cnst.getMaxFileSize() > 0) { + intent.putExtra(MediaStore.EXTRA_SIZE_LIMIT, cnst.getMaxFileSize()); + } + if (cnst.getMaxLength() > 0) { + intent.putExtra(MediaStore.EXTRA_DURATION_LIMIT, cnst.getMaxLength()); + } + } + + + File newFile = getOutputMediaFile(true); + newFile.getParentFile().mkdirs(); + newFile.getParentFile().setWritable(true, false); + Uri videoUri = FileProvider.getUriForFile(getContext(), getContext().getPackageName()+".provider", newFile); + + Storage.getInstance().writeObject("videoUri", newFile.getAbsolutePath()); + + intent.putExtra(android.provider.MediaStore.EXTRA_OUTPUT, videoUri); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + if (Build.VERSION.SDK_INT < 21) { + List resInfoList = getContext().getPackageManager().queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY); + for (ResolveInfo resolveInfo : resInfoList) { + String packageName = resolveInfo.activityInfo.packageName; + getContext().grantUriPermission(packageName, videoUri, Intent.FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + } + + this.getActivity().startActivityForResult(intent, CAPTURE_VIDEO); + } + + public void captureAudio(final ActionListener response) { + + if(!checkForPermission(Manifest.permission.RECORD_AUDIO, "This is required to record the audio")){ + return; + } + + try { + final Form current = Display.getInstance().getCurrent(); + + final File temp = File.createTempFile("mtmp", ".3gpp"); + temp.deleteOnExit(); + + if (recorder != null) { + recorder.release(); + } + recorder = new MediaRecorder(); + recorder.setAudioSource(MediaRecorder.AudioSource.MIC); + recorder.setOutputFormat(MediaRecorder.OutputFormat.THREE_GPP); + recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AMR_WB); + recorder.setOutputFile(temp.getAbsolutePath()); + + final Form recording = new Form("Recording"); + recording.setTransitionInAnimator(CommonTransitions.createEmpty()); + recording.setTransitionOutAnimator(CommonTransitions.createEmpty()); + recording.setLayout(new BorderLayout()); + + recorder.prepare(); + recorder.start(); + + final Label time = new Label("00:00"); + time.getAllStyles().setAlignment(Component.CENTER); + Font f = Font.createSystemFont(Font.FACE_SYSTEM, Font.STYLE_PLAIN, Font.SIZE_LARGE); + f = f.derive(getDisplayHeight() / 10, Font.STYLE_PLAIN); + time.getAllStyles().setFont(f); + recording.addComponent(BorderLayout.CENTER, time); + + recording.registerAnimated(new Animation() { + + long current = System.currentTimeMillis(); + long zero = current; + int sec = 0; + + public boolean animate() { + long now = System.currentTimeMillis(); + if (now - current > 1000) { + current = now; + sec++; + return true; + } + return false; + } + + public void paint(Graphics g) { + int seconds = sec % 60; + int minutes = sec / 60; + + String secStr = seconds < 10 ? "0" + seconds : "" + seconds; + String minStr = minutes < 10 ? "0" + minutes : "" + minutes; + + String txt = minStr + ":" + secStr; + time.setText(txt); + } + }); + + Container south = new Container(new com.codename1.ui.layouts.GridLayout(1, 2)); + Command cancel = new Command("Cancel") { + + @Override + public void actionPerformed(ActionEvent evt) { + if (recorder != null) { + recorder.stop(); + recorder.release(); + recorder = null; + } + current.showBack(); + response.actionPerformed(null); + } + + }; + recording.setBackCommand(cancel); + south.add(new com.codename1.ui.Button(cancel)); + south.add(new com.codename1.ui.Button(new Command("Save") { + + @Override + public void actionPerformed(ActionEvent evt) { + if (recorder != null) { + recorder.stop(); + recorder.release(); + recorder = null; + } + current.showBack(); + response.actionPerformed(new ActionEvent(temp.getAbsolutePath())); + } + + })); + recording.addComponent(BorderLayout.SOUTH, south); + recording.show(); + + } catch (IOException ex) { + ex.printStackTrace(); + throw new RuntimeException("failed to start audio recording"); + } + + } + + /** + * Opens the device image gallery + * + * @param response callback for the resulting image + * + * + * DISABLING: openGallery() should take care of this + public void openImageGallery(ActionListener response) { + if (getActivity() == null) { + throw new RuntimeException("Cannot open image gallery in background mode"); + } + if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to browse the photos")){ + return; + } + + if(editInProgress()) { + stopEditing(true); + } + + callback = new EventDispatcher(); + callback.addListener(response); + Intent galleryIntent = new Intent(Intent.ACTION_PICK, android.provider.MediaStore.Images.Media.INTERNAL_CONTENT_URI); + this.getActivity().startActivityForResult(galleryIntent, OPEN_GALLERY); + } + * */ + + @Override + public boolean isGalleryTypeSupported(int type) { + if (super.isGalleryTypeSupported(type)) { + return true; + } + if (type == -9999 || type == -9998) { + return true; + } + if (android.os.Build.VERSION.SDK_INT >= 16) { + switch (type) { + + case Display.GALLERY_ALL_MULTI: + case Display.GALLERY_VIDEO_MULTI: + case Display.GALLERY_IMAGE_MULTI: + return true; + } + } + return false; + } + + + + public void openGallery(final ActionListener response, int type){ + if (!isGalleryTypeSupported(type)) { + throw new IllegalArgumentException("Gallery type "+type+" not supported on this platform."); + } + if (getActivity() == null) { + throw new RuntimeException("Cannot open galery in background mode"); + } + if (PermissionsHelper.requiresExternalStoragePermissionForMediaAccess()) { + if(!checkForPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, "This is required to browse the photos")){ + return; + } + } + if(editInProgress()) { + stopEditing(true); + } + final boolean multi; + switch (type) { + case Display.GALLERY_ALL_MULTI: + multi=true; + type = Display.GALLERY_ALL; + break; + case Display.GALLERY_VIDEO_MULTI: + multi=true; + type = Display.GALLERY_VIDEO; + break; + case Display.GALLERY_IMAGE_MULTI: + multi = true; + type = Display.GALLERY_IMAGE; + break; + case -9998: + multi = true; + type = -9999; + break; + default: + multi = false; + } + + callback = new EventDispatcher(); + callback.addListener(response); + Intent galleryIntent = new Intent(Intent.ACTION_PICK, android.provider.MediaStore.Images.Media.INTERNAL_CONTENT_URI); + galleryIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + if (multi) { + galleryIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true); + } + if(type == Display.GALLERY_VIDEO){ + galleryIntent.setType("video/*"); + }else if(type == Display.GALLERY_IMAGE){ + galleryIntent.setType("image/*"); + }else if(type == Display.GALLERY_ALL){ + galleryIntent.setType("image/* video/*"); + }else if (type == -9999) { + galleryIntent = new Intent(); + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { + galleryIntent.setAction(Intent.ACTION_OPEN_DOCUMENT); + } else { + galleryIntent.setAction(Intent.ACTION_GET_CONTENT); + } + galleryIntent.addCategory(Intent.CATEGORY_OPENABLE); + galleryIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { + galleryIntent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); + } + + // set MIME type for image + galleryIntent.setType("*/*"); + galleryIntent.putExtra(Intent.EXTRA_MIME_TYPES, Display.getInstance().getProperty("android.openGallery.accept", "*/*").split(",")); + }else{ + galleryIntent.setType("*/*"); + } + this.getActivity().startActivityForResult(galleryIntent, multi ? OPEN_GALLERY_MULTI: OPEN_GALLERY); + } + + @Override + public void openFileChooser(final ActionListener response, String accept) { + if (getActivity() == null) { + throw new RuntimeException("Cannot open file chooser in background mode"); + } + if(editInProgress()) { + stopEditing(true); + } + callback = new EventDispatcher(); + callback.addListener(response); + Intent pickerIntent = new Intent(); + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { + pickerIntent.setAction(Intent.ACTION_OPEN_DOCUMENT); + } else { + pickerIntent.setAction(Intent.ACTION_GET_CONTENT); + } + pickerIntent.addCategory(Intent.CATEGORY_OPENABLE); + pickerIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.KITKAT) { + pickerIntent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); + } + String[] mimeTypes = getFileChooserMimeTypes(accept); + pickerIntent.setType("*/*"); + if (mimeTypes.length > 0) { + pickerIntent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes); + } + this.getActivity().startActivityForResult(pickerIntent, OPEN_GALLERY); + } + + private String[] getFileChooserMimeTypes(String accept) { + if (accept == null || accept.trim().length() == 0 || "*/*".equals(accept.trim())) { + return new String[0]; + } + ArrayList out = new ArrayList(); + String[] tokens = accept.split(","); + for (int iter = 0; iter < tokens.length; iter++) { + String token = tokens[iter].trim(); + if (token.length() == 0 || "*".equals(token)) { + continue; + } + if (token.indexOf('/') > 0) { + out.add(token); + } + } + if (out.isEmpty()) { + out.add("*/*"); + } + return out.toArray(new String[out.size()]); + } + + class NativeImage extends Image { + + public NativeImage(Bitmap nativeImage) { + super(nativeImage); + } + } + + /** + * Persist read permissions that were granted by an activity result so that media playback can + * continue after {@link Activity#onActivityResult(int, int, Intent)} returns. + * + *

Android 13 and newer revoke temporary grants immediately after the callback unless the + * app calls {@link ContentResolver#takePersistableUriPermission(Uri, int)}. Without this call + * {@link #createMedia(String, boolean, Runnable)} loses access to the {@code content://} URI + * provided by the system picker and playback fails on Android 15.

+ */ + private void takePersistablePermissionsFromIntent(Intent intent) { + if (intent == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { + return; + } + int takeFlags = intent.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + if (takeFlags == 0) { + return; + } + ContentResolver resolver = getContext().getContentResolver(); + if (resolver == null) { + return; + } + ClipData clip = intent.getClipData(); + if (clip != null) { + for (int i = 0; i < clip.getItemCount(); i++) { + Uri uri = clip.getItemAt(i).getUri(); + if (uri != null) { + try { + resolver.takePersistableUriPermission(uri, takeFlags); + } catch (SecurityException ignored) { + } + } + } + } + Uri dataUri = intent.getData(); + if (dataUri != null) { + try { + resolver.takePersistableUriPermission(dataUri, takeFlags); + } catch (SecurityException ignored) { + } + } + } + + /** + * Create a File for saving an image or video + */ + private File getOutputMediaFile(boolean isVideo) { + // To be safe, you should check that the SDCard is mounted + // using Environment.getExternalStorageState() before doing this. + if (getActivity() != null) { + return GetOutputMediaFile.getOutputMediaFile(isVideo, getActivity()); + } else { + return GetOutputMediaFile.getOutputMediaFile(isVideo, getContext(), "Video"); + } + } + + private static class GetOutputMediaFile { + + public static File getOutputMediaFile(boolean isVideo,Activity activity) { + activity.getComponentName(); + return getOutputMediaFile(isVideo, activity, activity.getTitle()); + } + + public static File getOutputMediaFile(boolean isVideo, Context activity, CharSequence title) { + + + File mediaStorageDir = new File(new File(getContext().getCacheDir(), "intent_files"), ""+title); + + // Create the storage directory if it does not exist + if (!mediaStorageDir.exists()) { + if (!mediaStorageDir.mkdirs()) { + Log.d(Display.getInstance().getProperty("AppName", "CodenameOne"), "failed to create directory"); + return null; + } + } + + // Create a media file name + String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date()); + File mediaFile = null; + if (!isVideo) { + mediaFile = new File(mediaStorageDir.getPath() + File.separator + + "IMG_" + timeStamp + ".jpg"); + } else { + mediaFile = new File(mediaStorageDir.getPath() + File.separator + + "VID_" + timeStamp + ".mp4"); + } + + return mediaFile; + } + } + + @Override + 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; + } + + @Override + public void registerPush(Hashtable metaData, boolean noFallback) { + if (getActivity() == null) { + return; + } + + if (android.os.Build.VERSION.SDK_INT >= 33) { + if(!checkForPermission("android.permission.POST_NOTIFICATIONS", "This is required to receive push notifications")){ + return; + } + } + + 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!"); + return; + } + String id = ""; + if (!huawei) { + id = (String)metaData.get(com.codename1.push.Push.GOOGLE_PUSH_KEY); + if (id == null) { + id = Display.getInstance().getProperty("gcm.sender_id", null); + } + } + Log.d("Codename One", "Sending async push request for id: " + id); + ((CodenameOneActivity) getActivity()).registerForPush(id); + } + + public static void stopPollingLoop() { + stopPolling(); + } + + public static void registerPolling() { + registerPollingFallback(); + } + + @Override + public void deregisterPush() { + boolean has = hasAndroidMarket() + || "huawei".equals(Display.getInstance().getProperty("cn1.push.transport", "")); + if (has) { + ((CodenameOneActivity) getActivity()).stopReceivingPush(); + deregisterPushFromServer(); + } else { + super.deregisterPush(); + } + } + + private static String convertImageUriToFilePath(Uri imageUri, Context activity) { + Cursor cursor = null; + String[] proj = {MediaStore.Images.Media.DATA}; + cursor = activity.getContentResolver().query(imageUri, proj, null, null, null); + int column_index = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA); + cursor.moveToFirst(); + String path = cursor.getString(column_index); + cursor.close(); + return path; + } + + class CN1MediaController extends MediaController { + + public CN1MediaController() { + super(getActivity()); + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + int keycode = event.getKeyCode(); + keycode = CodenameOneView.internalKeyCodeTranslate(keycode); + if (keycode == AndroidImplementation.DROID_IMPL_KEY_BACK) { + // Claim the gesture so the activity's OnBackInvokedCallback + // stands down; on Android 16 the platform can deliver both for + // one press. See PredictiveBackBridge. The claim brackets the + // DOWN and the UP even though this path answers each of them + // with a whole press/release pair of its own. + switch (event.getAction()) { + case KeyEvent.ACTION_DOWN: + PredictiveBackBridge.keyEventBackStarted(); + break; + case KeyEvent.ACTION_UP: + PredictiveBackBridge.keyEventBackFinished(); + break; + default: + break; + } + Display.getInstance().keyPressed(keycode); + Display.getInstance().keyReleased(keycode); + return true; + } else { + return super.dispatchKeyEvent(event); + } + } + } + private L10NManager l10n; + + /** + * @inheritDoc + */ + public L10NManager getLocalizationManager() { + if (l10n == null) { + final Locale l = Locale.getDefault(); + l10n = new L10NManager(l.getLanguage(), l.getCountry()) { + public double parseDouble(String localeFormattedDecimal) { + try { + return NumberFormat.getNumberInstance().parse(localeFormattedDecimal).doubleValue(); + } catch (ParseException err) { + return Double.parseDouble(localeFormattedDecimal); + } + } + + @Override + public String getLongMonthName(Date date) { + java.text.SimpleDateFormat fmt = new java.text.SimpleDateFormat("MMMM", l); + return fmt.format(date); + } + + @Override + public String getShortMonthName(Date date) { + java.text.SimpleDateFormat fmt = new java.text.SimpleDateFormat("MMM", l); + return fmt.format(date); + } + + + + public String format(int number) { + return NumberFormat.getNumberInstance().format(number); + } + + public String format(double number) { + return NumberFormat.getNumberInstance().format(number); + } + + public String formatCurrency(double currency) { + return NumberFormat.getCurrencyInstance().format(currency); + } + + public String formatDateLongStyle(Date d) { + return DateFormat.getDateInstance(DateFormat.LONG).format(d); + } + + public String formatDateShortStyle(Date d) { + return DateFormat.getDateInstance(DateFormat.SHORT).format(d); + } + + public String formatDateTime(Date d) { + return DateFormat.getDateTimeInstance().format(d); + } + + public String formatDateTimeMedium(Date d) { + DateFormat dd = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM); + return dd.format(d); + } + + public String formatDateTimeShort(Date d) { + DateFormat dd = DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT); + return dd.format(d); + } + + public String getCurrencySymbol() { + return NumberFormat.getInstance().getCurrency().getSymbol(); + } + + public void setLocale(String locale, String language) { + super.setLocale(locale, language); + Locale l = new Locale(language, locale); + Locale.setDefault(l); + } + }; + } + return l10n; + } + private com.codename1.ui.util.ImageIO imIO; + + private com.codename1.media.VideoIO videoIO; + private boolean videoIOResolved; + + @Override + public com.codename1.media.VideoIO getVideoIO() { + if (!videoIOResolved) { + videoIOResolved = true; + if (android.os.Build.VERSION.SDK_INT >= 21) { + videoIO = new AndroidVideoIO(); + } + } + return videoIO; + } + + @Override + public com.codename1.ui.util.ImageIO getImageIO() { + if (imIO == null) { + imIO = new com.codename1.ui.util.ImageIO() { + @Override + public Dimension getImageSize(String imageFilePath) throws IOException { + BitmapFactory.Options o = new BitmapFactory.Options(); + o.inJustDecodeBounds = true; + o.inPreferredConfig = Bitmap.Config.ARGB_8888; + + InputStream fis = createFileInputStream(imageFilePath); + BitmapFactory.decodeStream(fis, null, o); + fis.close(); + + ExifInterface exif = new ExifInterface(removeFilePrefix(imageFilePath)); + + // if the image is in portrait mode + int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); + if(orientation == ExifInterface.ORIENTATION_ROTATE_90 || orientation == ExifInterface.ORIENTATION_ROTATE_270) { + return new Dimension(o.outHeight, o.outWidth); + } + return new Dimension(o.outWidth, o.outHeight); + } + + private Dimension getImageSizeNoRotation(String imageFilePath) throws IOException { + BitmapFactory.Options o = new BitmapFactory.Options(); + o.inJustDecodeBounds = true; + o.inPreferredConfig = Bitmap.Config.ARGB_8888; + + InputStream fis = createFileInputStream(imageFilePath); + BitmapFactory.decodeStream(fis, null, o); + fis.close(); + + return new Dimension(o.outWidth, o.outHeight); + } + + @Override + public void save(InputStream image, OutputStream response, String format, int width, int height, float quality) throws IOException { + Bitmap.CompressFormat f = Bitmap.CompressFormat.PNG; + if (FORMAT_JPEG.equals(format)) { + f = Bitmap.CompressFormat.JPEG; + } + Image img = Image.createImage(image).scaled(width, height); + Bitmap b = (Bitmap) img.getImage(); + b.compress(f, (int) (quality * 100), response); + } + + @Override + public String saveAndKeepAspect(String imageFilePath, String preferredOutputPath, String format, int width, int height, float quality, boolean onlyDownscale, boolean scaleToFill) throws IOException{ + ExifInterface exif = new ExifInterface(removeFilePrefix(imageFilePath)); + Dimension d = getImageSizeNoRotation(imageFilePath); + if(onlyDownscale) { + if(scaleToFill) { + if(d.getHeight() <= height || d.getWidth() <= width) { + return imageFilePath; + } + } else { + if(d.getHeight() <= height && d.getWidth() <= width) { + return imageFilePath; + } + } + } + + float ratio = ((float)d.getWidth()) / ((float)d.getHeight()); + int heightBasedOnWidth = (int)(((float)width) / ratio); + int widthBasedOnHeight = (int)(((float)height) * ratio); + if(scaleToFill) { + if(heightBasedOnWidth >= width) { + height = heightBasedOnWidth; + } else { + width = widthBasedOnHeight; + } + } else { + if(heightBasedOnWidth > width) { + width = widthBasedOnHeight; + } else { + height = heightBasedOnWidth; + } + } + sampleSizeOverride = Math.max(d.getWidth()/width, d.getHeight()/height); + OutputStream im = FileSystemStorage.getInstance().openOutputStream(preferredOutputPath); + Image i = Image.createImage(imageFilePath); + Image newImage = i.scaled(width, height); + int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); + + int angle = 0; + switch (orientation) { + case ExifInterface.ORIENTATION_ROTATE_90: + angle = 90; + break; + case ExifInterface.ORIENTATION_ROTATE_180: + angle = 180; + break; + case ExifInterface.ORIENTATION_ROTATE_270: + angle = 270; + break; + } + if (angle != 0) { + Matrix mat = new Matrix(); + mat.postRotate(angle); + Bitmap b = (Bitmap)newImage.getImage(); + Bitmap correctBmp = Bitmap.createBitmap(b, 0, 0, b.getWidth(), b.getHeight(), mat, true); + b.recycle(); + newImage.dispose(); + Image tmp = Image.createImage(correctBmp); + newImage = tmp; + save(tmp, im, format, quality); + } else { + save(imageFilePath, im, format, width, height, quality); + } + sampleSizeOverride = -1; + return preferredOutputPath; + } + + @Override + public void save(String imageFilePath, OutputStream response, String format, int width, int height, float quality) throws IOException { + Image i = Image.createImage(imageFilePath); + Image newImage = i.scaled(width, height); + save(newImage, response, format, quality); + newImage.dispose(); + i.dispose(); + } + + @Override + protected void saveImage(Image img, OutputStream response, String format, float quality) throws IOException { + Bitmap.CompressFormat f = Bitmap.CompressFormat.PNG; + if (FORMAT_JPEG.equals(format)) { + f = Bitmap.CompressFormat.JPEG; + } + Bitmap b = (Bitmap) img.getImage(); + b.compress(f, (int) (quality * 100), response); + } + + @Override + public boolean isFormatSupported(String format) { + return FORMAT_JPEG.equals(format) || FORMAT_PNG.equals(format); + } + }; + } + return imIO; + } + + @Override + public Database openOrCreateDB(String databaseName) throws IOException { + // Reserved first, and recovery run inside the reservation. The slot has to be taken + // before the engine opens anything, or a conversion reading the count during the open + // starts replacing the file this is about to hand back -- and recovery has to be inside + // it too, because a conversion that has just installed its converted file leaves the live + // file and the backup both present, which recovery would otherwise read as a completed + // conversion and act on by deleting the backup. + String nativePath = resolveNativeDatabasePath(databaseName); + reserveDatabaseConnection(nativePath); + SQLiteDatabase db; + try { + // A plaintext open of a database mid-conversion would create an empty one over the + // top of the real data, which nothing afterwards could undo. + // + // One connection is allowed to be open here, and it is the reservation taken above. + // Anything beyond that is somebody else's handle -- including one taken through the + // constructor that wraps an already-open connection -- and recovery moves the file + // out from under it. When that is the case and a conversion is waiting to be + // finished, this open is refused rather than handing back a file recovery is going + // to replace; with nothing waiting there is nothing to recover and the open goes + // ahead as before. + recoverIfSoleConnection(nativePath); + if (databaseName.startsWith("file://")) { + db = SQLiteDatabase.openOrCreateDatabase( + FileSystemStorage.getInstance().toNativePath(databaseName), null, + KEEP_ON_CORRUPTION); + } else { + db = getContext().openOrCreateDatabase(databaseName, getContext().MODE_PRIVATE, + null, KEEP_ON_CORRUPTION); + } + } catch (RuntimeException didNotOpen) { + databaseConnectionClosed(nativePath); + // The engine reports a file it cannot read by throwing an unchecked + // SQLiteDatabaseCorruptException, and an encrypted database opened without its key is + // exactly that to the plain engine. This API promises every failure as an IOException, + // so the caller can catch one thing rather than an unchecked type per platform. + throw new IOException("The database " + databaseName + " could not be opened: " + + didNotOpen.getMessage(), didNotOpen); + } catch (IOException didNotRecover) { + databaseConnectionClosed(nativePath); + throw didNotRecover; + } + return new AndroidDB(db, nativePath); + } + + @Override + public Database openOrCreateDB(String databaseName, com.codename1.db.DatabaseConfig config) throws IOException { + if (config == null || !config.isEncrypted()) { + return openOrCreateDB(databaseName); + } + // The slot is taken before the engine opens anything, for the reason given in + // openOrCreateDB. AndroidCipherFactory hands back a connection that already holds it. + String nativePath = resolveNativeDatabasePath(databaseName); + reserveDatabaseConnection(nativePath); + // The SQLCipher-backed package is deleted at build time for apps that never touch + // DatabaseConfig, so it has to be reached reflectively - the same arrangement the + // ARCore-backed AR implementation uses. + Object opened; + try { + Class c = Class.forName("com.codename1.impl.android.cipher.AndroidCipherFactory"); + java.lang.reflect.Method open = c.getMethod("open", String.class, String.class, + String.class); + // Cast outside the try, below: inside a block that catches Throwable, a wrong type + // from the reflective call would be swallowed and reported as the package being + // absent. The resolved file, not the name it was asked for: a managed key with no explicit + // alias is stored under whatever is passed here, so two accepted spellings of one + // database would derive two different keys and the second open would report a wrong + // key against data that is perfectly intact. + opened = open.invoke(null, + resolveNativeDatabasePath(databaseName), databaseName, + config.resolveKeyMaterial(databaseKey(nativePath))); + } catch (java.lang.reflect.InvocationTargetException err) { + releaseUnusedDatabaseConnection(nativePath); + Throwable cause = err.getCause(); + if (cause instanceof IOException) { + throw (IOException) cause; + } + throw new IOException(cause == null ? err.toString() : cause.getMessage(), cause); + } catch (IOException err) { + releaseUnusedDatabaseConnection(nativePath); + throw err; + } catch (ClassNotFoundException notBundled) { + // The only benign reason to land here: the build pruned the package because the + // application never referenced DatabaseConfig. + releaseUnusedDatabaseConnection(nativePath); + throw new com.codename1.db.DatabaseEncryptionException( + com.codename1.db.DatabaseEncryptionException.NOT_SUPPORTED, + "This build does not include encrypted database support", notBundled); + } catch (NoSuchMethodException broken) { + // The package is present but does not expose the entry point this reaches through. + // That is a broken build, not an unsupported platform, and reporting it as + // NOT_SUPPORTED would hide it: every caller would be told encryption is unavailable + // on a device that ships the engine. This is the failure mode a compiler would have + // caught if the seam were not reflective, so it has to be loud. + releaseUnusedDatabaseConnection(nativePath); + throw new IOException("The encrypted database implementation is present but does not " + + "expose the expected entry point. This build is inconsistent: " + + broken.getMessage(), broken); + } catch (Throwable err) { + releaseUnusedDatabaseConnection(nativePath); + throw new com.codename1.db.DatabaseEncryptionException( + com.codename1.db.DatabaseEncryptionException.NOT_SUPPORTED, + "This build does not include encrypted database support", err); + } + if (!(opened instanceof Database)) { + releaseUnusedDatabaseConnection(nativePath); + throw new IOException("The encrypted database implementation returned " + + (opened == null ? "nothing" : opened.getClass().getName()) + + " rather than a Database. This build is inconsistent."); + } + return (Database) opened; + } + + /// The file an implicit managed key is stored under; see the open path, which resolves the + /// same way so two spellings of one database derive one key. + @Override + public String databaseManagedKeyIdentity(String databaseName) { + // Canonical, like the connection registry: resolveNativeDatabasePath leaves a custom + // spelling as it was given, so "/data/app/./db.sqlite" and "/data/app/db.sqlite" would + // otherwise pick different stored keys for one file and report the second open as wrong. + return databaseKey(resolveNativeDatabasePath(databaseName)); + } + + @Override + public boolean isDatabaseEncryptionSupported() { + Object available; + try { + Class c = Class.forName("com.codename1.impl.android.cipher.AndroidCipherFactory"); + available = c.getMethod("isAvailable").invoke(null); + } catch (Throwable notPresent) { + return false; + } + // Tested rather than cast inside the try: the reflective answer is untyped, and + // anything but a Boolean means the feature is unavailable rather than absent. + return available instanceof Boolean && ((Boolean) available).booleanValue(); + } + + @Override + public boolean isDatabaseManagedKeyHardwareBacked() { + // Ask the key itself. An API level says only that the API exists: emulators, and plenty of + // real devices, back AndroidKeyStore keys in software. Applications are told they may use + // this to refuse to store sensitive data, so it has to describe the actual key. + return AndroidSecureStorage.isPlainKeyInsideSecureHardware(); + } + + /** + * Absolute filesystem path for a database name, converting a custom file:// URL. + * + * getDatabasePath() deliberately echoes a file:// URL back unchanged, which is right for + * callers that hand it to FileSystemStorage but wrong for anything constructing a java.io.File + * from it. + */ + /// Directory holding the encrypted-database migration's working files. + /// + /// A directory beside the database, so the rename that installs the converted file stays + /// within one filesystem and is therefore atomic. + /// + /// The location alone does not make these files ours. Custom paths mean an application can + /// point a database anywhere, including inside here, so ownership is established by the + /// marker's contents rather than by where a file sits or what it is called. Nothing is + /// deleted, renamed over or truncated without that proof. + public static final String DATABASE_MIGRATION_DIR = ".cn1migration"; + + /// Marker name for a database. Deterministic so recovery can find it; its contents, not its + /// name, are what establish that a conversion wrote it. + public static final String MIGRATION_MARKER = ".marker"; + + /// Fourth line of a marker whose installed file was never shown to open. + private static final String MIGRATION_UNVALIDATED = "unvalidated"; + + /// First line of a marker written by this port. + private static final String MIGRATION_MARKER_MAGIC = "codename1-database-migration-1"; + + /// The migration directory for a database, or null if the path has no parent. + public static File databaseMigrationDir(String path) { + File parent = new File(path).getParentFile(); + return parent == null ? null : new File(parent, DATABASE_MIGRATION_DIR); + } + + public static File databaseMigrationMarker(String path) { + File dir = databaseMigrationDir(path); + return dir == null ? null : new File(dir, new File(path).getName() + MIGRATION_MARKER); + } + + /// Reads a marker written by this port, or null when the file is not one of ours. + /// + /// A marker is trusted only if it opens with the magic line. Anything else - including an + /// application database that happens to live at this path - is left alone. + /// + /// The two entries after it are the file holding the original and the export being built, + /// either of which may be absent: the marker is written before the export is filled in and + /// rewritten once the original has been moved aside, so which files exist depends on how far + /// the conversion got. + /// + /// What this does NOT defend against, deliberately: an actor who can write in the migration + /// directory can still write a marker naming files inside it. The magic line is in the + /// source, so it authenticates nothing -- and there is no secret this port could sign a + /// marker with that the same actor could not read out of the application. The damage is + /// bounded to that one directory, which that actor can already write to and delete from + /// directly, so the check earns its keep by keeping the names inside it rather than by + /// pretending the file is trusted. + /// + /// A rejected marker is treated as somebody else's file: recovery leaves it alone and a + /// conversion refuses to start rather than overwriting it, with a message naming the file. A + /// crafted marker therefore stops conversions of that one database until it is removed, which + /// is the outcome to prefer over acting on it. + /// + /// @return the two names, either element null, or null if this is not our marker + private static String[] readDatabaseMigrationMarker(String path) { + File marker = databaseMigrationMarker(path); + if (marker == null || !marker.isFile()) { + return null; + } + BufferedReader reader = null; + try { + reader = new BufferedReader(new InputStreamReader(new FileInputStream(marker), + "UTF-8")); + if (!MIGRATION_MARKER_MAGIC.equals(reader.readLine())) { + return null; + } + String backup = reader.readLine(); + String target = reader.readLine(); + String state = reader.readLine(); + String backupName = backup == null || backup.length() == 0 ? null : backup; + String targetName = target == null || target.length() == 0 ? null : target; + // The names this port writes are basenames createTempFile produced in the migration + // directory, and they are read back as files to truncate, delete and rename over. A + // marker is a plain text file beside the database, so where the database sits + // somewhere another actor can write -- which a custom path can -- an entry like + // "../../../files/secret" would be resolved against that directory and handed to the + // cleanup, which truncates and deletes what it is given. Anything that is not a + // simple name inside this directory means the file is not one of ours, which is the + // answer that stops every caller: recovery leaves it alone and a conversion refuses + // to overwrite it rather than starting. + File dir = databaseMigrationDir(path); + if ((backupName != null && !isMigrationEntryName(backupName, dir)) + || (targetName != null && !isMigrationEntryName(targetName, dir))) { + return null; + } + return new String[] { + backupName, + targetName, + state == null || state.length() == 0 ? null : state, + }; + } catch (IOException unreadable) { + return null; + } finally { + if (reader != null) { + try { + reader.close(); + } catch (IOException ignored) { + // Nothing useful to do. + } + } + } + } + + /// Whether a name a marker carries is one this port could have written there. + /// + /// A generated basename, and a file that really is a direct child of the migration directory: + /// the first rejects a path that climbs out of it, the second rejects a name inside it that + /// is a link to somewhere else. Both are checked because either alone can be walked around -- + /// a name with no separator can still be a symlink, and a canonical check on its own would + /// accept "sub/dir/../file". + /// + /// #### Parameters + /// + /// - `name`: the entry read from the marker + /// - `directory`: the migration directory the marker lives in + /// + /// #### Returns + /// + /// true if the name is safe to resolve against that directory + private static boolean isMigrationEntryName(String name, File directory) { + if (directory == null || name.length() == 0 || ".".equals(name) || "..".equals(name)) { + return false; + } + if (name.indexOf('/') >= 0 || name.indexOf('\\') >= 0 || name.indexOf('\u0000') >= 0) { + return false; + } + try { + File resolved = new File(directory, name).getCanonicalFile(); + File parent = resolved.getParentFile(); + return parent != null && parent.equals(directory.getCanonicalFile()); + } catch (IOException cannotResolve) { + // A name that cannot be resolved is not one that gets acted on. + return false; + } + } + + /// Whether the marker for this database was written by this port. + /// + /// Distinct from having a backup: a marker written before the export was filled in names no + /// backup yet, and is still ours to rewrite. + private static boolean ownsDatabaseMigrationMarker(String path) { + return readDatabaseMigrationMarker(path) != null; + } + + /// Reads the backup a marker claims, or null when there is none. + public static File readDatabaseMigrationBackup(String path) { + String[] entry = readDatabaseMigrationMarker(path); + if (entry == null || entry[0] == null) { + return null; + } + return new File(databaseMigrationMarker(path).getParentFile(), entry[0]); + } + + /// Whether the marker says its installed file was never shown to open. + private static boolean isDatabaseMigrationUnvalidated(String path) { + String[] entry = readDatabaseMigrationMarker(path); + return entry != null && entry.length > 2 && MIGRATION_UNVALIDATED.equals(entry[2]); + } + + /// Reads the export a marker claims, or null when there is none. + /// + /// The export is a second complete copy of the data, and a plaintext one when the conversion + /// was a decryption, so it is recorded before anything is written into it. Otherwise a process + /// death between creating it and finishing the conversion would leave readable data behind + /// under a name nothing knows to look for. + public static File readDatabaseMigrationTarget(String path) { + String[] entry = readDatabaseMigrationMarker(path); + if (entry == null || entry[1] == null) { + return null; + } + return new File(databaseMigrationMarker(path).getParentFile(), entry[1]); + } + + /// Every database connection this port has open, by the file it is open on. + /// + /// Shared by both implementations on purpose. Only a conversion needs it, and a conversion is + /// not a statement: it renames a new file over the database while the process is running, and + /// Android lets that succeed while another connection holds the old one. That connection goes + /// on writing to a file that is no longer the database, is told each write succeeded, and + /// loses all of it when the backup is deleted. + /// + /// The connection it collides with is usually not another encrypted one -- the ordinary case + /// is an application holding `Database.openOrCreate(name)` open, which is a plaintext + /// connection, and then calling `Database.encrypt(name, ...)`. Counting only the encrypted + /// ones would miss exactly the case that happens. + private static final java.util.Map OPEN_DATABASE_CONNECTIONS = + new java.util.HashMap(); + + /// The key a database file is tracked under. + /// + /// Canonical, because two spellings of one file must not be two entries: a connection opened + /// as `/data/app/db.sqlite` has to be visible to a conversion started as + /// `/data/app/./db.sqlite`, or the file is replaced underneath it and its later writes -- each + /// one reported as successful -- disappear with the old inode. `toNativePath` only strips the + /// `file://` prefix, so a custom path arrives however the caller spelled it. + /// + /// Falls back to the absolute path when the file system cannot answer, which still collapses + /// the relative spellings; a canonical path that cannot be resolved is not a reason to refuse + /// to open a database. + /// The canonical identity of a database file, for callers outside this class. + /// + /// The cipher package resolves a managed key against it, so that its key change and the next + /// open agree on which file they are talking about. + public static String canonicalDatabaseKey(String path) { + return databaseKey(path); + } + + private static String databaseKey(String path) { + if (path == null) { + return null; + } + try { + return new File(path).getCanonicalPath(); + } catch (IOException cannotResolve) { + return new File(path).getAbsolutePath(); + } + } + + /// Records a connection opened on a database file. + public static synchronized void databaseConnectionOpened(String rawPath) { + String path = databaseKey(rawPath); + if (path == null) { + return; + } + Integer count = OPEN_DATABASE_CONNECTIONS.get(path); + OPEN_DATABASE_CONNECTIONS.put(path, + Integer.valueOf(count == null ? 1 : count.intValue() + 1)); + } + + /// Records a connection closed on a database file. + public static synchronized void databaseConnectionClosed(String rawPath) { + String path = databaseKey(rawPath); + if (path == null) { + return; + } + Integer count = OPEN_DATABASE_CONNECTIONS.get(path); + if (count == null) { + return; + } + if (count.intValue() <= 1) { + OPEN_DATABASE_CONNECTIONS.remove(path); + } else { + OPEN_DATABASE_CONNECTIONS.put(path, Integer.valueOf(count.intValue() - 1)); + } + } + + /// Database files a conversion currently owns exclusively. + private static final java.util.Set MIGRATING_DATABASES = + new java.util.HashSet(); + + /// Claims a database for a conversion, or refuses. + /// + /// Counting the connections and then converting are one decision, not two. Between a count + /// read on its own and the rename that ends the conversion, another thread can open the + /// database, and that connection then holds the file the rename replaces: its writes are + /// accepted and disappear when the backup goes. So the count is read and the claim taken + /// under the same lock the opens take, and an open that arrives afterwards is refused for as + /// long as the conversion runs. + /// + /// #### Parameters + /// + /// - `path`: the database file + /// + /// #### Throws + /// + /// - `IOException`: if the database is open elsewhere, or already being converted + public static synchronized void beginDatabaseMigration(String rawPath) throws IOException { + String path = databaseKey(rawPath); + if (MIGRATING_DATABASES.contains(path)) { + throw new IOException("The database " + path + " is already being converted."); + } + Integer count = OPEN_DATABASE_CONNECTIONS.get(path); + if (count != null && count.intValue() > 1) { + throw new IOException("The database " + path + " is open more than once, and " + + "converting it replaces the file underneath every connection to it. Close " + + "the other connections first; writes made through them during the " + + "conversion would be accepted and then lost."); + } + MIGRATING_DATABASES.add(path); + } + + /// Recovers an interrupted conversion, but only for an open that has the file to itself. + /// + /// Called from the open paths, plaintext and encrypted, each of which has already reserved + /// its own connection -- so one open connection is this caller and anything beyond it is + /// somebody else's handle, including one taken through the constructor that wraps an + /// already-open connection. Recovery renames the live file aside and puts a backup back, and + /// a connection attached to the displaced file keeps accepting writes that go nowhere, so it + /// is left for the next open that has the file alone. + /// + /// #### Parameters + /// + /// - `rawPath`: the database file + /// + /// #### Throws + /// + /// - `IOException`: if the recovery itself fails + public static void recoverIfSoleConnection(String rawPath) throws IOException { + if (claimDatabaseForRecovery(rawPath, 1)) { + try { + recoverInterruptedDatabaseMigration(rawPath); + } finally { + endDatabaseMigration(rawPath); + } + return; + } + if (hasInterruptedDatabaseMigration(rawPath)) { + // Recovery could not run and there is work waiting for it, which means the file this + // open would hand back is one recovery is going to replace. Two handles writing to it + // in the meantime would both be told their writes succeeded, and the next open with + // the file to itself would restore the backup over the top of them. Refusing is the + // only answer that does not accept writes it cannot keep. + throw new IOException("The database " + rawPath + " has a conversion that was " + + "interrupted, and it cannot be finished while another connection holds the " + + "file. Close the other connections and open it again; the data is intact " + + "and will be put back then."); + } + } + + /// Whether a conversion of this database was interrupted and still has work waiting. + /// + /// A marker this port wrote is the record of that. One written by something else is not ours + /// to read, and recovery leaves it alone for the same reason. + /// + /// #### Parameters + /// + /// - `rawPath`: the database file + /// + /// #### Returns + /// + /// true when recovery has something to do + private static boolean hasInterruptedDatabaseMigration(String rawPath) { + File marker = databaseMigrationMarker(rawPath); + return marker != null && marker.isFile() && ownsDatabaseMigrationMarker(rawPath); + } + + /// Takes the conversion claim for a recovery, or reports that a conversion already holds it. + /// + /// Recovery moves the same three files a conversion does, so the two must not overlap. The + /// claim is the conversion's own, so a conversion starting while recovery runs is refused by + /// `#beginDatabaseMigration(String)` exactly as a second conversion would be. + /// + /// #### Parameters + /// + /// - `rawPath`: the database file + /// + /// #### Returns + /// + /// true when the claim was taken and must be given back + private static synchronized boolean claimDatabaseForRecovery(String rawPath, + int connectionsOfOurOwn) { + String path = databaseKey(rawPath); + if (path == null || MIGRATING_DATABASES.contains(path)) { + return false; + } + Integer count = OPEN_DATABASE_CONNECTIONS.get(path); + if (count != null && count.intValue() > connectionsOfOurOwn) { + // Somebody else holds the file. Recovery renames the live file aside and puts a + // backup back, and a connection already attached to the displaced file keeps + // accepting writes that go nowhere -- worst of all for a conversion whose converted + // file was never validated, where the backup is what recovery installs. Refusing + // leaves the marker in place for the next open that has the file to itself. + return false; + } + MIGRATING_DATABASES.add(path); + return true; + } + + /// Whether a conversion currently owns a database file. + public static synchronized boolean isDatabaseBeingConverted(String rawPath) { + return MIGRATING_DATABASES.contains(databaseKey(rawPath)); + } + + /// Releases a database claimed by `#beginDatabaseMigration(String)`. + public static synchronized void endDatabaseMigration(String rawPath) { + MIGRATING_DATABASES.remove(databaseKey(rawPath)); + } + + /// Gives back a slot taken by `#reserveDatabaseConnection(String)` when no connection was + /// handed to the caller after all. + public static void releaseUnusedDatabaseConnection(String path) { + databaseConnectionClosed(path); + } + + /// Takes a connection slot on a database, or refuses because a conversion owns it. + /// + /// The check and the count are one step. Checking that no conversion is running and then + /// registering afterwards leaves a gap: the engine's open sits between them, and a conversion + /// that reads the count during it sees only its own connection, takes its claim, and starts + /// replacing the file the open is about to return a connection to. Taking the slot inside the + /// same lock as the check closes that -- a conversion either sees the slot and refuses, or + /// holds the claim and the open refuses. + /// + /// The caller releases the slot with `#databaseConnectionClosed(String)` if the open itself + /// then fails, and the connection releases it on close. + /// + /// #### Throws + /// + /// - `IOException`: if a conversion currently owns the file + public static synchronized void reserveDatabaseConnection(String rawPath) throws IOException { + String path = databaseKey(rawPath); + if (path != null && com.codename1.db.Database.isDatabaseBeingDeleted(path)) { + // The claim the delete holds, not one of this port's: it is taken before the count + // this method increments is read, so an open arriving mid-delete is refused here and + // an open that got in first is seen by that count. A claim of our own, taken when + // the delete reached this port, would have been too late -- the count had already + // been read by then, and an open landing in between would have been handed a file + // about to lose its name. + throw new IOException("The database " + path + " is being deleted and cannot be " + + "opened."); + } + if (path != null && MIGRATING_DATABASES.contains(path)) { + throw new IOException("The database " + path + " is being converted and cannot be " + + "opened until that finishes."); + } + databaseConnectionOpened(path); + } + + /// How many connections are open on a database file, encrypted or not. + public static synchronized int connectionsOpenOn(String rawPath) { + Integer count = OPEN_DATABASE_CONNECTIONS.get(databaseKey(rawPath)); + return count == null ? 0 : count.intValue(); + } + + /// Disposes of an export, and reports anything that survived. + /// + /// If the file cannot be unlinked it is truncated instead, which removes the contents even + /// where the directory entry survives. + /// + /// @return a sentence to append to a failure message, empty when nothing survived + public static String discardDatabaseMigrationExport(File target) { + if (target == null) { + return ""; + } + // The sidecars before anything else, and through the platform's own deletion, which knows + // the whole set: -wal, -shm, -journal and the master journals. A database written here + // leaves rows in those, so removing the file alone left the data behind under a name + // nobody was looking at -- which is the one thing this method exists to prevent. It is + // also the case that matters most, since the export is a complete copy of the database, + // in plaintext whenever the conversion was a decrypt. + android.database.sqlite.SQLiteDatabase.deleteDatabase(target); + String survivingSidecars = discardDatabaseSidecars(target); + if (!target.exists() || target.delete()) { + return survivingSidecars; + } + if (isSymbolicLink(target)) { + // Emptying follows the link, and what it would empty is whatever the link points at. + // The name was checked before any of this began, but a directory another actor can + // write to can have that name replaced afterwards, and unlinking a link that cannot + // be unlinked leaves this holding a name that now means somebody else's file. + // Reported instead: the export could not be removed, and nothing else is touched. + return " A complete copy of the data was left at " + target.getPath() + + ", which is now a link and was left alone; delete it." + survivingSidecars; + } + try { + new FileOutputStream(target).close(); + } catch (IOException cannotEmptyIt) { + return " A complete copy of the data was left at " + target.getPath() + + " and could not be removed; delete it." + survivingSidecars; + } + if (!target.exists() || target.delete()) { + return survivingSidecars; + } + return " An emptied file was left at " + target.getPath() + "." + survivingSidecars; + } + + /// Whether a name now resolves to something other than itself. + /// + /// Everything under the migration directory was checked to be a plain name inside it before + /// any of it was acted on. That check happens once, and a directory another actor can write to + /// can have an entry replaced between then and the cleanup -- so anything that opens a file + /// rather than unlinking it asks again, immediately before it opens it. + /// + /// Unlinking needs no such question: removing a link removes the link. Emptying does, because + /// a stream follows it and empties whatever it points at. + /// + /// Compares the canonical path with the absolute one rather than using a no-follow open, which + /// this port cannot reach at the API levels it supports. It does not close the window between + /// the question and the open, and cannot from Java; it does stop the case that makes the + /// window worth anything, which is a link that has been left in place because it could not be + /// unlinked. + /// + /// #### Parameters + /// + /// - `f`: the entry about to be opened + /// + /// #### Returns + /// + /// true if it is a link, or if that could not be determined + private static boolean isSymbolicLink(File f) { + try { + return !f.getCanonicalFile().equals(f.getAbsoluteFile()); + } catch (IOException cannotResolve) { + // Unresolvable is treated as a link: this only decides whether to open something, and + // not opening it costs a message where opening it could truncate another file. + return true; + } + } + + /// Disposes of the files SQLite keeps beside a database, and reports anything that survived. + /// + /// Called after the platform's own deletion rather than instead of it: that removes them in + /// the ordinary case, and this is what happens when one could not be unlinked. Emptying is + /// the fallback for the same reason it is for the database itself -- a file that cannot be + /// removed can still be stripped of what it holds. + /// + /// @param target the database file whose companions these are + /// @return a sentence to append to a failure message, empty when nothing survived + private static String discardDatabaseSidecars(File target) { + String[] suffixes = {"-wal", "-shm", "-journal"}; + StringBuilder left = new StringBuilder(); + for (int iter = 0; iter < suffixes.length; iter++) { + File sidecar = new File(target.getPath() + suffixes[iter]); + if (!sidecar.exists() || sidecar.delete()) { + continue; + } + if (isSymbolicLink(sidecar)) { + // As above: emptying a link empties its target, and the target is not ours. + left.append(" A working file was left at ").append(sidecar.getPath()) + .append(", which is now a link and was left alone."); + continue; + } + try { + new FileOutputStream(sidecar).close(); + } catch (IOException cannotEmptyIt) { + left.append(" Part of the data was left at ").append(sidecar.getPath()) + .append(" and could not be removed; delete it."); + continue; + } + if (sidecar.exists() && !sidecar.delete()) { + left.append(" An emptied file was left at ").append(sidecar.getPath()).append("."); + } + } + return left.toString(); + } + + /// Records that a conversion is under way and which file holds the original. + /// + /// The marker is the one file here whose name has to be predictable, because recovery has to + /// find it without being told. So it is the one place something could already be sitting - + /// an application may point a database at this exact path - and writing over it would + /// destroy that database. Anything already there that this port did not write means the + /// conversion does not start. + /// Marks a conversion whose installed file was never shown to open. + /// + /// Recovery reads a live file and a backup both being present as a completed conversion and + /// removes the backup. That is right when the converted file opened, and catastrophic when it + /// did not and could not be taken back out either: the last readable copy would go. This + /// records the difference, and recovery puts the backup back instead. + public static void markDatabaseMigrationUnvalidated(String path, File backup) + throws IOException { + writeMarker(path, backup, null, true); + } + + /// The same, for a conversion whose export has not been installed yet. + /// + /// The export has to stay named while it still exists under its own name, or recovery cannot + /// find it to clean it up -- and a conversion interrupted here leaves a complete copy of the + /// database in the migration directory, which after a decryption is a plaintext one. + /// + /// #### Parameters + /// + /// - `path`: the live database + /// - `backup`: the file the original was moved to + /// - `target`: the export, while it is still under its own name + /// + /// #### Throws + /// + /// - `IOException`: if the record cannot be written + public static void markDatabaseMigrationUnvalidated(String path, File backup, File target) + throws IOException { + writeMarker(path, backup, target, true); + } + + public static void writeDatabaseMigrationMarker(String path, File backup, File target) + throws IOException { + writeMarker(path, backup, target, false); + } + + private static void writeMarker(String path, File backup, File target, boolean unvalidated) + throws IOException { + File marker = databaseMigrationMarker(path); + if (marker == null) { + throw new IOException("The database " + path + " has no directory to convert it in"); + } + if (marker.exists() && !ownsDatabaseMigrationMarker(path)) { + throw new IOException("There is already a file at " + marker + " that this port did " + + "not write, so the conversion was not started rather than overwriting it. " + + "Move it aside if it is not a database you need."); + } + // Written beside the marker and renamed over it, never written into it. The second call + // updates a marker that is already valid and already naming a file holding data, and + // opening it for writing truncates it first: a process death in that window leaves a + // marker that recovery cannot recognise, so it acts on nothing and the export it named is + // orphaned. A rename is atomic, so the marker is only ever the old contents or the new. + // The marker's own name already carries the ".marker" suffix, so it is never short + // enough for createTempFile to reject the prefix. + File pending = File.createTempFile(marker.getName() + ".", ".pending", + marker.getParentFile()); + Writer writer = new OutputStreamWriter(new FileOutputStream(pending), "UTF-8"); + try { + writer.write(MIGRATION_MARKER_MAGIC); + writer.write("\n"); + writer.write(backup == null ? "" : backup.getName()); + writer.write("\n"); + writer.write(target == null ? "" : target.getName()); + writer.write("\n"); + writer.write(unvalidated ? MIGRATION_UNVALIDATED : ""); + writer.write("\n"); + } finally { + writer.close(); + } + // renameTo replaces an existing destination on the filesystems Android puts databases on. + // Deleting first would reopen exactly the window this is here to close. + if (!pending.renameTo(marker)) { + pending.delete(); + throw new IOException("The record of the conversion at " + marker + " could not be " + + "written, so the conversion was not started."); + } + } + + /// Restores a database whose conversion was interrupted between the two renames. + /// + /// Called before every open, encrypted or not. Encrypt and decrypt move the original aside + /// and install the converted file in its place, so a process death in that gap leaves a + /// complete database in the migration directory and nothing under the live name. Putting it + /// back is what makes that window recoverable rather than a silent empty database. + /// + /// Acts only on a marker this port wrote, and only on the backup that marker names. + public static void recoverInterruptedDatabaseMigration(String path) throws IOException { + if (path == null) { + return; + } + File marker = databaseMigrationMarker(path); + if (marker == null || !marker.isFile() || !ownsDatabaseMigrationMarker(path)) { + // Nothing of ours is here, and nothing of anybody else's gets touched. A file at this + // name that this port did not write belongs to someone -- a custom database path can + // legitimately put another database here -- and this runs before every open, so acting + // on it would mean that opening one database destroys an unrelated one. + return; + } + // The export first, whatever else is true. It is a second complete copy of the data, and + // a plaintext one when the conversion was a decryption, so an interrupted conversion must + // not leave it lying in the migration directory. It is only ever installed by being + // renamed over the live database, so anything still under its own name is an orphan. + File orphanedExport = readDatabaseMigrationTarget(path); + if (orphanedExport != null && orphanedExport.exists()) { + String surviving = discardDatabaseMigrationExport(orphanedExport); + if (surviving.length() > 0) { + throw new IOException("The database " + path + " has an interrupted conversion " + + "whose working copy could not be cleaned up." + surviving); + } + } + File backup = readDatabaseMigrationBackup(path); + if (backup == null) { + // No original was moved aside, so the conversion never reached the swap. Only the + // export existed, and it is gone. + marker.delete(); + return; + } + File live = new File(path); + if (!backup.isFile()) { + // The marker outlived its backup, so there is nothing to put back or clean up. + marker.delete(); + return; + } + if (!live.exists()) { + // Died between the two renames: the backup is the only copy. Put it back, and refuse + // to continue if that fails - opening would create an empty database over the top and + // the next conversion would remove the backup as stale, losing the data for good. + if (!backup.renameTo(live)) { + throw new IOException("The database " + path + " is mid-conversion and the copy " + + "holding its contents, at " + backup + ", could not be moved back. The " + + "data is intact in that file; the database was not opened rather than " + + "replacing it with an empty one."); + } + marker.delete(); + return; + } + if (isDatabaseMigrationUnvalidated(path)) { + // The converted file is in place but was never shown to open, and the conversion could + // not take it back out. Both files existing is not evidence of success here, so the + // backup goes back rather than away: deleting it would drop the last readable copy. + File displaced = unusedSibling(path + ".unvalidated"); + if (displaced == null) { + throw new IOException("The database " + path + " holds a converted file that was " + + "never shown to open, and there is nowhere to move it aside to. The " + + "original is intact at " + backup + "; nothing was overwritten."); + } + // Named in the marker before the first rename, in the slot an export is named in. + // The two renames below are not one step: a process dying between them leaves the + // converted file under a name nothing knows about, and the recovery after that takes + // the branch above -- restores the backup, deletes the marker, and leaves that file + // beside the database for good. After a failed decryption it is a plaintext copy. + // Recorded first, the next recovery finds it exactly where it finds an abandoned + // export, and discards it the same way. + try { + markDatabaseMigrationUnvalidated(path, backup, displaced); + } catch (IOException cannotRecord) { + throw new IOException("The database " + path + " holds a converted file that was " + + "never shown to open, and where it is about to be moved could not be " + + "recorded. The original is intact at " + backup + "; nothing was moved.", + cannotRecord); + } + if (!live.renameTo(displaced) || !backup.renameTo(live)) { + throw new IOException("The database " + path + " holds a converted file that was " + + "never shown to open, and the original at " + backup + " could not be " + + "put back. The data is in that file; it was left there rather than " + + "removed."); + } + // The same cleanup an abandoned export gets, and for the same reason: this file is a + // complete copy of the database, and after a failed decryption it is the plaintext + // one. A delete() whose result nobody reads would leave it beside the restored + // database under a predictable name while recovery reported success. + String surviving = discardDatabaseMigrationExport(displaced); + if (surviving.length() > 0) { + throw new IOException("The database " + path + " was restored from its backup, but" + + " the converted copy could not be removed." + surviving); + } + marker.delete(); + return; + } + // Both exist, so the swap completed and only the cleanup was lost. The backup is the + // database in its previous form, which after an encrypt is a plaintext copy of an + // encrypted database - the encryption-at-rest hole in slow motion. + if (!backup.delete() && backup.exists()) { + throw new IOException("The database " + path + " was converted, but the copy of its " + + "previous form at " + backup + " could not be removed. Delete it before " + + "relying on this database being encrypted."); + } + marker.delete(); + } + + /// A path near `preferred` that no file occupies, or null if too many are taken. + /// + /// The recovery moves the rejected file aside before putting the original back, and on these + /// filesystems a rename replaces whatever is at the destination. A custom database path can put + /// that destination anywhere the application also keeps files, so writing to it blind would let + /// a failed conversion destroy an unrelated file of the application's while reporting that it + /// recovered cleanly. + private static File unusedSibling(String preferred) { + File candidate = new File(preferred); + if (!candidate.exists()) { + return candidate; + } + for (int iter = 1; iter < 100; iter++) { + candidate = new File(preferred + "." + iter); + if (!candidate.exists()) { + return candidate; + } + } + return null; + } + + /// Removes the working files for a database, reporting anything it could not remove. + /// + /// Used by delete, where the caller's intent is that the data goes away. A failure here has + /// to stop the deletion: continuing would report success while a complete copy of the + /// database survives, and a later open would restore it. + static void discardDatabaseMigrationArtifacts(String path) throws IOException { + if (path == null) { + return; + } + File export = readDatabaseMigrationTarget(path); + if (export != null && export.exists()) { + String surviving = discardDatabaseMigrationExport(export); + if (surviving.length() > 0) { + throw new IOException("The database " + path + " was not deleted, because the " + + "working copy of its interrupted conversion could not be removed." + + surviving); + } + } + File backup = readDatabaseMigrationBackup(path); + if (backup == null) { + File onlyMarker = databaseMigrationMarker(path); + if (onlyMarker != null && onlyMarker.isFile() && ownsDatabaseMigrationMarker(path) + && !onlyMarker.delete() && onlyMarker.exists()) { + throw new IOException("The database " + path + " was not deleted, because the " + + "record of its interrupted conversion at " + onlyMarker + " could not " + + "be removed."); + } + return; + } + if (backup.exists() && !backup.delete() && backup.exists()) { + throw new IOException("The database " + path + " was not deleted, because the copy of " + + "it at " + backup + " could not be removed and a later open would restore " + + "it."); + } + File marker = databaseMigrationMarker(path); + if (marker.exists() && !marker.delete() && marker.exists()) { + throw new IOException("The database " + path + " was not deleted, because the record " + + "of its interrupted conversion at " + marker + " could not be removed."); + } + } + + /// Whether a marked migration backup is holding a database's contents. + static boolean hasRecoverableDatabaseBackup(String path) { + File backup = readDatabaseMigrationBackup(path); + return backup != null && backup.isFile(); + } + + /// Leaves a database that will not open where it is. + /// + /// The platform default answers corruption by deleting the file. An encrypted database opened + /// without its key is ciphertext to the plain engine, which is indistinguishable from + /// corruption -- so a single accidental openOrCreate(name) against an encrypted database + /// destroyed it, and destroyed it in the one case where the data was perfectly intact and one + /// correct-key open away from being readable. + /// + /// Keeping the file turns that into a failed open, which is what a wrong key should be. A + /// genuinely corrupt database is kept too, which is the answer every other port gives: + /// reporting the failure and leaving the bytes for a backup or a repair tool beats deleting + /// them on the application's behalf. + private static final class KeepDatabaseOnCorruption + implements android.database.DatabaseErrorHandler { + @Override + public void onCorruption(SQLiteDatabase databaseObject) { + com.codename1.io.Log.p("Database " + databaseObject.getPath() + " could not be read. " + + "It was left in place rather than deleted: an encrypted database opened " + + "without its key looks exactly like this."); + } + } + + private static final android.database.DatabaseErrorHandler KEEP_ON_CORRUPTION = + new KeepDatabaseOnCorruption(); + + private String resolveNativeDatabasePath(String databaseName) { + if (databaseName.startsWith("file://")) { + return FileSystemStorage.getInstance().toNativePath(databaseName); + } + return getDatabasePath(databaseName); + } + + @Override + public Database openOrCreateDBForRekey(String databaseName) throws IOException { + // The stock android.database.sqlite engine has no cipher, so a plaintext database opened + // through it can never be encrypted in place. Route the migration through SQLCipher, which + // opens an unencrypted file when given an empty key and can then rekey it. + if (!isDatabaseEncryptionSupported()) { + return openOrCreateDB(databaseName); + } + // The slot is taken before the engine opens anything, for the reason given in + // openOrCreateDB. AndroidCipherFactory hands back a connection that already holds it. + String nativePath = resolveNativeDatabasePath(databaseName); + reserveDatabaseConnection(nativePath); + Object opened; + try { + Class c = Class.forName("com.codename1.impl.android.cipher.AndroidCipherFactory"); + java.lang.reflect.Method open = c.getMethod("open", String.class, String.class, String.class); + // Cast below, outside the try, for the reason given in openOrCreateDB. + opened = open.invoke(null, + resolveNativeDatabasePath(databaseName), databaseName, ""); + } catch (java.lang.reflect.InvocationTargetException err) { + // The open threw, so no connection exists to release the slot later. A rekey open of + // a file that turns out to be encrypted lands here, and leaving the slot behind would + // make every later conversion of that database see a connection that is not there. + releaseUnusedDatabaseConnection(nativePath); + Throwable cause = err.getCause(); + if (cause instanceof IOException) { + throw (IOException) cause; + } + throw new IOException(cause == null ? err.toString() : cause.getMessage(), cause); + } catch (NoSuchMethodException broken) { + // Same reasoning as openOrCreateDB: falling back to the plaintext engine here would + // silently turn a re-key into a no-op on a build that does ship the cipher. + releaseUnusedDatabaseConnection(nativePath); + throw new IOException("The encrypted database implementation is present but does not " + + "expose the expected entry point. This build is inconsistent: " + + broken.getMessage(), broken); + } catch (Throwable err) { + releaseUnusedDatabaseConnection(nativePath); + return openOrCreateDB(databaseName); + } + if (!(opened instanceof Database)) { + releaseUnusedDatabaseConnection(nativePath); + throw new IOException("The encrypted database implementation returned " + + (opened == null ? "nothing" : opened.getClass().getName()) + + " rather than a Database. This build is inconsistent."); + } + return (Database) opened; + } + + @Override + public boolean isBlobQueryParameterSupported() { + return true; + } + + @Override + public boolean isDatabaseCustomPathSupported() { + return true; + } + + + + /// How many connections this port has open on a database, for the delete guard in core. + /// + /// This port counts connections in its own registry rather than the base class's, because the + /// conversion that consults them runs here. Answering from it is what makes + /// `Database.delete(String)` refuse on Android as it does everywhere else. + @Override + public int openDatabaseConnections(String databaseName) { + try { + return connectionsOpenOn(resolveNativeDatabasePath(databaseName)); + } catch (RuntimeException cannotResolve) { + // An unresolvable name cannot be matched against the registry. Reporting none leaves + // the delete to the checks below rather than refusing something that may be fine. + return 0; + } + } + + @Override + public void deleteDB(String databaseName) throws IOException { + String deletePath = resolveNativeDatabasePath(databaseName); + if (isDatabaseBeingConverted(deletePath)) { + // A conversion owns the file and its working copies. Deleting either underneath it + // would strand the data in whichever one the conversion has not installed yet. + throw new IOException("The database " + deletePath + " is being converted and cannot " + + "be deleted until that finishes."); + } + // The working files first. They survive deleting the live file, and the next open runs + // recovery and puts the backup back - so a database the caller was told had been deleted + // reappears, and after an interrupted encryption what reappears is the plaintext copy. + discardDatabaseMigrationArtifacts(deletePath); + if (databaseName.startsWith("file://")) { + // Through the platform's own deletion rather than by removing the file, which is what + // this used to do. A SQLite database is more than its file: a crash or a kill leaves + // -wal, -shm and -journal beside it, holding rows that were written, and for an + // encrypted database those rows are as readable as the pages they came from. Removing + // the file alone reported a successful delete and left them there, and the next open + // on the same name would read them back. deleteDatabase takes the sidecars and the + // master journals with it, which is exactly what the non-custom branch below has been + // getting from Context.deleteDatabase all along. + android.database.sqlite.SQLiteDatabase.deleteDatabase(new File(deletePath)); + } else { + getContext().deleteDatabase(databaseName); + } + requireDatabaseGone(deletePath); + } + + /// Reports anything the platform left behind, rather than trusting that it deleted it. + /// + /// Both calls above answer with a boolean and neither says what it could not remove -- + /// deleteDatabase ORs the results of deleting the file, the journal, the shared-memory index, + /// the write-ahead log and any master journals, so it answers true when the database file went + /// and a read-only or busy -wal stayed. Reading that boolean would therefore report success + /// over surviving pages just as ignoring it did, so this looks at the files instead. + /// + /// It matters most for the case this was added for: those files hold rows that were written, + /// and for an encrypted database they are as readable as the pages they came from. A caller + /// told the database was deleted has no reason to look, so the only chance to say so is here. + /// + /// #### Parameters + /// + /// - `path`: the database file, whose companions share its name + /// + /// #### Throws + /// + /// - `IOException`: naming whatever is still on disk + private void requireDatabaseGone(String path) throws IOException { + File database = new File(path); + StringBuilder left = new StringBuilder(); + if (database.exists()) { + left.append(' ').append(database.getPath()); + } + String[] sidecars = databaseSidecarPaths(path); + for (int iter = 0; iter < sidecars.length; iter++) { + File sidecar = new File(sidecars[iter]); + if (sidecar.exists()) { + left.append(' ').append(sidecar.getPath()); + } + } + // The master journals as well, which is why this lists the directory rather than checking + // three fixed names: SQLite names them -mj and there can be more than one. + File directory = database.getParentFile(); + if (directory != null) { + final String prefix = database.getName() + "-mj"; + File[] journals = directory.listFiles(); + if (journals != null) { + for (int iter = 0; iter < journals.length; iter++) { + if (journals[iter].getName().startsWith(prefix)) { + left.append(' ').append(journals[iter].getPath()); + } + } + } + } + if (left.length() > 0) { + throw new IOException("The database was not fully deleted. These files are still on " + + "disk and hold its data:" + left + ". Close every connection to it and try " + + "again, or remove them."); + } + } + + @Override + public boolean existsDB(String databaseName) { + // Recover first. A conversion interrupted between its two renames leaves the live name + // missing while the database itself sits complete in the migration directory, and + // reporting "does not exist" there would refuse a retry of encrypt or decrypt - the one + // operation that could put it right. + String path = resolveNativeDatabasePath(databaseName); + // The claim, not a look at it. Asking whether a conversion is running and then recovering + // are two steps, and a conversion starting in between would find recovery already moving + // its marker, target and backup around: depending on how far it had got, recovery would + // delete the export it was writing, restore the backup during the swap, or -- the worst + // of the three -- remove the backup before the converted file had been validated, which + // is the copy the conversion falls back to when the reopen fails. + if (!claimDatabaseForRecovery(path, 0)) { + // A conversion is mid-flight and owns both the live file and its working copies. + // Recovering underneath it would act on a half-installed state, so this answers from + // what the conversion has not yet consumed instead. + return hasRecoverableDatabaseBackup(path) || new File(path).exists(); + } + try { + recoverInterruptedDatabaseMigration(path); + } catch (IOException cannotRecover) { + // The data is still in the migration directory, so the database does exist even + // though it could not be moved back. Say so; the open will report the real problem. + return hasRecoverableDatabaseBackup(path); + } finally { + endDatabaseMigration(path); + } + if (databaseName.startsWith("file://")) { + return exists(databaseName); + } + File db = new File(getContext().getApplicationInfo().dataDir + "/databases/" + databaseName); + return db.exists(); + } + + public String getDatabasePath(String databaseName) { + if (databaseName.startsWith("file://")) { + return databaseName; + } + File db = new File(getContext().getApplicationInfo().dataDir + "/databases/" + databaseName); + return db.getAbsolutePath(); + } + + public boolean isNativeTitle() { + if(com.codename1.ui.Toolbar.isGlobalToolbar()) { + return false; + } + Form f = getCurrentForm(); + boolean nativeCommand; + if(f != null){ + nativeCommand = f.getMenuBar().getCommandBehavior() == Display.COMMAND_BEHAVIOR_NATIVE; + }else{ + nativeCommand = getCommandBehavior() == Display.COMMAND_BEHAVIOR_NATIVE; + } + return hasActionBar() && nativeCommand; + } + + public void refreshNativeTitle(){ + if (getActivity() == null || com.codename1.ui.Toolbar.isGlobalToolbar()) { + return; + } + Form f = getCurrentForm(); + if (f != null && isNativeTitle() && !(f instanceof Dialog)) { + getActivity().runOnUiThread(new SetCurrentFormImpl(getActivity(), f)); + } + } + + public void setCurrentForm(final Form f) { + if (getActivity() == null) { + return; + } + if(getCurrentForm() == null){ + flushGraphics(); + } + if(editInProgress()) { + stopEditing(true); + } + super.setCurrentForm(f); + if (isNativeTitle() && !(f instanceof Dialog)) { + getActivity().runOnUiThread(new SetCurrentFormImpl(getActivity(), f)); + } + } + + @Override + public void setNativeCommands(Vector commands) { + refreshNativeTitle(); + } + + @Override + public boolean isScreenLockSupported() { + return true; + } + + @Override + public void lockScreen(){ + ((CodenameOneActivity)getContext()).lockScreen(); + } + + @Override + public void unlockScreen(){ + ((CodenameOneActivity)getContext()).unlockScreen(); + } + + private static class SetCurrentFormImpl implements Runnable { + private Activity activity; + private Form f; + + public SetCurrentFormImpl(Activity activity, Form f) { + this.activity = activity; + this.f = f; + } + + @Override + public void run() { + if(com.codename1.ui.Toolbar.isGlobalToolbar()) { + return; + } + ActionBar ab = activity.getActionBar(); + String title = f.getTitle(); + boolean hasMenuBtn = false; + if(android.os.Build.VERSION.SDK_INT >= 14){ + try { + ViewConfiguration vc = ViewConfiguration.get(activity); + Method m = vc.getClass().getMethod("hasPermanentMenuKey", (Class[])null); + hasMenuBtn = ((Boolean)m.invoke(vc, (Object[])null)).booleanValue(); + } catch(Throwable t) { + t.printStackTrace(); + } + } + if((title != null && title.length() > 0) || (f.getCommandCount() > 0 && !hasMenuBtn)){ + activity.runOnUiThread(new NotifyActionBar(activity, true)); + }else{ + activity.runOnUiThread(new NotifyActionBar(activity, false)); + return; + } + + ab.setTitle(title); + ab.setDisplayHomeAsUpEnabled(f.getBackCommand() != null); + if(android.os.Build.VERSION.SDK_INT >= 14){ + Image icon = f.getTitleComponent().getIcon(); + try { + if(icon != null){ + ab.getClass().getMethod("setIcon", Drawable.class).invoke(ab, new BitmapDrawable(activity.getResources(), (Bitmap)icon.getImage())); + }else{ + if(activity.getApplicationInfo().icon != 0){ + ab.getClass().getMethod("setIcon", Integer.TYPE).invoke(ab, activity.getApplicationInfo().icon); + } + } + activity.runOnUiThread(new InvalidateOptionsMenuImpl(activity)); + } catch(Throwable t) { + t.printStackTrace(); + } + } + return; + } + + } + + private Purchase pur; + + @Override + public Purchase getInAppPurchase() { + try { + pur = ZoozPurchase.class.newInstance(); + return pur; + } catch(Throwable t) { + return super.getInAppPurchase(); + } + } + + @Override + public boolean isTimeoutSupported() { + return true; + } + + @Override + public void setTimeout(int t) { + timeout = t; + } + + @Override + public CodeScanner getCodeScanner() { + if(scannerInstance == null) { + scannerInstance = new CodeScannerImpl(); + } + return scannerInstance; + } + + public void addCookie(Cookie c, boolean addToWebViewCookieManager, boolean sync) { + if(addToWebViewCookieManager) { + CookieManager mgr; + CookieSyncManager syncer; + try { + syncer = CookieSyncManager.getInstance(); + mgr = getCookieManager(); + } catch(IllegalStateException ex) { + syncer = CookieSyncManager.createInstance(this.getContext()); + mgr = getCookieManager(); + } + java.text.SimpleDateFormat format = new java.text.SimpleDateFormat("EEE, dd-MMM-yyyy HH:mm:ss z"); + format.setTimeZone(TimeZone.getTimeZone("GMT")); + addCookie(c, mgr, format); + if(sync) { + syncer.sync(); + } + } + super.addCookie(c); + + + + } + + private void addCookie(Cookie c, CookieManager mgr, java.text.SimpleDateFormat format) { + + String d = c.getDomain(); + String port = ""; + if (d.contains(":")) { + // For some reason, the port must be stripped and stored separately + // or it won't retrieve it properly. + // https://github.com/codenameone/CodenameOne/issues/2804 + port = "; Port=" + d.substring(d.indexOf(":")+1); + d = d.substring(0, d.indexOf(":")); + } + String cookieString = c.getName() + "=" + c.getValue() + + "; Domain=" + d + + port + + "; Path=" + c.getPath() + + "; " + (c.isSecure() ? "Secure;" : "") + + (c.getExpires() != 0 ? (" Expires="+format.format(new Date(c.getExpires()))+";") : "") + + (c.isHttpOnly() ? "httpOnly;" : ""); + String cookieUrl = "http" + + (c.isSecure() ? "s" : "") + "://" + + d + + c.getPath(); + mgr.setCookie(cookieUrl, cookieString); + } + + public void addCookie(Cookie[] cs, boolean addToWebViewCookieManager, boolean sync) { + if(addToWebViewCookieManager) { + CookieManager mgr; + CookieSyncManager syncer; + try { + syncer = CookieSyncManager.getInstance(); + mgr = getCookieManager(); + } catch(IllegalStateException ex) { + syncer = CookieSyncManager.createInstance(this.getContext()); + mgr = getCookieManager(); + } + java.text.SimpleDateFormat format = new java.text.SimpleDateFormat("EEE, dd-MMM-yyyy HH:mm:ss z"); + format.setTimeZone(TimeZone.getTimeZone("GMT")); + + for (Cookie c : cs) { + addCookie(c, mgr, format); + + } + + if(sync) { + syncer.sync(); + } + } + super.addCookie(cs); + + + + } + + @Override + public void addCookie(Cookie c) { + if(isUseNativeCookieStore()) { + this.addCookie(c, true, true); + } else { + super.addCookie(c); + } + } + + + + @Override + public void addCookie(Cookie[] cookiesArray) { + if(isUseNativeCookieStore()) { + this.addCookie(cookiesArray, true); + } else { + super.addCookie(cookiesArray); + } + } + + public void addCookie(Cookie[] cookiesArray, boolean addToWebViewCookieManager){ + addCookie(cookiesArray, addToWebViewCookieManager, false); + + } + + + + class CodeScannerImpl extends CodeScanner implements IntentResultListener { + private ScanResult callback; + + @Override + public void scanQRCode(ScanResult callback) { + if (getActivity() == null) { + return; + } + if (getActivity() instanceof CodenameOneActivity) { + ((CodenameOneActivity) getActivity()).setIntentResultListener(this); + } + this.callback = callback; + IntentIntegrator in = new IntentIntegrator(getActivity()); + if(!in.initiateScan(IntentIntegrator.QR_CODE_TYPES, "QR_CODE_MODE")){ + // restore old activity handling + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + if(CodeScannerImpl.this != null && CodeScannerImpl.this.callback != null) { + CodeScannerImpl.this.callback.scanError(-1, "no scan app"); + CodeScannerImpl.this.callback = null; + } + } + }); + + if (getActivity() instanceof CodenameOneActivity) { + ((CodenameOneActivity) getActivity()).restoreIntentResultListener(); + } + } + } + + @Override + public void scanBarCode(ScanResult callback) { + if (getActivity() == null) { + return; + } + if (getActivity() instanceof CodenameOneActivity) { + ((CodenameOneActivity) getActivity()).setIntentResultListener(this); + } + this.callback = callback; + IntentIntegrator in = new IntentIntegrator(getActivity()); + Collection types = IntentIntegrator.PRODUCT_CODE_TYPES; + if(Display.getInstance().getProperty("scanAllCodeTypes", "false").equals("true")) { + types = IntentIntegrator.ALL_CODE_TYPES; + } + if(Display.getInstance().getProperty("android.scanTypes", null) != null) { + String[] arr = Display.getInstance().getProperty("android.scanTypes", null).split(";"); + types = Arrays.asList(arr); + } + + if(!in.initiateScan(types, "ONE_D_MODE")){ + // restore old activity handling + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + CodeScannerImpl.this.callback.scanError(-1, "no scan app"); + CodeScannerImpl.this.callback = null; + } + }); + + if (getActivity() instanceof CodenameOneActivity) { + ((CodenameOneActivity) getActivity()).restoreIntentResultListener(); + } + } + } + + public void onActivityResult(int requestCode, final int resultCode, Intent data) { + if (requestCode == IntentIntegrator.REQUEST_CODE && callback != null) { + final ScanResult sr = callback; + if (resultCode == Activity.RESULT_OK) { + final String contents = data.getStringExtra("SCAN_RESULT"); + final String formatName = data.getStringExtra("SCAN_RESULT_FORMAT"); + final byte[] rawBytes = data.getByteArrayExtra("SCAN_RESULT_BYTES"); + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + sr.scanCompleted(contents, formatName, rawBytes); + } + }); + } else if(resultCode == Activity.RESULT_CANCELED) { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + sr.scanCanceled(); + } + }); + + } else { + Display.getInstance().callSerially(new Runnable() { + @Override + public void run() { + sr.scanError(resultCode, null); + } + }); + } + callback = null; + } + + // restore old activity handling + if (getActivity() instanceof CodenameOneActivity) { + ((CodenameOneActivity) getActivity()).restoreIntentResultListener(); + } + } + } + + public boolean hasCamera() { + try { + int numCameras = Camera.getNumberOfCameras(); + return numCameras > 0; + } catch(Throwable t) { + return true; + } + } + + @Override + public com.codename1.impl.CameraImpl createCameraImpl() { + Activity act = getActivity(); + if (act == null) return null; + return new AndroidCameraImpl(act); + } + + @Override + public com.codename1.impl.ARImpl createARImpl() { + Activity act = getActivity(); + if (act == null) { + return null; + } + // The ARCore-backed impl lives in a package the build deletes for + // apps that never reference com.codename1.ar (it compiles against + // com.google.ar.core which only exists when the AR gradle dependency + // was injected), so it must be reached reflectively. + try { + Class clazz = Class.forName("com.codename1.impl.android.ar.AndroidARImpl"); + return (com.codename1.impl.ARImpl) clazz + .getConstructor(Activity.class).newInstance(act); + } catch (Throwable t) { + return null; + } + } + + private AndroidNearbyBridge nearbyBridge; + + /// The nearby bridge, which finds its own implementation. + /// + /// Always returned rather than conditionally null: the shell answers every + /// capability query honestly whether or not the optional backend was + /// bundled, so the public API reports NOT_SUPPORTED without this getter + /// having to know how the app was built. + @Override + public synchronized com.codename1.nearby.spi.NearbyBridge + getNearbyBridge() { + // Synchronized, because two threads reaching nearby for the first + // time both saw null and both built a backend. Only one was kept, + // and the loser could already have prepared a UWB session or taken + // the companion chooser slot in state nothing could reach again -- + // so a later start or stop could not find its session, and the radio + // it had opened stayed open. + if (nearbyBridge == null) { + nearbyBridge = new AndroidNearbyBridge(getActivity()); + } + return nearbyBridge; + } + + private com.codename1.impl.android.call.AndroidCallBridge callBridge; + + private com.codename1.impl.android.vpn.AndroidVpnBridge vpnBridge; + + /// The call bridge, on Telecom. + /// + /// Always returned rather than conditionally null: the bridge answers + /// every capability query honestly, including reporting no support at all + /// below API 26 where a self-managed ConnectionService does not exist, so + /// the public API degrades without this getter having to know the OS + /// version. + /// + /// Synchronized for the reason the nearby getter is: the bridge holds the + /// registered PhoneAccount, and two threads racing this would each build + /// one, with the loser's registration unreachable. + @Override + public synchronized com.codename1.call.spi.CallBridge getCallBridge() { + if (callBridge == null) { + callBridge = new com.codename1.impl.android.call.AndroidCallBridge( + callServiceContext()); + } + return callBridge; + } + + /// The context the call and VPN bridges do their system work through. + /// + /// NOT getActivity(): Codename One can be initialised from a Service -- + /// which is what happens when a push wakes the app to report an incoming + /// call -- and getActivity() is null there. The bridge cached that null + /// for the life of the process, so even isSupported() threw on the + /// TelecomManager lookup, and foregrounding later did not repair it. + /// + /// An activity is only needed to SHOW something, and the two places that + /// need one look for it when they get there. + private Context callServiceContext() { + Context any = getActivity(); + if (any == null) { + any = getContext(); + } + if (any == null) { + return null; + } + // The APPLICATION context, never the Activity. Both bridges keep + // what they are given in a final field and are never cleared, so + // caching an Activity here held that Activity and its whole view + // hierarchy reachable for the rest of the process -- a leak renewed + // by every rotation. Nothing the bridges do with it needs an + // Activity: they look up system services, the package manager and + // the application label, and the two places that must SHOW + // something ask getActivity() at the point of showing, which is + // what the comment above already promised and what + // currentActivity() implements. + Context app = any.getApplicationContext(); + return app != null ? app : any; + } + + /// The VPN bridge, on the platform's managed IKEv2 client. + /// + /// Reports no support below API 30, where `VpnManager` does not exist. + @Override + public synchronized com.codename1.vpn.spi.VpnBridge getVpnBridge() { + if (vpnBridge == null) { + vpnBridge = new com.codename1.impl.android.vpn.AndroidVpnBridge( + callServiceContext()); + } + return vpnBridge; + } + + @Override + public com.codename1.impl.VisionImpl createVisionImpl() { + return (com.codename1.impl.VisionImpl) createOptionalAiBackend( + "com.codename1.impl.android.ai.AndroidVisionImpl"); + } + + @Override + public com.codename1.impl.InferenceImpl createInferenceImpl() { + return (com.codename1.impl.InferenceImpl) createOptionalAiBackend( + "com.codename1.impl.android.ai.AndroidInferenceImpl"); + } + + @Override + public com.codename1.impl.LanguageImpl createLanguageImpl() { + return (com.codename1.impl.LanguageImpl) createOptionalAiBackend( + "com.codename1.impl.android.ai.AndroidLanguageImpl"); + } + + private Object createOptionalAiBackend(String className) { + try { + return Class.forName(className).newInstance(); + } catch (Throwable t) { + return null; + } + } + + // Deeper-network connectivity platform factories. Each returns a small + // platform-specific class living under + // com.codename1.impl.android.connectivity. Those classes are loaded + // lazily on first call so apps that never reference WiFi / Bonjour / + // USB / NetworkTypeListener never pay the loading cost. + + @Override + protected com.codename1.io.wifi.WifiPlatform createWifiPlatform() { + return new com.codename1.impl.android.connectivity.AndroidWifiPlatform(); + } + + @Override + protected com.codename1.io.wifi.WifiDirectPlatform createWifiDirectPlatform() { + return new com.codename1.impl.android.connectivity.AndroidWifiDirectPlatform(); + } + + @Override + protected com.codename1.io.bonjour.BonjourPlatform createBonjourPlatform() { + return new com.codename1.impl.android.connectivity.AndroidBonjourPlatform(); + } + + @Override + protected com.codename1.io.usb.UsbPlatform createUsbPlatform() { + return new com.codename1.impl.android.connectivity.AndroidUsbPlatform(); + } + + @Override + protected com.codename1.io.NetworkTypePlatform createNetworkTypePlatform() { + return new com.codename1.impl.android.connectivity.AndroidNetworkTypePlatform(); + } + + public String getCurrentAccessPoint() { + + ConnectivityManager cm = (ConnectivityManager) getContext().getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo info = cm.getActiveNetworkInfo(); + if (info == null) { + return null; + } + String apName = info.getTypeName() + "_" + info.getSubtypeName(); + if (info.getExtraInfo() != null) { + apName += "_" + info.getExtraInfo(); + } + return apName; + } + + @Override + public boolean isVPNDetectionSupported() { + return true; + } + + @Override + public boolean isVPNActive() { + try { + ConnectivityManager cm = (ConnectivityManager) getContext().getSystemService(Context.CONNECTIVITY_SERVICE); + if (cm != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + android.net.Network network = cm.getActiveNetwork(); + if (network != null) { + android.net.NetworkCapabilities capabilities = cm.getNetworkCapabilities(network); + if (capabilities != null && capabilities.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN)) { + return true; + } + } + } + + Enumeration interfaces = NetworkInterface.getNetworkInterfaces(); + while (interfaces != null && interfaces.hasMoreElements()) { + NetworkInterface current = interfaces.nextElement(); + if (!current.isUp() || current.isLoopback()) { + continue; + } + String name = current.getName(); + if (name == null) { + continue; + } + name = name.toLowerCase(Locale.US); + if (name.startsWith("tun") || name.startsWith("ppp") || name.startsWith("tap") || name.startsWith("ipsec")) { + return true; + } + } + } catch (Throwable t) { + Log.d("Codename One", "VPN detection failed", t); + } + return false; + } + + /** + * @inheritDoc + */ + public String[] getAPIds() { + if (apIds == null) { + apIds = new HashMap(); + NetworkInfo[] aps = ((ConnectivityManager) getContext().getSystemService(Context.CONNECTIVITY_SERVICE)).getAllNetworkInfo(); + for (int i = 0; i < aps.length; i++) { + String apName = aps[i].getTypeName() + "_" + aps[i].getSubtypeName(); + if (aps[i].getExtraInfo() != null) { + apName += "_" + aps[i].getExtraInfo(); + } + apIds.put(apName, aps[i]); + } + } + if (apIds.isEmpty()) { + return null; + } + String[] ret = new String[apIds.size()]; + Iterator iter = apIds.keySet().iterator(); + for (int i = 0; iter.hasNext(); i++) { + ret[i] = iter.next().toString(); + } + return ret; + + } + + /** + * @inheritDoc + */ + public int getAPType(String id) { + if (apIds == null) { + getAPIds(); + } + NetworkInfo info = (NetworkInfo) apIds.get(id); + if (info == null) { + return NetworkManager.ACCESS_POINT_TYPE_UNKNOWN; + } + int type = info.getType(); + int subType = info.getSubtype(); + if (type == ConnectivityManager.TYPE_WIFI) { + return NetworkManager.ACCESS_POINT_TYPE_WLAN; + } else if (type == ConnectivityManager.TYPE_MOBILE) { + switch (subType) { + case TelephonyManager.NETWORK_TYPE_1xRTT: + return NetworkManager.ACCESS_POINT_TYPE_NETWORK2G; // ~ 50-100 kbps + case TelephonyManager.NETWORK_TYPE_CDMA: + return NetworkManager.ACCESS_POINT_TYPE_NETWORK2G; // ~ 14-64 kbps + case TelephonyManager.NETWORK_TYPE_EDGE: + return NetworkManager.ACCESS_POINT_TYPE_NETWORK2G; // ~ 50-100 kbps + case TelephonyManager.NETWORK_TYPE_EVDO_0: + return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 400-1000 kbps + case TelephonyManager.NETWORK_TYPE_EVDO_A: + return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 600-1400 kbps + case TelephonyManager.NETWORK_TYPE_GPRS: + return NetworkManager.ACCESS_POINT_TYPE_NETWORK2G; // ~ 100 kbps + case TelephonyManager.NETWORK_TYPE_HSDPA: + return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 2-14 Mbps + case TelephonyManager.NETWORK_TYPE_HSPA: + return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 700-1700 kbps + case TelephonyManager.NETWORK_TYPE_HSUPA: + return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 1-23 Mbps + case TelephonyManager.NETWORK_TYPE_UMTS: + return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 400-7000 kbps + /* + * Above API level 7, make sure to set android:targetSdkVersion + * to appropriate level to use these + */ + case TelephonyManager.NETWORK_TYPE_EHRPD: // API level 11 + return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 1-2 Mbps + case TelephonyManager.NETWORK_TYPE_EVDO_B: // API level 9 + return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 5 Mbps + case TelephonyManager.NETWORK_TYPE_HSPAP: // API level 13 + return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 10-20 Mbps + case TelephonyManager.NETWORK_TYPE_IDEN: // API level 8 + return NetworkManager.ACCESS_POINT_TYPE_NETWORK2G; // ~25 kbps + case TelephonyManager.NETWORK_TYPE_LTE: // API level 11 + return NetworkManager.ACCESS_POINT_TYPE_NETWORK3G; // ~ 10+ Mbps + // Unknown + case TelephonyManager.NETWORK_TYPE_UNKNOWN: + default: + return NetworkManager.ACCESS_POINT_TYPE_NETWORK2G; + } + } else { + return NetworkManager.ACCESS_POINT_TYPE_UNKNOWN; + } + } + + /** + * @inheritDoc + */ + public void setCurrentAccessPoint(String id) { + + if (apIds == null) { + getAPIds(); + } + NetworkInfo info = (NetworkInfo) apIds.get(id); + if (info == null || info.isConnectedOrConnecting()) { + return; + + } + ConnectivityManager cm = (ConnectivityManager) getContext().getSystemService(Context.CONNECTIVITY_SERVICE); + cm.setNetworkPreference(info.getType()); + } + + private void scanMedia(File file) { + Uri uri = Uri.fromFile(file); + Intent scanFileIntent = new Intent( + Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, uri); + getActivity().sendBroadcast(scanFileIntent); + } + + /** + * Gets the last image id from the media store + * + * @return + */ + private String getLastImageId() { + int idVal = 0;; + final String[] imageColumns = {MediaStore.Images.Media._ID}; + final String imageOrderBy = MediaStore.Images.Media._ID + " DESC"; + final String imageWhere = null; + final String[] imageArguments = null; + Cursor imageCursor = getContext().getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, imageColumns, imageWhere, imageArguments, imageOrderBy); + if (imageCursor.moveToFirst()) { + int id = imageCursor.getInt(imageCursor.getColumnIndex(MediaStore.Images.Media._ID)); + imageCursor.close(); + idVal = id; + } + return "" + idVal; + } + + private void clearMediaDB(String lastId, String capturePath) { + final String[] imageColumns = {MediaStore.Images.Media.DATA, MediaStore.Images.Media.DATE_TAKEN, MediaStore.Images.Media.SIZE, MediaStore.Images.Media._ID}; + final String imageOrderBy = MediaStore.Images.Media._ID + " DESC"; + final String imageWhere = MediaStore.Images.Media._ID + ">?"; + final String[] imageArguments = {lastId}; + Cursor imageCursor = getContext().getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, imageColumns, imageWhere, imageArguments, imageOrderBy); + if (imageCursor.getCount() > 1) { + while (imageCursor.moveToNext()) { + int id = imageCursor.getInt(imageCursor.getColumnIndex(MediaStore.Images.Media._ID)); + String path = imageCursor.getString(imageCursor.getColumnIndex(MediaStore.Images.Media.DATA)); + Long takenTimeStamp = imageCursor.getLong(imageCursor.getColumnIndex(MediaStore.Images.Media.DATE_TAKEN)); + Long size = imageCursor.getLong(imageCursor.getColumnIndex(MediaStore.Images.Media.SIZE)); + if (path.contentEquals(capturePath)) { + // Remove it + ContentResolver cr = getContext().getContentResolver(); + cr.delete(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaStore.Images.Media._ID + "=?", new String[]{Long.toString(id)}); + break; + } + } + } + imageCursor.close(); + } + + + @Override + public boolean isNativePickerTypeSupported(int pickerType) { + if(android.os.Build.VERSION.SDK_INT >= 11) { + return pickerType == Display.PICKER_TYPE_DATE || pickerType == Display.PICKER_TYPE_TIME || pickerType == Display.PICKER_TYPE_STRINGS; + } + return pickerType == Display.PICKER_TYPE_DATE || pickerType == Display.PICKER_TYPE_TIME; + } + + @Override + public Object showNativePicker(final int type, final Component source, final Object currentValue, final Object data) { + if (getActivity() == null) { + return null; + } + final boolean [] canceled = new boolean[1]; + final boolean [] dismissed = new boolean[1]; + + if(editInProgress()) { + stopEditing(true); + } + if(type == Display.PICKER_TYPE_TIME) { + + class TimePick implements TimePickerDialog.OnTimeSetListener, TimePickerDialog.OnCancelListener, Runnable { + int result = ((Integer)currentValue).intValue(); + public void onTimeSet(TimePicker tp, int hour, int minute) { + result = hour * 60 + minute; + dismissed[0] = true; + synchronized(this) { + notify(); + } + } + + public void run() { + while(!dismissed[0]) { + synchronized(this) { + try { + wait(50); + } catch(InterruptedException er) {} + } + } + } + + @Override + public void onCancel(DialogInterface di) { + dismissed[0] = true; + canceled[0] = true; + synchronized (this) { + notify(); + } + } + } + final TimePick pickInstance = new TimePick(); + getActivity().runOnUiThread(new Runnable() { + public void run() { + int hour = ((Integer)currentValue).intValue() / 60; + int minute = ((Integer)currentValue).intValue() % 60; + TimePickerDialog tp = new TimePickerDialog(getActivity(), pickInstance, hour, minute, true){ + + @Override + public void cancel() { + super.cancel(); + dismissed[0] = true; + canceled[0] = true; + } + + @Override + public void dismiss() { + super.dismiss(); + dismissed[0] = true; + } + + }; + tp.setOnCancelListener(pickInstance); + //DateFormat.is24HourFormat(activity)); + tp.show(); + } + }); + Display.getInstance().invokeAndBlock(pickInstance); + if(canceled[0]) { + return null; + } + return new Integer(pickInstance.result); + } + if(type == Display.PICKER_TYPE_DATE) { + final java.util.Calendar cl = java.util.Calendar.getInstance(); + if(currentValue != null) { + cl.setTime((Date)currentValue); + } + class DatePick implements DatePickerDialog.OnDateSetListener,DatePickerDialog.OnCancelListener, Runnable { + Date result = (Date)currentValue; + + public void onDateSet(DatePicker dp, int year, int month, int day) { + java.util.Calendar c = java.util.Calendar.getInstance(); + c.set(java.util.Calendar.YEAR, year); + c.set(java.util.Calendar.MONTH, month); + c.set(java.util.Calendar.DAY_OF_MONTH, day); + result = c.getTime(); + dismissed[0] = true; + synchronized(this) { + notify(); + } + } + + public void run() { + while(!dismissed[0]) { + synchronized(this) { + try { + wait(50); + } catch(InterruptedException er) {} + } + } + } + + public void onCancel(DialogInterface di) { + result = null; + dismissed[0] = true; + canceled[0] = true; + synchronized(this) { + notify(); + } + } + } + final DatePick pickInstance = new DatePick(); + getActivity().runOnUiThread(new Runnable() { + public void run() { + DatePickerDialog tp = new DatePickerDialog(getActivity(), pickInstance, cl.get(java.util.Calendar.YEAR), cl.get(java.util.Calendar.MONTH), cl.get(java.util.Calendar.DAY_OF_MONTH)){ + + @Override + public void cancel() { + super.cancel(); + dismissed[0] = true; + canceled[0] = true; + } + + @Override + public void dismiss() { + super.dismiss(); + dismissed[0] = true; + } + + }; + tp.setOnCancelListener(pickInstance); + tp.show(); + } + }); + Display.getInstance().invokeAndBlock(pickInstance); + return pickInstance.result; + } + if(type == Display.PICKER_TYPE_STRINGS) { + final String[] values = (String[])data; + class StringPick implements Runnable, NumberPicker.OnValueChangeListener { + int result = -1; + + StringPick() { + } + + public void run() { + while(!dismissed[0]) { + synchronized(this) { + try { + wait(50); + } catch(InterruptedException er) {} + } + } + } + + public void cancel() { + dismissed[0] = true; + canceled[0] = true; + synchronized(this) { + notify(); + } + } + + public void ok() { + canceled[0] = false; + dismissed[0] = true; + synchronized(this) { + notify(); + } + } + + @Override + public void onValueChange(NumberPicker np, int oldVal, int newVal) { + result = newVal; + } + } + + final StringPick pickInstance = new StringPick(); + for(int iter = 0 ; iter < values.length ; iter++) { + if(values[iter].equals(currentValue)) { + pickInstance.result = iter; + break; + } + } + if (pickInstance.result == -1 && values.length > 0) { + // The picker will default to showing the first element anyways + // If we don't set the result to 0, then the user has to first + // scroll to a different number, then back to the first option + // to pick the first option. + pickInstance.result = 0; + } + + getActivity().runOnUiThread(new Runnable() { + public void run() { + NumberPicker picker = new NumberPicker(getActivity()); + if(source.getClientProperty("showKeyboard") == null) { + picker.setDescendantFocusability(NumberPicker.FOCUS_BLOCK_DESCENDANTS); + } + picker.setMinValue(0); + picker.setMaxValue(values.length - 1); + picker.setDisplayedValues(values); + picker.setOnValueChangedListener(pickInstance); + if(pickInstance.result > -1) { + picker.setValue(pickInstance.result); + } + RelativeLayout linearLayout = new RelativeLayout(getActivity()); + RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(50, 50); + RelativeLayout.LayoutParams numPicerParams = new RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT); + numPicerParams.addRule(RelativeLayout.CENTER_HORIZONTAL); + + linearLayout.setLayoutParams(params); + linearLayout.addView(picker,numPicerParams); + + AlertDialog.Builder alertDialogBuilder = new AlertDialog.Builder(getActivity()); + alertDialogBuilder.setView(linearLayout); + alertDialogBuilder + .setCancelable(false) + .setPositiveButton("Ok", + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, + int id) { + pickInstance.ok(); + } + }) + .setNegativeButton("Cancel", + new DialogInterface.OnClickListener() { + public void onClick(DialogInterface dialog, + int id) { + dialog.cancel(); + pickInstance.cancel(); + } + }); + AlertDialog alertDialog = alertDialogBuilder.create(); + alertDialog.show(); + } + }); + Display.getInstance().invokeAndBlock(pickInstance); + if(canceled[0]) { + return null; + } + if(pickInstance.result < 0) { + return null; + } + return values[pickInstance.result]; + } + return null; + } + + private ServerSockets serverSockets; + private synchronized ServerSockets getServerSockets() { + if (serverSockets == null) { + serverSockets = new ServerSockets(); + } + return serverSockets; + } + + class ServerSockets { + Map socks = new HashMap(); + Map loopbackSocks = new HashMap(); + + public synchronized ServerSocket get(int port) throws IOException { + return get(port, false); + } + + /** + * When loopbackOnly is set the socket binds 127.0.0.1 rather than the wildcard + * address, so the channel isn't published on every network interface. The two + * are cached in SEPARATE maps: a port that is already bound to the wildcard + * address must never be handed back to a caller that asked for loopback. + * Distinguishing them by sign within one map would collide on port 0, the + * ephemeral-port request, where -0 == 0. + * + * The IPv4 loopback is named explicitly rather than taken from + * InetAddress.getLoopbackAddress(), which answers ::1 when the runtime + * prefers IPv6. A client that then connects to 127.0.0.1 - which is what + * adb forward and attaching agents do, and what the iOS port binds - would + * find nothing listening, with the server reporting that it had started. + */ + public synchronized ServerSocket get(int port, boolean loopbackOnly) throws IOException { + Map cache = loopbackOnly ? loopbackSocks : socks; + Integer key = Integer.valueOf(port); + ServerSocket sock = cache.get(key); + if (sock == null || sock.isClosed()) { + sock = loopbackOnly + ? new ServerSocket(port, 50, InetAddress.getByName("127.0.0.1")) + : new ServerSocket(port); + cache.put(key, sock); + } + return sock; + } + + /** + * Closes and forgets the socket, so a thread blocked in accept returns and a + * later listener on this port binds a fresh one rather than sharing this. + */ + public synchronized void close(int port, boolean loopbackOnly) { + Map cache = loopbackOnly ? loopbackSocks : socks; + ServerSocket sock = cache.remove(Integer.valueOf(port)); + if (sock != null) { + try { + sock.close(); + } catch (IOException ignored) { + // best effort: the point is to unblock accept, and a socket that + // cannot be closed is already unusable + } + } + } + + + } + + class SocketImpl { + java.net.Socket socketInstance; + int errorCode = -1; + String errorMessage = null; + InputStream is; + OutputStream os; + + public boolean connect(String param, int param1, int connectTimeout) { + try { + socketInstance = new java.net.Socket(); + socketInstance.connect(new InetSocketAddress(param, param1), connectTimeout); + return true; + } catch(Exception err) { + err.printStackTrace(); + errorMessage = err.toString(); + return false; + } + } + + private InputStream getInput() throws IOException { + if(is == null) { + if(socketInstance != null) { + is = socketInstance.getInputStream(); + } else { + + } + } + return is; + } + + private OutputStream getOutput() throws IOException { + if(os == null) { + os = socketInstance.getOutputStream(); + } + return os; + } + + public int getAvailableInput() { + try { + return getInput().available(); + } catch(IOException err) { + errorMessage = err.toString(); + err.printStackTrace(); + } + return 0; + } + + public String getErrorMessage() { + return errorMessage; + } + + public byte[] readFromStream() { + try { + int av = getAvailableInput(); + if(av > 0) { + byte[] arr = new byte[av]; + int size = getInput().read(arr); + if(size == arr.length) { + return arr; + } + return shrink(arr, size); + } + byte[] arr = new byte[8192]; + int size = getInput().read(arr); + if(size == arr.length) { + return arr; + } + return shrink(arr, size); + } catch(IOException err) { + err.printStackTrace(); + errorMessage = err.toString(); + return null; + } + } + + private byte[] shrink(byte[] arr, int size) { + if(size == -1) { + return null; + } + byte[] n = new byte[size]; + System.arraycopy(arr, 0, n, 0, size); + return n; + } + + public void writeToStream(byte[] param) { + writeToStream(param, 0, param.length); + } + + public void writeToStream(byte[] param, int offset, int len) { + try { + OutputStream os = getOutput(); + os.write(param, offset, len); + os.flush(); + } catch(IOException err) { + errorMessage = err.toString(); + err.printStackTrace(); + } + } + + public void disconnect() { + try { + if(socketInstance != null) { + if(is != null) { + try { + is.close(); + } catch(IOException err) {} + } + if(os != null) { + try { + os.close(); + } catch(IOException err) {} + } + socketInstance.close(); + socketInstance = null; + } + } catch(IOException err) { + errorMessage = err.toString(); + err.printStackTrace(); + } + } + + public Object listen(int param) { + return listen(param, false); + } + + public Object listen(int param, boolean loopbackOnly) { + ServerSocket serverSocketInstance = null; + try { + serverSocketInstance = getServerSockets().get(param, loopbackOnly); + socketInstance = serverSocketInstance.accept(); + SocketImpl si = new SocketImpl(); + si.socketInstance = socketInstance; + return si; + } catch(Exception err) { + errorMessage = err.toString(); + // A closed socket here is the deliberate stop path: stopping a + // listener closes it precisely to bring this accept back. Printing a + // stack trace for that would put an alarming fake failure in the log + // every time a listener is stopped. + if(serverSocketInstance == null || !serverSocketInstance.isClosed()) { + err.printStackTrace(); + } + return null; + } + } + + public boolean isConnected() { + return socketInstance != null; + } + + public int getErrorCode() { + return errorCode; + } + } + + @Override + public Object connectSocket(String host, int port) { + return connectSocket(host, port, 0); + } + + + + @Override + public Object connectSocket(String host, int port, int connectTimeout) { + SocketImpl i = new SocketImpl(); + if(i.connect(host, port, connectTimeout)) { + return i; + } + return null; + } + + @Override + public Object listenSocket(int port) { + return new SocketImpl().listen(port); + } + + @Override + public boolean isLoopbackServerSocketAvailable() { + return true; + } + + @Override + public Object listenSocketLoopback(int port) { + return new SocketImpl().listen(port, true); + } + + @Override + public void stopListeningSocket(int port, boolean loopbackOnly) { + getServerSockets().close(port, loopbackOnly); + } + + /** + * A debuggable package is one built for development: the flag is set by the + * build for a debug variant and cleared for a release variant, so this reads the + * distinction straight off the installed application rather than guessing. + */ + @Override + public boolean isDebuggableBuild() { + Context ctx = getContext(); + if (ctx == null) { + return false; + } + ApplicationInfo info = ctx.getApplicationInfo(); + return info != null && (info.flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0; + } + + @Override + public String getHostOrIP() { + try { + InetAddress i = java.net.InetAddress.getLocalHost(); + if(i.isLoopbackAddress()) { + Enumeration nie = NetworkInterface.getNetworkInterfaces(); + while(nie.hasMoreElements()) { + NetworkInterface current = nie.nextElement(); + if(!current.isLoopback()) { + Enumeration iae = current.getInetAddresses(); + while(iae.hasMoreElements()) { + InetAddress currentI = iae.nextElement(); + if(!currentI.isLoopbackAddress()) { + return currentI.getHostAddress(); + } + } + } + } + } + return i.getHostAddress(); + } catch(Throwable t) { + com.codename1.io.Log.e(t); + return null; + } + } + + @Override + public void disconnectSocket(Object socket) { + ((SocketImpl)socket).disconnect(); + } + + @Override + public boolean isSocketConnected(Object socket) { + return ((SocketImpl)socket).isConnected(); + } + + + + @Override + public boolean isServerSocketAvailable() { + return true; + } + + @Override + public boolean isSocketAvailable() { + return true; + } + + @Override + public String getSocketErrorMessage(Object socket) { + return ((SocketImpl)socket).getErrorMessage(); + } + + @Override + public int getSocketErrorCode(Object socket) { + return ((SocketImpl)socket).getErrorCode(); + } + + @Override + public int getSocketAvailableInput(Object socket) { + return ((SocketImpl)socket).getAvailableInput(); + } + + @Override + public byte[] readFromSocketStream(Object socket) { + return ((SocketImpl)socket).readFromStream(); + } + + @Override + public void writeToSocketStream(Object socket, byte[] data) { + ((SocketImpl)socket).writeToStream(data); + } + + @Override + public boolean isWebSocketSupported() { + return true; + } + + @Override + public com.codename1.impl.WebSocketImpl createWebSocketImpl(String url) { + return new AndroidWebSocketImpl(url); + } + + @Override + public void writeToSocketStream(Object socket, byte[] data, int offset, int len) { + ((SocketImpl)socket).writeToStream(data, offset, len); + } + + //Begin new Graphics Work + @Override + public boolean isShapeSupported(Object graphics) { + return true; + } + + @Override + public boolean isTransformSupported(Object graphics) { + return true; + } + + @Override + public boolean isPerspectiveTransformSupported(Object graphics){ + return android.os.Build.VERSION.SDK_INT >= 14; + } + + @Override + public void fillShape(Object graphics, com.codename1.ui.geom.Shape shape) { + AndroidGraphics ag = (AndroidGraphics)graphics; + Path p = cn1ShapeToAndroidPath(shape); + ag.fillPath(p); + } + + @Override + public void fillShapeShadow(Object graphics, com.codename1.ui.geom.Shape shape, int fillColor, + int fillAlpha, int shadowColor, float shadowOpacity, int blurRadius, int offsetX, int offsetY) { + AndroidGraphics ag = (AndroidGraphics)graphics; + Path p = cn1ShapeToAndroidPath(shape); + ag.fillPathShadow(p, fillColor, fillAlpha, shadowColor, shadowOpacity, blurRadius, offsetX, offsetY); + } + + @Override + public boolean isShapeShadowSupported(Object graphics) { + // Android's Canvas has no cheap GPU shadow for arbitrary shapes: BlurMaskFilter is ignored on + // the hardware canvas, and Paint.setShadowLayer collapses the whole view to software rendering + // (severe jank/ANR). Fall back to the cached-image path; the RAM cost is bounded by keeping the + // number of live shadowed components small (windowed lists) or disabling the per-border cache. + return false; + } + + @Override + public void drawShape(Object graphics, com.codename1.ui.geom.Shape shape, com.codename1.ui.Stroke stroke) { + AndroidGraphics ag = (AndroidGraphics)graphics; + Path p = cn1ShapeToAndroidPath(shape); + ag.drawPath(p, stroke); + + } + + @Override + public void drawShadow(Object graphics, Object image, int x, int y, int offsetX, int offsetY, int blurRadius, int spreadRadius, int color, float opacity) { + AndroidGraphics ag = (AndroidGraphics)graphics; + + ag.drawShadow(image, x, y, offsetX, offsetY, blurRadius, spreadRadius, color, opacity); + } + + @Override + public boolean isDrawShadowSupported() { + return true; + } + + @Override + public boolean isDrawShadowFast() { + return false; + } + // BEGIN TRANSFORMATION METHODS--------------------------------------------------------- + + + + @Override + public boolean transformEqualsImpl(Transform t1, Transform t2) { + Object o1 = null; + if(t1 != null) { + o1 = t1.getNativeTransform(); + } + Object o2 = null; + if(t2 != null) { + o2 = t2.getNativeTransform(); + } + return transformNativeEqualsImpl(o1, o2); + } + + @Override + public boolean transformNativeEqualsImpl(Object t1, Object t2) { + if ( t1 != null ){ + CN1Matrix4f m1 = (CN1Matrix4f)t1; + CN1Matrix4f m2 = (CN1Matrix4f)t2; + return m1.equals(m2); + } else { + return t2 == null; + } + } + + + @Override + public boolean isTransformSupported() { + return true; + } + + @Override + public boolean isPerspectiveTransformSupported() { + + return true; + } + + @Override + public Object makeTransformAffine(double m00, double m10, double m01, double m11, double m02, double m12) { + CN1Matrix4f t = CN1Matrix4f.make(new float[]{ + (float)m00, (float)m10, 0, 0, + (float)m01, (float)m11, 0, 0, + 0, 0, 1, 0, + (float)m02, (float)m12, 0, 1 + }); + return t; + } + + @Override + public void setTransformAffine(Object nativeTransform, double m00, double m10, double m01, double m11, double m02, double m12) { + ((CN1Matrix4f)nativeTransform).setData(new float[]{ + (float)m00, (float)m10, 0, 0, + (float)m01, (float)m11, 0, 0, + 0, 0, 1, 0, + (float)m02, (float)m12, 0, 1 + }); + } + + + @Override + public Object makeTransformTranslation(float translateX, float translateY, float translateZ) { + return CN1Matrix4f.makeTranslation(translateX, translateY, translateZ); + } + + @Override + public void setTransformTranslation(Object nativeTransform, float translateX, float translateY, float translateZ) { + CN1Matrix4f m = (CN1Matrix4f)nativeTransform; + m.reset(); + m.translate(translateX, translateY, translateZ); + } + + @Override + public Object makeTransformScale(float scaleX, float scaleY, float scaleZ) { + CN1Matrix4f t = CN1Matrix4f.makeIdentity(); + t.scale(scaleX, scaleY, scaleZ); + return t; + } + + @Override + public void setTransformScale(Object nativeTransform, float scaleX, float scaleY, float scaleZ) { + CN1Matrix4f t = (CN1Matrix4f)nativeTransform; + t.reset(); + t.scale(scaleX, scaleY, scaleZ); + } + + @Override + public Object makeTransformRotation(float angle, float x, float y, float z) { + return CN1Matrix4f.makeRotation(angle, x, y, z); + } + + @Override + public void setTransformRotation(Object nativeTransform, float angle, float x, float y, float z) { + CN1Matrix4f m = (CN1Matrix4f)nativeTransform; + m.reset(); + m.rotate(angle, x, y, z); + } + + @Override + public Object makeTransformPerspective(float fovy, float aspect, float zNear, float zFar) { + return CN1Matrix4f.makePerspective(fovy, aspect, zNear, zFar); + } + + @Override + public void setTransformPerspective(Object nativeGraphics, float fovy, float aspect, float zNear, float zFar) { + CN1Matrix4f m = (CN1Matrix4f)nativeGraphics; + m.setPerspective(fovy, aspect, zNear, zFar); + } + + @Override + public Object makeTransformOrtho(float left, float right, float bottom, float top, float near, float far) { + return CN1Matrix4f.makeOrtho(left, right, bottom, top, near, far); + } + + @Override + public void setTransformOrtho(Object nativeGraphics, float left, float right, float bottom, float top, float near, float far) { + CN1Matrix4f m = (CN1Matrix4f)nativeGraphics; + m.setOrtho(left, right, bottom, top, near, far); + } + + @Override + public Object makeTransformCamera(float eyeX, float eyeY, float eyeZ, float centerX, float centerY, float centerZ, float upX, float upY, float upZ) { + return CN1Matrix4f.makeCamera(eyeX, eyeY, eyeZ, centerX, centerY, centerZ, upX, upY, upZ); + } + + @Override + public void setTransformCamera(Object nativeGraphics, float eyeX, float eyeY, float eyeZ, float centerX, float centerY, float centerZ, float upX, float upY, float upZ) { + CN1Matrix4f m = (CN1Matrix4f)nativeGraphics; + m.setCamera(eyeX, eyeY, eyeZ, centerX, centerY, centerZ, upX, upY, upZ); + } + + + @Override + public void transformRotate(Object nativeTransform, float angle, float x, float y, float z) { + ((CN1Matrix4f)nativeTransform).rotate(angle, x, y, z); + } + + @Override + public void transformTranslate(Object nativeTransform, float x, float y, float z) { + //((Matrix) nativeTransform).preTranslate(x, y); + ((CN1Matrix4f)nativeTransform).translate(x, y, z); + } + + @Override + public void transformScale(Object nativeTransform, float x, float y, float z) { + //((Matrix) nativeTransform).preScale(x, y); + ((CN1Matrix4f)nativeTransform).scale(x, y, z); + } + + @Override + public Object makeTransformInverse(Object nativeTransform) { + + CN1Matrix4f inverted = CN1Matrix4f.makeIdentity(); + inverted.setData(((CN1Matrix4f)nativeTransform).getData()); + if( inverted.invert()){ + return inverted; + } + return null; + + //Matrix inverted = new Matrix(); + //if(((Matrix) nativeTransform).invert(inverted)){ + // return inverted; + //} + //return null; + } + + @Override + public void setTransformInverse(Object nativeTransform) throws com.codename1.ui.Transform.NotInvertibleException { + + CN1Matrix4f m = (CN1Matrix4f)nativeTransform; + if (!m.invert()) { + throw new com.codename1.ui.Transform.NotInvertibleException(); + } + } + + @Override + public void setTransformIdentity(Object transform) { + CN1Matrix4f m = (CN1Matrix4f)transform; + m.setIdentity(); + } + + @Override + public Object makeTransformIdentity() { + return CN1Matrix4f.makeIdentity(); + } + + @Override + public void copyTransform(Object src, Object dest) { + CN1Matrix4f t1 = (CN1Matrix4f) src; + CN1Matrix4f t2 = (CN1Matrix4f) dest; + t2.setData(t1.getData()); + } + + @Override + public void concatenateTransform(Object t1, Object t2) { + //((Matrix) t1).preConcat((Matrix) t2); + ((CN1Matrix4f)t1).concatenate((CN1Matrix4f)t2); + } + + @Override + public void transformPoint(Object nativeTransform, float[] in, float[] out) { + //Matrix t = (Matrix) nativeTransform; + //t.mapPoints(in, 0, out, 0, 2); + ((CN1Matrix4f)nativeTransform).transformCoord(in, out); + } + + @Override + public void setTransform(Object graphics, Transform transform) { + AndroidGraphics ag = (AndroidGraphics) graphics; + Transform existing = ag.getTransform(); + if (existing == null) { + existing = transform == null ? Transform.makeIdentity() : transform.copy(); + ag.setTransform(existing); + } else { + if (transform == null) { + existing.setIdentity(); + } else { + existing.setTransform(transform); + } + ag.setTransform(existing); // sets dirty flag for transform + } + + } + + @Override + public com.codename1.ui.Transform getTransform(Object graphics) { + com.codename1.ui.Transform t = ((AndroidGraphics) graphics).getTransform(); + if (t == null) { + return Transform.makeIdentity(); + } + Transform t2 = Transform.makeIdentity(); + t2.setTransform(t); + return t2; + } + + @Override + public void getTransform(Object graphics, Transform transform) { + com.codename1.ui.Transform t = ((AndroidGraphics) graphics).getTransform(); + if (t == null) { + transform.setIdentity(); + } else { + transform.setTransform(t); + } + } + + + // END TRANSFORM STUFF + + + static Path cn1ShapeToAndroidPath(com.codename1.ui.geom.Shape shape, Path p) { + //Path p = new Path(); + p.rewind(); + + com.codename1.ui.geom.PathIterator it = shape.getPathIterator(); + switch (it.getWindingRule()) { + case GeneralPath.WIND_EVEN_ODD: + p.setFillType(Path.FillType.EVEN_ODD); + break; + case GeneralPath.WIND_NON_ZERO: + p.setFillType(Path.FillType.WINDING); + break; + } + //p.setWindingRule(it.getWindingRule() == com.codename1.ui.geom.PathIterator.WIND_EVEN_ODD ? GeneralPath.WIND_EVEN_ODD : GeneralPath.WIND_NON_ZERO); + float[] buf = new float[6]; + while (!it.isDone()) { + int type = it.currentSegment(buf); + switch (type) { + case com.codename1.ui.geom.PathIterator.SEG_MOVETO: + p.moveTo(buf[0], buf[1]); + break; + case com.codename1.ui.geom.PathIterator.SEG_LINETO: + p.lineTo(buf[0], buf[1]); + break; + case com.codename1.ui.geom.PathIterator.SEG_QUADTO: + p.quadTo(buf[0], buf[1], buf[2], buf[3]); + break; + case com.codename1.ui.geom.PathIterator.SEG_CUBICTO: + p.cubicTo(buf[0], buf[1], buf[2], buf[3], buf[4], buf[5]); + break; + case com.codename1.ui.geom.PathIterator.SEG_CLOSE: + p.close(); + break; + + } + it.next(); + } + + return p; + } + + static Path cn1ShapeToAndroidPath(com.codename1.ui.geom.Shape shape) { + return cn1ShapeToAndroidPath(shape, new Path()); + } + + /** + * The ID used for a local notification that should actually trigger a background + * fetch. This type of notification is handled specially by the {@link LocalNotificationPublisher}. It + * doesn't display a notification to the user, but instead just calls the {@link #performBackgroundFetch() } + * method. + */ + static final String BACKGROUND_FETCH_NOTIFICATION_ID="$$$CN1_BACKGROUND_FETCH$$$"; + + + /** + * Calls the background fetch callback. If the app is in teh background, this will + * check to see if the lifecycle class implements the {@link com.codename1.background.BackgroundFetch} + * interface. If it does, it will execute its {@link com.codename1.background.BackgroundFetch#performBackgroundFetch(long, com.codename1.util.Callback) } + * method. + * @param blocking True if this should block until it is complete. + */ + public static void performBackgroundFetch(boolean blocking) { + + if (Display.getInstance().isMinimized()) { + // By definition, background fetch should only occur if the app is minimized. + // This keeps it consistent with the iOS implementation that doesn't have a + // choice + final boolean[] complete = new boolean[1]; + final Object lock = new Object(); + final BackgroundFetch bgFetchListener = instance.getBackgroundFetchListener(); + final long timeout = System.currentTimeMillis()+25000; + if (bgFetchListener != null) { + Display.getInstance().callSerially(new Runnable() { + public void run() { + bgFetchListener.performBackgroundFetch(timeout, new Callback() { + + @Override + public void onSucess(Boolean value) { + // On Android the OS doesn't care whether it worked or not + // So we'll just consume this. + synchronized (lock) { + complete[0] = true; + lock.notify(); + } + } + + @Override + public void onError(Object sender, Throwable err, int errorCode, String errorMessage) { + com.codename1.io.Log.e(err); + synchronized (lock) { + complete[0] = true; + lock.notify(); + } + } + + }); + } + }); + + } + + while (blocking && !complete[0]) { + Util.wait(lock, 1000); + if (!complete[0]) { + System.out.println("Waiting for background fetch to complete. Make sure your background fetch handler calls onSuccess() or onError() in the callback when complete"); + + } + if (System.currentTimeMillis() > timeout) { + System.out.println("Background fetch exceeded time alotted. Not waiting for its completion"); + break; + } + + } + + + } + } + + /** + * Starts the background fetch service. + */ + public void startBackgroundFetchService() { + LocalNotification n = new LocalNotification(); + n.setId(BACKGROUND_FETCH_NOTIFICATION_ID); + cancelLocalNotification(BACKGROUND_FETCH_NOTIFICATION_ID); + // We schedule a local notification + // First callback will be at the repeat interval + // We don't specify a repeat interval because the scheduleLocalNotification will + // set that for us using the getPreferredBackgroundFetchInterval method. + scheduleLocalNotification(n, System.currentTimeMillis() + getPreferredBackgroundFetchInterval() * 1000, 0); + } + + public void stopBackgroundFetchService() { + cancelLocalNotification(BACKGROUND_FETCH_NOTIFICATION_ID); + } + + + private boolean backgroundFetchInitialized; + + @Override + public void setPreferredBackgroundFetchInterval(int seconds) { + int oldInterval = getPreferredBackgroundFetchInterval(); + super.setPreferredBackgroundFetchInterval(seconds); + + if (!backgroundFetchInitialized || oldInterval != seconds) { + backgroundFetchInitialized = true; + if (seconds > 0) { + startBackgroundFetchService(); + } else { + stopBackgroundFetchService(); + } + } + } + + + + @Override + public boolean isBackgroundFetchSupported() { + return true; + } + public static BackgroundFetch backgroundFetchListener; + + BackgroundFetch getBackgroundFetchListener() { + if (getActivity() != null && getActivity().getApp() instanceof BackgroundFetch) { + return (BackgroundFetch)getActivity().getApp(); + } else if (backgroundFetchListener != null) { + return backgroundFetchListener; + } else { + return null; + } + } + + /** + * Returns the fully qualified class name of the app's background fetch listener, or null + * when the app does not implement {@link com.codename1.background.BackgroundFetch}. The + * surfaces plumbing persists this name on publish so a home screen widget that rendered an + * exhausted timeline can start {@link BackgroundFetchHandler} and let the app republish + * fresh content while no activity exists. + * + * @return the listener class name or null + */ + public static String getBackgroundFetchListenerClassName() { + if (instance == null) { + return null; + } + BackgroundFetch listener = instance.getBackgroundFetchListener(); + return listener == null ? null : listener.getClass().getName(); + } + + public void scheduleLocalNotification(LocalNotification notif, long firstTime, int repeat) { + if (android.os.Build.VERSION.SDK_INT >= 33) { + if(!checkForPermission("android.permission.POST_NOTIFICATIONS", "This is required to receive notifications")){ + com.codename1.io.Log.e(new RuntimeException("Local notification was prevented the POST_NOTIFICATIONS permission was not granted by the user.")); + return; + } + } + final Intent notificationIntent = new Intent(getContext(), LocalNotificationPublisher.class); + notificationIntent.setAction(getContext().getApplicationInfo().packageName + "." + notif.getId()); + notificationIntent.putExtra(LocalNotificationPublisher.NOTIFICATION, createBundleFromNotification(notif)); + + Intent contentIntent = new Intent(); + if (activityComponentName != null) { + contentIntent.setComponent(activityComponentName); + } else { + try { + contentIntent.setComponent(getContext().getPackageManager().getLaunchIntentForPackage(getContext().getApplicationInfo().packageName).getComponent()); + } catch (Exception ex) { + System.err.println("Failed to get the component name for local notification. Local notification may not work."); + ex.printStackTrace(); + } + } + contentIntent.putExtra("LocalNotificationID", notif.getId()); + + if (BACKGROUND_FETCH_NOTIFICATION_ID.equals(notif.getId()) && getBackgroundFetchListener() != null) { + Context context = AndroidNativeUtil.getContext(); + + Intent intent = new Intent(context, BackgroundFetchHandler.class); + //there is an bug that causes this to not to workhttps://code.google.com/p/android/issues/detail?id=81812 + //intent.putExtra("backgroundClass", getBackgroundLocationListener().getName()); + //an ugly workaround to the putExtra bug + intent.setData(Uri.parse("http://codenameone.com/a?" + getBackgroundFetchListener().getClass().getName())); + PendingIntent pendingIntent = getPendingIntent(context, 0, + intent); + notificationIntent.putExtra(LocalNotificationPublisher.BACKGROUND_FETCH_INTENT, pendingIntent); + + } else { + contentIntent.setData(Uri.parse("http://codenameone.com/a?LocalNotificationID="+Uri.encode(notif.getId()))); + } + PendingIntent pendingContentIntent = createPendingIntent(getContext(), 0, contentIntent); + + notificationIntent.putExtra(LocalNotificationPublisher.NOTIFICATION_INTENT, pendingContentIntent); + // carry the configured content intent as a template so the publisher can build + // a distinct per-action PendingIntent (with the action id and any remote input) + if (!notif.getActions().isEmpty()) { + notificationIntent.putExtra(LocalNotificationPublisher.NOTIFICATION_CONTENT_TEMPLATE, contentIntent); + } + + + PendingIntent pendingIntent = getBroadcastPendingIntent(getContext(), 0, notificationIntent); + + AlarmManager alarmManager = (AlarmManager) getContext().getSystemService(Context.ALARM_SERVICE); + if (BACKGROUND_FETCH_NOTIFICATION_ID.equals(notif.getId())) { + alarmManager.setRepeating(AlarmManager.RTC_WAKEUP, firstTime, getPreferredBackgroundFetchInterval() * 1000, pendingIntent); + } else { + if(repeat == LocalNotification.REPEAT_NONE){ + alarmManager.set(AlarmManager.RTC_WAKEUP, firstTime, pendingIntent); + + }else if(repeat == LocalNotification.REPEAT_MINUTE){ + + alarmManager.setRepeating(AlarmManager.RTC_WAKEUP, firstTime, 60*1000, pendingIntent); + + }else if(repeat == LocalNotification.REPEAT_HOUR){ + + alarmManager.setInexactRepeating(AlarmManager.RTC_WAKEUP, firstTime, AlarmManager.INTERVAL_HALF_HOUR, pendingIntent); + + }else if(repeat == LocalNotification.REPEAT_DAY){ + + alarmManager.setInexactRepeating(AlarmManager.RTC_WAKEUP, firstTime, AlarmManager.INTERVAL_DAY, pendingIntent); + + }else if(repeat == LocalNotification.REPEAT_WEEK){ + + alarmManager.setRepeating(AlarmManager.RTC_WAKEUP, firstTime, AlarmManager.INTERVAL_DAY * 7, pendingIntent); + + } + } + } + + public void cancelLocalNotification(String notificationId) { + Intent notificationIntent = new Intent(getContext(), LocalNotificationPublisher.class); + notificationIntent.setAction(getContext().getApplicationInfo().packageName + "." + notificationId); + + PendingIntent pendingIntent = getBroadcastPendingIntent(getContext(), 0, notificationIntent); + AlarmManager alarmManager = (AlarmManager) getContext().getSystemService(Context.ALARM_SERVICE); + alarmManager.cancel(pendingIntent); + } + + static Bundle createBundleFromNotification(LocalNotification notif){ + Bundle b = new Bundle(); + b.putString("NOTIF_ID", notif.getId()); + b.putString("NOTIF_TITLE", notif.getAlertTitle()); + b.putString("NOTIF_BODY", notif.getAlertBody()); + b.putString("NOTIF_SOUND", notif.getAlertSound()); + b.putString("NOTIF_IMAGE", notif.getAlertImage()); + b.putInt("NOTIF_NUMBER", notif.getBadgeNumber()); + b.putString("NOTIF_CHANNEL", notif.getChannelId()); + b.putString("NOTIF_GROUP", notif.getGroupId()); + b.putBoolean("NOTIF_GROUP_SUMMARY", notif.isGroupSummary()); + b.putBoolean("NOTIF_FULLSCREEN", notif.isFullScreenIntent()); + b.putBoolean("NOTIF_TIME_SENSITIVE", notif.isTimeSensitive()); + b.putBoolean("NOTIF_ONGOING", notif.isOngoing()); + b.putInt("NOTIF_PROGRESS_MAX", notif.getProgressMax()); + b.putInt("NOTIF_PROGRESS", notif.getProgress()); + b.putBoolean("NOTIF_PROGRESS_INDETERMINATE", notif.isProgressIndeterminate()); + b.putString("NOTIF_CUSTOM_VIEW", notif.getCustomView()); + java.util.List actions = notif.getActions(); + if (!actions.isEmpty()) { + ArrayList ids = new ArrayList(); + ArrayList titles = new ArrayList(); + ArrayList icons = new ArrayList(); + ArrayList placeholders = new ArrayList(); + ArrayList buttons = new ArrayList(); + for (LocalNotification.Action a : actions) { + ids.add(a.getId()); + titles.add(a.getTitle() == null ? "" : a.getTitle()); + icons.add(a.getIcon() == null ? "" : a.getIcon()); + placeholders.add(a.getTextInputPlaceholder() == null ? "" : a.getTextInputPlaceholder()); + buttons.add(a.getTextInputButtonText() == null ? "" : a.getTextInputButtonText()); + } + b.putStringArrayList("NOTIF_ACTION_IDS", ids); + b.putStringArrayList("NOTIF_ACTION_TITLES", titles); + b.putStringArrayList("NOTIF_ACTION_ICONS", icons); + b.putStringArrayList("NOTIF_ACTION_PLACEHOLDERS", placeholders); + b.putStringArrayList("NOTIF_ACTION_BUTTONS", buttons); + } + LocalNotification.MessagingStyle ms = notif.getMessagingStyle(); + if (ms != null) { + b.putString("NOTIF_MSG_SELF", ms.getSelfDisplayName()); + b.putString("NOTIF_MSG_TITLE", ms.getConversationTitle()); + b.putBoolean("NOTIF_MSG_GROUP", ms.isGroupConversation()); + ArrayList texts = new ArrayList(); + ArrayList senders = new ArrayList(); + long[] times = new long[ms.getMessages().size()]; + int i = 0; + for (LocalNotification.MessagingStyle.Message m : ms.getMessages()) { + texts.add(m.getText() == null ? "" : m.getText()); + senders.add(m.getSenderName() == null ? "" : m.getSenderName()); + times[i++] = m.getTimestamp(); + } + b.putStringArrayList("NOTIF_MSG_TEXTS", texts); + b.putStringArrayList("NOTIF_MSG_SENDERS", senders); + b.putLongArray("NOTIF_MSG_TIMES", times); + } + return b; + } + + static LocalNotification createNotificationFromBundle(Bundle b){ + LocalNotification n = new LocalNotification(); + n.setId(b.getString("NOTIF_ID")); + n.setAlertTitle(b.getString("NOTIF_TITLE")); + n.setAlertBody(b.getString("NOTIF_BODY")); + n.setAlertSound(b.getString("NOTIF_SOUND")); + n.setAlertImage(b.getString("NOTIF_IMAGE")); + n.setBadgeNumber(b.getInt("NOTIF_NUMBER")); + // new fields are guarded so bundles serialized by older builds still parse + if (b.containsKey("NOTIF_CHANNEL")) { + n.setChannelId(b.getString("NOTIF_CHANNEL")); + } + if (b.containsKey("NOTIF_GROUP")) { + n.setGroup(b.getString("NOTIF_GROUP")); + } + n.setGroupSummary(b.getBoolean("NOTIF_GROUP_SUMMARY", false)); + n.setFullScreenIntent(b.getBoolean("NOTIF_FULLSCREEN", false)); + n.setTimeSensitive(b.getBoolean("NOTIF_TIME_SENSITIVE", false)); + n.setOngoing(b.getBoolean("NOTIF_ONGOING", false)); + int progressMax = b.getInt("NOTIF_PROGRESS_MAX", 0); + if (progressMax > 0) { + n.setProgress(progressMax, b.getInt("NOTIF_PROGRESS", 0)); + } + n.setIndeterminateProgress(b.getBoolean("NOTIF_PROGRESS_INDETERMINATE", false)); + if (b.containsKey("NOTIF_CUSTOM_VIEW")) { + n.setCustomView(b.getString("NOTIF_CUSTOM_VIEW")); + } + ArrayList ids = b.getStringArrayList("NOTIF_ACTION_IDS"); + if (ids != null) { + ArrayList titles = b.getStringArrayList("NOTIF_ACTION_TITLES"); + ArrayList icons = b.getStringArrayList("NOTIF_ACTION_ICONS"); + ArrayList placeholders = b.getStringArrayList("NOTIF_ACTION_PLACEHOLDERS"); + ArrayList buttons = b.getStringArrayList("NOTIF_ACTION_BUTTONS"); + for (int i = 0; i < ids.size(); i++) { + String placeholder = placeholders != null ? emptyToNull(placeholders.get(i)) : null; + String button = buttons != null ? emptyToNull(buttons.get(i)) : null; + if (placeholder != null || button != null) { + n.addInputAction(ids.get(i), titles.get(i), placeholder, button); + } else { + String icon = icons != null ? emptyToNull(icons.get(i)) : null; + n.addAction(new LocalNotification.Action(ids.get(i), titles.get(i), icon)); + } + } + } + if (b.containsKey("NOTIF_MSG_SELF")) { + LocalNotification.MessagingStyle ms = n.asMessagingStyle(b.getString("NOTIF_MSG_SELF")); + ms.conversationTitle(b.getString("NOTIF_MSG_TITLE")); + ms.groupConversation(b.getBoolean("NOTIF_MSG_GROUP", false)); + ArrayList texts = b.getStringArrayList("NOTIF_MSG_TEXTS"); + ArrayList senders = b.getStringArrayList("NOTIF_MSG_SENDERS"); + long[] times = b.getLongArray("NOTIF_MSG_TIMES"); + if (texts != null) { + for (int i = 0; i < texts.size(); i++) { + ms.addMessage(texts.get(i), + times != null && i < times.length ? times[i] : 0, + senders != null ? emptyToNull(senders.get(i)) : null); + } + } + } + return n; + } + + private static String emptyToNull(String s) { + return s == null || s.length() == 0 ? null : s; + } + + @Override + public void requestNotificationPermission(final NotificationPermissionRequest request, final NotificationPermissionCallback callback) { + if (callback == null) { + return; + } + final boolean granted; + if (android.os.Build.VERSION.SDK_INT >= 33) { + granted = checkForPermission("android.permission.POST_NOTIFICATIONS", "This is required to receive notifications", true); + } else { + // notifications are allowed by default below Android 13 + granted = true; + } + Display.getInstance().callSerially(new Runnable() { + public void run() { + callback.notificationPermissionResult(new NotificationPermissionResult(granted + ? NotificationPermissionResult.AuthorizationLevel.AUTHORIZED + : NotificationPermissionResult.AuthorizationLevel.DENIED)); + } + }); + } + + @Override + public void registerNotificationChannel(NotificationChannelBuilder builder) { + if (builder == null || android.os.Build.VERSION.SDK_INT < 26) { + return; + } + try { + NotificationManager nm = (NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE); + Class clsChannel = Class.forName("android.app.NotificationChannel"); + Constructor ctor = clsChannel.getConstructor(String.class, CharSequence.class, int.class); + // map our 0..5 importance onto the platform IMPORTANCE_* (NONE=0 .. MAX=5) + Object channel = ctor.newInstance(builder.getId(), builder.getName(), builder.getImportance()); + if (builder.getDescription() != null) { + clsChannel.getMethod("setDescription", String.class).invoke(channel, builder.getDescription()); + } + clsChannel.getMethod("enableLights", boolean.class).invoke(channel, builder.isLightsEnabled()); + if (builder.isLightsEnabled()) { + clsChannel.getMethod("setLightColor", int.class).invoke(channel, builder.getLightColor()); + } + clsChannel.getMethod("enableVibration", boolean.class).invoke(channel, builder.isVibrationEnabled()); + if (builder.getVibrationPattern() != null) { + clsChannel.getMethod("setVibrationPattern", long[].class).invoke(channel, (Object) builder.getVibrationPattern()); + } + clsChannel.getMethod("setLockscreenVisibility", int.class).invoke(channel, builder.getLockscreenVisibility()); + clsChannel.getMethod("setShowBadge", boolean.class).invoke(channel, builder.isShowBadge()); + if (builder.getGroup() != null) { + clsChannel.getMethod("setGroup", String.class).invoke(channel, builder.getGroup()); + } + String sound = builder.getSound(); + if (sound != null && sound.length() > 0) { + sound = sound.toLowerCase(); + Uri uri = Uri.parse("android.resource://" + getContext().getApplicationInfo().packageName + "/raw" + + sound.substring(0, sound.indexOf("."))); + android.media.AudioAttributes attrs = new android.media.AudioAttributes.Builder() + .setContentType(android.media.AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(android.media.AudioAttributes.USAGE_NOTIFICATION) + .build(); + clsChannel.getMethod("setSound", Uri.class, android.media.AudioAttributes.class).invoke(channel, uri, attrs); + } + nm.getClass().getMethod("createNotificationChannel", clsChannel).invoke(nm, channel); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + @Override + public void deleteNotificationChannel(String channelId) { + if (channelId == null || android.os.Build.VERSION.SDK_INT < 26) { + return; + } + try { + NotificationManager nm = (NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE); + nm.getClass().getMethod("deleteNotificationChannel", String.class).invoke(nm, channelId); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + @Override + public void createNotificationChannelGroup(String groupId, String groupName) { + if (groupId == null || android.os.Build.VERSION.SDK_INT < 26) { + return; + } + try { + NotificationManager nm = (NotificationManager) getContext().getSystemService(Context.NOTIFICATION_SERVICE); + Class clsGroup = Class.forName("android.app.NotificationChannelGroup"); + Constructor ctor = clsGroup.getConstructor(String.class, CharSequence.class); + Object group = ctor.newInstance(groupId, groupName); + nm.getClass().getMethod("createNotificationChannelGroup", clsGroup).invoke(nm, group); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + @Override + public void subscribeToPushTopic(final String topic) { + invokeFirebaseTopic("subscribeToTopic", topic); + } + + @Override + public void unsubscribeFromPushTopic(final String topic) { + invokeFirebaseTopic("unsubscribeFromTopic", 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); + } + } + + @Override + public boolean isReceiveSharedContentSupported() { + return true; + } + + private static SharedContent pendingSharedContent; + + /// Delivers shared content received from another app. If the CN1 app instance is + /// running it is dispatched immediately on the EDT; otherwise it is held until the app + /// finishes starting and `#deliverPendingSharedContent()` is invoked. + static void deliverSharedContent(SharedContent content) { + if (content == null) { + return; + } + Object app = CodenameOneImplementation.getCurrentApplicationInstance(); + if (app != null && Display.isInitialized()) { + dispatchSharedContent(app, content); + } else { + pendingSharedContent = content; + } + } + + /// Invoked once the app has started to flush any shared content that arrived before the + /// app instance existed. + public static void deliverPendingSharedContent() { + SharedContent c = pendingSharedContent; + pendingSharedContent = null; + Object app = CodenameOneImplementation.getCurrentApplicationInstance(); + if (c != null && app != null) { + dispatchSharedContent(app, c); + } + } + + private static void dispatchSharedContent(final Object app, final SharedContent content) { + if (!(app instanceof com.codename1.system.Lifecycle)) { + return; + } + Display.getInstance().callSerially(new Runnable() { + public void run() { + ((com.codename1.system.Lifecycle) app).onReceivedSharedContent(content); + } + }); + } + + // ---- Constraint-aware background work (JobScheduler) ---- + + @Override + public boolean isBackgroundWorkSupported() { + return android.os.Build.VERSION.SDK_INT >= 21; + } + + private static int jobIdFor(String id) { + return (id.hashCode() & 0x7fffffff) % 1000000 + 1000; + } + + @Override + public void scheduleBackgroundWork(WorkRequest request) { + if (android.os.Build.VERSION.SDK_INT < 21) { + return; + } + try { + android.app.job.JobScheduler scheduler = + (android.app.job.JobScheduler) getContext().getSystemService(Context.JOB_SCHEDULER_SERVICE); + android.content.ComponentName component = + new android.content.ComponentName(getContext(), CodenameOneJobService.class); + android.app.job.JobInfo.Builder builder = + new android.app.job.JobInfo.Builder(jobIdFor(request.getId()), component); + + if (request.isRequiresUnmeteredNetwork()) { + builder.setRequiredNetworkType(android.app.job.JobInfo.NETWORK_TYPE_UNMETERED); + } else if (request.isRequiresNetwork()) { + builder.setRequiredNetworkType(android.app.job.JobInfo.NETWORK_TYPE_ANY); + } + builder.setRequiresCharging(request.isRequiresCharging()); + if (android.os.Build.VERSION.SDK_INT >= 23) { + builder.setRequiresDeviceIdle(request.isRequiresIdle()); + } + if (android.os.Build.VERSION.SDK_INT >= 26) { + builder.setRequiresBatteryNotLow(request.isRequiresBatteryNotLow()); + } + if (request.isPeriodic()) { + builder.setPeriodic(Math.max(15 * 60 * 1000L, request.getMinIntervalMillis())); + } else { + if (request.getInitialDelayMillis() > 0) { + builder.setMinimumLatency(request.getInitialDelayMillis()); + } + builder.setOverrideDeadline(Math.max(request.getInitialDelayMillis(), 0) + 60 * 60 * 1000L); + } + + PersistableBundle extras = new PersistableBundle(); + extras.putString(CodenameOneJobService.EXTRA_WORKER_CLASS, request.getWorkerClass()); + extras.putString(CodenameOneJobService.EXTRA_WORK_ID, request.getId()); + for (java.util.Map.Entry e : request.getInputData().entrySet()) { + extras.putString(CodenameOneJobService.INPUT_PREFIX + e.getKey(), e.getValue()); + } + builder.setExtras(extras); + scheduler.schedule(builder.build()); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + @Override + public void cancelBackgroundWork(String workId) { + if (android.os.Build.VERSION.SDK_INT < 21) { + return; + } + try { + android.app.job.JobScheduler scheduler = + (android.app.job.JobScheduler) getContext().getSystemService(Context.JOB_SCHEDULER_SERVICE); + scheduler.cancel(jobIdFor(workId)); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + @Override + public boolean isBackgroundProcessingSupported() { + return android.os.Build.VERSION.SDK_INT >= 21; + } + + @Override + public void scheduleBackgroundProcessing(String id, long earliestBeginEpochMs, boolean requiresNetwork, boolean requiresPower, Runnable task) { + if (android.os.Build.VERSION.SDK_INT < 21 || task == null) { + return; + } + try { + CodenameOneJobService.registerProcessingRunnable(id, task); + android.app.job.JobScheduler scheduler = + (android.app.job.JobScheduler) getContext().getSystemService(Context.JOB_SCHEDULER_SERVICE); + android.content.ComponentName component = + new android.content.ComponentName(getContext(), CodenameOneJobService.class); + android.app.job.JobInfo.Builder builder = + new android.app.job.JobInfo.Builder(jobIdFor("proc-" + id), component); + if (requiresNetwork) { + builder.setRequiredNetworkType(android.app.job.JobInfo.NETWORK_TYPE_ANY); + } + builder.setRequiresCharging(requiresPower); + long delay = earliestBeginEpochMs <= 0 ? 0 : Math.max(0, earliestBeginEpochMs - System.currentTimeMillis()); + if (delay > 0) { + builder.setMinimumLatency(delay); + } + builder.setOverrideDeadline(delay + 60 * 60 * 1000L); + PersistableBundle extras = new PersistableBundle(); + extras.putString(CodenameOneJobService.EXTRA_PROCESSING_ID, id); + builder.setExtras(extras); + scheduler.schedule(builder.build()); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + @Override + public void cancelBackgroundProcessing(String id) { + CodenameOneJobService.unregisterProcessingRunnable(id); + if (android.os.Build.VERSION.SDK_INT < 21) { + return; + } + try { + android.app.job.JobScheduler scheduler = + (android.app.job.JobScheduler) getContext().getSystemService(Context.JOB_SCHEDULER_SERVICE); + scheduler.cancel(jobIdFor("proc-" + id)); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + // ---- Foreground service ---- + + @Override + public boolean isForegroundServiceSupported() { + return true; + } + + @Override + public Object startForegroundService(String channelId, String title, String body, String iconName, ForegroundService.Task task, ForegroundService handle) { + int token = CodenameOneForegroundService.registerTask(task, handle, channelId, title, body, iconName); + try { + Intent intent = new Intent(getContext(), CodenameOneForegroundService.class); + intent.setAction(CodenameOneForegroundService.ACTION_START); + intent.putExtra(CodenameOneForegroundService.EXTRA_TOKEN, token); + intent.putExtra(CodenameOneForegroundService.EXTRA_CHANNEL, channelId); + intent.putExtra(CodenameOneForegroundService.EXTRA_TITLE, title); + intent.putExtra(CodenameOneForegroundService.EXTRA_BODY, body); + intent.putExtra(CodenameOneForegroundService.EXTRA_ICON, iconName); + if (android.os.Build.VERSION.SDK_INT >= 26) { + getContext().startForegroundService(intent); + } else { + getContext().startService(intent); + } + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + return Integer.valueOf(token); + } + + @Override + public void updateForegroundServiceNotification(Object nativeHandle, String title, String body) { + try { + Intent intent = new Intent(getContext(), CodenameOneForegroundService.class); + intent.setAction(CodenameOneForegroundService.ACTION_UPDATE); + if (nativeHandle instanceof Integer) { + intent.putExtra(CodenameOneForegroundService.EXTRA_TOKEN, ((Integer) nativeHandle).intValue()); + } + intent.putExtra(CodenameOneForegroundService.EXTRA_TITLE, title); + intent.putExtra(CodenameOneForegroundService.EXTRA_BODY, body); + getContext().startService(intent); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + @Override + public void stopForegroundService(Object nativeHandle) { + try { + Intent intent = new Intent(getContext(), CodenameOneForegroundService.class); + intent.setAction(CodenameOneForegroundService.ACTION_STOP); + if (nativeHandle instanceof Integer) { + intent.putExtra(CodenameOneForegroundService.EXTRA_TOKEN, ((Integer) nativeHandle).intValue()); + } + getContext().startService(intent); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + + boolean brokenGaussian; + public Image gaussianBlurImage(Image image, float radius) { + try { + Bitmap outputBitmap = Bitmap.createBitmap((Bitmap)image.getImage()); + + RenderScript rs = RenderScript.create(getContext()); + try { + ScriptIntrinsicBlur theIntrinsic = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs)); + Allocation tmpIn = Allocation.createFromBitmap(rs, (Bitmap)image.getImage()); + Allocation tmpOut = Allocation.createFromBitmap(rs, outputBitmap); + theIntrinsic.setRadius(radius); + theIntrinsic.setInput(tmpIn); + theIntrinsic.forEach(tmpOut); + tmpOut.copyTo(outputBitmap); + tmpIn.destroy(); + tmpOut.destroy(); + theIntrinsic.destroy(); + } finally { + rs.destroy(); + } + + return new NativeImage(outputBitmap); + } catch(Throwable t) { + brokenGaussian = true; + return image; + } + } + + public boolean isGaussianBlurSupported() { + return (!brokenGaussian) && android.os.Build.VERSION.SDK_INT >= 11; + } + + @Override + public boolean blurRegion(Object graphics, int x, int y, int width, int height, float radius) { + if (radius <= 0f || width <= 0 || height <= 0 || !isGaussianBlurSupported()) { + return radius <= 0f || width <= 0 || height <= 0; + } + // In-place CSS backdrop-filter:blur on a mutable-image target. Read/write the + // backing Bitmap directly at absolute coordinates (bypassing the canvas + // transform), Gaussian-blur the region via RenderScript. The live screen + // canvas has no backing Bitmap here -> returns false (component paints + // without the blur). + if (!(graphics instanceof AndroidGraphics)) { + return false; + } + Bitmap dest = ((AndroidGraphics) graphics).underlyingBitmap; + if (dest == null || !dest.isMutable()) { + return false; + } + try { + int rx = Math.max(0, x), ry = Math.max(0, y); + int rw = Math.min(width, dest.getWidth() - rx); + int rh = Math.min(height, dest.getHeight() - ry); + if (rw <= 0 || rh <= 0) { + return true; + } + int[] pix = new int[rw * rh]; + dest.getPixels(pix, 0, rw, rx, ry, rw, rh); + Bitmap region = Bitmap.createBitmap(pix, rw, rh, Bitmap.Config.ARGB_8888); + Bitmap blurred = Bitmap.createBitmap(region); + RenderScript rs = RenderScript.create(getContext()); + try { + ScriptIntrinsicBlur theIntrinsic = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs)); + Allocation tmpIn = Allocation.createFromBitmap(rs, region); + Allocation tmpOut = Allocation.createFromBitmap(rs, blurred); + // RenderScript blur radius is capped at 25. + theIntrinsic.setRadius(Math.min(25f, radius)); + theIntrinsic.setInput(tmpIn); + theIntrinsic.forEach(tmpOut); + tmpOut.copyTo(blurred); + tmpIn.destroy(); + tmpOut.destroy(); + theIntrinsic.destroy(); + } finally { + rs.destroy(); + } + blurred.getPixels(pix, 0, rw, 0, 0, rw, rh); + dest.setPixels(pix, 0, rw, rx, ry, rw, rh); + return true; + } catch (Throwable t) { + brokenGaussian = true; + return false; + } + } + + public static boolean checkForPermission(String permission, String description){ + return checkForPermission(permission, description, false); + } + + public static void setPermissionPromptCallback(PermissionPromptCallback callback) { + permissionPromptCallback = callback; + } + + public static PermissionPromptCallback getPermissionPromptCallback() { + return permissionPromptCallback; + } + + private static String getPermissionText(String key, String defaultValue) { + return UIManager.getInstance().localize(key, Display.getInstance().getProperty(key, defaultValue)); + } + + private static boolean showPermissionPrompt(String permission, String title, String body, String positiveButtonText, String negativeButtonText) { + if (permissionPromptCallback != null) { + return permissionPromptCallback.showPermissionPrompt(permission, title, body, positiveButtonText, negativeButtonText); + } + return Dialog.show(title, body, positiveButtonText, negativeButtonText); + } + + private static void showPermissionMessage(String permission, String title, String body, String okButtonText) { + if (permissionPromptCallback != null) { + permissionPromptCallback.showPermissionMessage(permission, title, body, okButtonText); + return; + } + Dialog.show(title, body, okButtonText, null); + } + + /** + * Return a list of all of the permissions that have been requested by the app (granted or no). + * This can be used to see which permissions are included in the manifest file. + * @return + */ + public static List getRequestedPermissions() { + PackageManager pm = getContext().getPackageManager(); + try + { + PackageInfo packageInfo = pm.getPackageInfo(getContext().getPackageName(), PackageManager.GET_PERMISSIONS); + String[] requestedPermissions = null; + if (packageInfo != null) { + requestedPermissions = packageInfo.requestedPermissions; + return Arrays.asList(requestedPermissions); + } + return new ArrayList(); + } + catch (PackageManager.NameNotFoundException e) + { + com.codename1.io.Log.e(e); + return new ArrayList(); + } + } + + public static boolean checkForPermission(String permission, String description, boolean forceAsk){ + //before sdk 23 no need to ask for permission + if(android.os.Build.VERSION.SDK_INT < 23){ + return true; + } + + if (android.os.Build.VERSION.SDK_INT >= 30 && "android.permission.ACCESS_BACKGROUND_LOCATION".equals(permission)) { + if (android.support.v4.content.ContextCompat.checkSelfPermission(getContext(), permission) == PackageManager.PERMISSION_GRANTED) { + return true; + } + if (getActivity() == null) { + return false; + } + + String prompt = getPermissionText(permission, description); + String title = getPermissionText("android.permission.ACCESS_BACKGROUND_LOCATION.title", "Requires permission"); + String settingsBtn = getPermissionText("android.permission.ACCESS_BACKGROUND_LOCATION.settings", "Settings"); + String cancelBtn = getPermissionText("android.permission.ACCESS_BACKGROUND_LOCATION.cancel", "Cancel"); + + if(showPermissionPrompt(permission, title, prompt, settingsBtn, cancelBtn)){ + Intent intent = new Intent(android.provider.Settings.ACTION_APPLICATION_DETAILS_SETTINGS); + Uri uri = Uri.fromParts("package", getContext().getPackageName(), null); + intent.setData(uri); + getActivity().startActivity(intent); + + String explanationTitle = getPermissionText("android.permission.ACCESS_BACKGROUND_LOCATION.explanation_title", "Permission Required"); + String explanationBody = getPermissionText("android.permission.ACCESS_BACKGROUND_LOCATION.explanation_body", "Please enable 'Allow all the time' in the settings, then press OK."); + String okBtn = getPermissionText("android.permission.ACCESS_BACKGROUND_LOCATION.ok", "OK"); + + showPermissionMessage(permission, explanationTitle, explanationBody, okBtn); + return android.support.v4.content.ContextCompat.checkSelfPermission(getActivity(), permission) == PackageManager.PERMISSION_GRANTED; + } else { + return false; + } + } + + String prompt = getPermissionText(permission, description); + + if (android.support.v4.content.ContextCompat.checkSelfPermission(getContext(), + permission) + != PackageManager.PERMISSION_GRANTED) { + + if (getActivity() == null) { + return false; + } + + // Should we show an explanation? + if (!forceAsk && android.support.v4.app.ActivityCompat.shouldShowRequestPermissionRationale(getActivity(), + permission)) { + + // Show an expanation to the user *asynchronously* -- don't block + String title = getPermissionText(permission + ".title", "Requires permission"); + String askAgain = getPermissionText(permission + ".askAgain", "Ask again"); + String dontAsk = getPermissionText(permission + ".dontAsk", "Don't Ask"); + if(showPermissionPrompt(permission, title, prompt, askAgain, dontAsk)){ + return checkForPermission(permission, description, true); + }else { + return false; + } + } else { + + // No explanation needed, we can request the permission. + ((CodenameOneActivity)getActivity()).setRequestForPermission(true); + ((CodenameOneActivity)getActivity()).setWaitingForPermissionResult(true); + android.support.v4.app.ActivityCompat.requestPermissions(getActivity(), + new String[]{permission}, + 1); + //wait for a response + Display.getInstance().invokeAndBlock(new Runnable() { + @Override + public void run() { + while(((CodenameOneActivity)getActivity()).isRequestForPermission()) { + try { + Thread.sleep(50); + } catch (InterruptedException e) { + e.printStackTrace(); + } + } + } + }); + //check again if the permission is given after the dialog was displayed + return android.support.v4.content.ContextCompat.checkSelfPermission(getActivity(), + permission) == PackageManager.PERMISSION_GRANTED; + + } + } + return true; + } + + public boolean isJailbrokenDevice() { + try { + Runtime.getRuntime().exec("su"); + return true; + } catch(Throwable t) { + com.codename1.io.Log.e(t); + } + return false; + } + + @Override + public boolean isAttestationSupported() { + try { + Class.forName("com.google.android.play.core.integrity.IntegrityManagerFactory"); + return true; + } catch(Throwable t) { + return false; + } + } + + @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); + } + return result; + } + + @Override + public boolean isDeviceCompromised() { + return getCompromiseReasons().length > 0; + } + + /** + * Base64 SHA-256 digests of the certificates this APK is actually signed with. + * + *

Uses the v2/v3 signing-block API on API 28 and up, which reports the full + * signing lineage after a key rotation; below that only the legacy v1 signature + * is available. Note that under Play App Signing the digest seen here is + * Google's app signing key, not the developer's upload key -- comparing + * against the upload key is the classic way to make every production install + * report itself as repackaged.

+ */ + @Override + public String[] getAppSignerDigests() { + try { + Context ctx = getContext(); + if (ctx == null) { + return new String[0]; + } + PackageManager pm = ctx.getPackageManager(); + String pkg = ctx.getPackageName(); + Signature[] signatures = null; + if (android.os.Build.VERSION.SDK_INT >= 28) { + // Reflection because the port compiles against an older android.jar + // than the devices it runs on, the same reason the Play Integrity + // call in this file is reflective. + signatures = signingCertificatesViaReflection(pm, pkg); + } + if (signatures == null) { + PackageInfo info = pm.getPackageInfo(pkg, PackageManager.GET_SIGNATURES); + signatures = info.signatures; + } + if (signatures == null) { + return new String[0]; + } + java.util.ArrayList out = new java.util.ArrayList(); + for (int i = 0; i < signatures.length; i++) { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update(signatures[i].toByteArray()); + out.add(com.codename1.util.Base64.encodeNoNewline(md.digest())); + } + return out.toArray(new String[out.size()]); + } catch (Throwable t) { + // Reporting nothing is better than failing a request over a + // package-manager quirk on some OEM build. + com.codename1.io.Log.e(t); + return new String[0]; + } + } + + /** + * PackageManager.GET_SIGNING_CERTIFICATES. Inlined because the port compiles + * against an android.jar that predates it. + */ + private static final int FLAG_GET_SIGNING_CERTIFICATES = 0x08000000; + + /** + * Reads the v2/v3 signing certificates on API 28+, or null when unavailable so + * the caller falls back to the legacy v1 signatures. + */ + private static Signature[] signingCertificatesViaReflection(PackageManager pm, String pkg) { + try { + PackageInfo info = pm.getPackageInfo(pkg, FLAG_GET_SIGNING_CERTIFICATES); + java.lang.reflect.Field signingInfoField = + PackageInfo.class.getField("signingInfo"); + Object signingInfo = signingInfoField.get(info); + if (signingInfo == null) { + return null; + } + Class signingInfoClass = signingInfo.getClass(); + boolean multipleSigners = ((Boolean) signingInfoClass + .getMethod("hasMultipleSigners").invoke(signingInfo)).booleanValue(); + // With one signer the history includes the pre-rotation certificates, + // which a server comparing against an older build still needs to accept. + String method = multipleSigners + ? "getApkContentsSigners" + : "getSigningCertificateHistory"; + return (Signature[]) signingInfoClass.getMethod(method).invoke(signingInfo); + } catch (Throwable t) { + return null; + } + } + + @Override + public String[] getCompromiseReasons() { + java.util.ArrayList reasons = new java.util.ArrayList(); + if(isRootedViaRootBeer() || isJailbrokenDevice()) { + reasons.add("root"); + } + try { + if(FridaDetectionUtil.isFridaDetected()) { + reasons.add("frida"); + } + } catch(Throwable t) { + // detection must never crash the host app + } + if(isProbablyEmulator()) { + reasons.add("emulator"); + } + return reasons.toArray(new String[reasons.size()]); + } + + private boolean isRootedViaRootBeer() { + try { + Class rootBeerClass = Class.forName("com.scottyab.rootbeer.RootBeer"); + Object rootBeer = rootBeerClass.getConstructor(Context.class).newInstance(getContext()); + Object rooted = rootBeerClass.getMethod("isRooted").invoke(rootBeer); + return Boolean.TRUE.equals(rooted); + } catch(Throwable t) { + // RootBeer not bundled (android.rootCheck off) - caller falls back to the su probe + return false; + } + } + + private boolean isProbablyEmulator() { + try { + String fingerprint = Build.FINGERPRINT; + if(fingerprint != null && (fingerprint.startsWith("generic") || fingerprint.startsWith("unknown") + || fingerprint.contains("emulator"))) { + return true; + } + String model = Build.MODEL; + if(model != null && (model.contains("google_sdk") || model.contains("Emulator") + || model.contains("Android SDK built for"))) { + return true; + } + String manufacturer = Build.MANUFACTURER; + if(manufacturer != null && manufacturer.contains("Genymotion")) { + return true; + } + String product = Build.PRODUCT; + if(product != null && (product.contains("sdk_gphone") || product.equals("google_sdk") + || product.contains("emulator") || product.contains("simulator"))) { + return true; + } + String hardware = Build.HARDWARE; + if(hardware != null && (hardware.contains("goldfish") || hardware.contains("ranchu"))) { + return true; + } + } catch(Throwable t) { + // ignore + } + return false; + } + + @Override + public String[] getEnabledAccessibilityServices() { + Context context = getContext(); + if(context == null) { + return new String[0]; + } + try { + AccessibilityManager am = (AccessibilityManager) context.getSystemService(Context.ACCESSIBILITY_SERVICE); + if(am != null) { + java.util.List list = + am.getEnabledAccessibilityServiceList( + android.accessibilityservice.AccessibilityServiceInfo.FEEDBACK_ALL_MASK); + if(list != null && !list.isEmpty()) { + java.util.ArrayList ids = new java.util.ArrayList(); + for(android.accessibilityservice.AccessibilityServiceInfo info : list) { + String id = info.getId(); + if(id != null && id.length() > 0) { + ids.add(id); + } + } + return ids.toArray(new String[ids.size()]); + } + } + } catch(Throwable t) { + // fall through to the Settings.Secure based lookup below + } + try { + String enabled = Settings.Secure.getString(context.getContentResolver(), + Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES); + if(enabled != null && enabled.length() > 0) { + return enabled.split(":"); + } + } catch(Throwable t) { + com.codename1.io.Log.e(t); + } + return new String[0]; + } + + @Override + public void setSecureScreen(final boolean secure) { + final Activity act = getActivity(); + if(act == null) { + return; + } + act.runOnUiThread(new Runnable() { + public void run() { + try { + if(secure) { + act.getWindow().addFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE); + } else { + act.getWindow().clearFlags(android.view.WindowManager.LayoutParams.FLAG_SECURE); + } + } catch(Throwable t) { + com.codename1.io.Log.e(t); + } + } + }); + } + + @Override + public boolean isHideOverlayWindowsSupported() { + // The permission half matters as much as the API level. Window.setHideOverlayWindows + // throws SecurityException without HIDE_OVERLAY_WINDOWS; reflection wraps it and the + // catch below only logs it, so reporting support on the API level alone would tell an + // app its native peers were protected when in fact nothing happened. It is a normal + // permission, granted at install once the manifest declares it, which the + // android.tapjackingGuard / android.hideOverlayWindows build hints arrange. + return Build.VERSION.SDK_INT >= 31 && hasHideOverlayWindowsPermission(); + } + + /** The last value passed to setHideOverlayWindows, replayed onto a recreated window. */ + private boolean hideOverlayWindowsRequested; + + private boolean hasHideOverlayWindowsPermission() { + try { + Context ctx = getContext(); + if (ctx == null) { + return false; + } + return ctx.checkSelfPermission("android.permission.HIDE_OVERLAY_WINDOWS") + == android.content.pm.PackageManager.PERMISSION_GRANTED; + } catch (Throwable t) { + return false; + } + } + + @Override + public void setHideOverlayWindows(final boolean hide) { + // Recorded before the guards below because it is a request, not a result: the flag + // lives on the Window, and a configuration change destroys and recreates the activity + // without touching this implementation instance. initSurface() replays it onto the new + // window, otherwise an app that hid overlays on a sensitive screen would come back from + // a rotation with them allowed again and no way to notice. + hideOverlayWindowsRequested = hide; + if (Build.VERSION.SDK_INT < 31) { + return; + } + if (!hasHideOverlayWindowsPermission()) { + // Said out loud rather than left to the swallowed SecurityException below: an app + // that calls this without the build hint would otherwise see no effect and no + // explanation for why its overlays were never hidden. + com.codename1.io.Log.p("Codename One: setHideOverlayWindows ignored, the app does " + + "not hold android.permission.HIDE_OVERLAY_WINDOWS. Enable the " + + "android.tapjackingGuard or android.hideOverlayWindows build hint."); + return; + } + final Activity act = getActivity(); + if (act == null) { + return; + } + act.runOnUiThread(new Runnable() { + public void run() { + try { + // Window.setHideOverlayWindows(boolean) is API 31 and absent from the + // android.jar this port compiles against, so it is reached reflectively -- + // the same approach the port uses for the Play Integrity API. + android.view.Window w = act.getWindow(); + if (w == null) { + return; + } + java.lang.reflect.Method m = android.view.Window.class.getMethod( + "setHideOverlayWindows", boolean.class); + m.invoke(w, Boolean.valueOf(hide)); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + } + }); + } + + @Override + public void announceForAccessibility(final Component cmp, final String text) { + final Activity act = getActivity(); + if (act == null) { + return; + } + act.runOnUiThread(new Runnable() { + @Override + public void run() { + View view = null; + if (cmp instanceof PeerComponent) { + Object peer = ((PeerComponent) cmp).getNativePeer(); + if (peer instanceof View) { + view = (View) peer; + } + } + if (view == null) { + view = act.getWindow().getDecorView(); + } + if (view == null) { + return; + } + if (Build.VERSION.SDK_INT >= 16) { + view.announceForAccessibility(text); + } else { + AccessibilityManager manager = (AccessibilityManager) act.getSystemService(Context.ACCESSIBILITY_SERVICE); + if (manager != null && manager.isEnabled()) { + AccessibilityEvent event = AccessibilityEvent.obtain(AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED); + event.getText().add(text); + event.setSource(view); + manager.sendAccessibilityEvent(event); + } + } + } + }); + } + + @Override + public boolean isHighContrastEnabled() { + try { + AccessibilityManager manager = (AccessibilityManager)getContext() + .getSystemService(Context.ACCESSIBILITY_SERVICE); + if (android.os.Build.VERSION.SDK_INT >= 21 && manager != null) { + Object enabled = AccessibilityManager.class.getMethod("isHighTextContrastEnabled") + .invoke(manager); + return enabled instanceof Boolean && ((Boolean)enabled).booleanValue(); + } + } catch (Throwable t) { + // Fall through to the secure settings used by older Android stubs. + } + return secureSettingEnabled("high_text_contrast_enabled") + || secureSettingEnabled("accessibility_display_high_text_contrast_enabled"); + } + + @Override + public boolean isDifferentiateWithoutColorEnabled() { + return secureSettingEnabled("accessibility_display_daltonizer_enabled"); + } + + @Override + public AccessibilityColorVisionDeficiency getColorVisionDeficiency() { + if (!secureSettingEnabled("accessibility_display_daltonizer_enabled")) { + return AccessibilityColorVisionDeficiency.NONE; + } + try { + int mode = Settings.Secure.getInt(getContext().getContentResolver(), + "accessibility_display_daltonizer"); + switch (mode) { + case 0: return AccessibilityColorVisionDeficiency.MONOCHROMACY; + case 11: return AccessibilityColorVisionDeficiency.PROTANOPIA; + case 12: return AccessibilityColorVisionDeficiency.DEUTERANOPIA; + case 13: return AccessibilityColorVisionDeficiency.TRITANOPIA; + default: return AccessibilityColorVisionDeficiency.UNKNOWN; + } + } catch (Throwable t) { + return AccessibilityColorVisionDeficiency.UNKNOWN; + } + } + + @Override + public boolean isReduceMotionEnabled() { + try { + return Settings.Global.getFloat(getContext().getContentResolver(), + Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f; + } catch (Throwable t) { + return false; + } + } + + @Override + public boolean isBoldTextEnabled() { + try { + Object value = Configuration.class.getField("fontWeightAdjustment") + .get(getContext().getResources().getConfiguration()); + return value instanceof Integer && ((Integer)value).intValue() >= 300; + } catch (Throwable t) { + return false; + } + } + + @Override + public boolean isInvertColorsEnabled() { + return secureSettingEnabled("accessibility_display_inversion_enabled"); + } + + @Override + public boolean isGrayscaleEnabled() { + return getColorVisionDeficiency() == AccessibilityColorVisionDeficiency.MONOCHROMACY; + } + + @Override + public boolean isScreenReaderEnabled() { + try { + AccessibilityManager manager = (AccessibilityManager)getContext() + .getSystemService(Context.ACCESSIBILITY_SERVICE); + return manager != null && manager.isEnabled() && manager.isTouchExplorationEnabled(); + } catch (Throwable t) { + return false; + } + } + + private boolean secureSettingEnabled(String key) { + try { + return Settings.Secure.getInt(getContext().getContentResolver(), key, 0) == 1; + } catch (Throwable t) { + return false; + } + } + + @Override + public void accessibilityTreeChanged(final int changeType) { + final Activity act = getActivity(); + if (act == null || accessibilityProvider == null) return; + act.runOnUiThread(new Runnable() { + public void run() { + if (accessibilityProvider != null) accessibilityProvider.invalidate(changeType); + } + }); + } + + @Override + public boolean isAccessibilityTreeSupported() { + return Build.VERSION.SDK_INT >= 16; + } + + @Override + public boolean isAccessibilityTreeUpdateRequired() { + return accessibilityTreeUpdateRequired; + } + + void setAccessibilityTreeUpdateRequired(boolean required) { + accessibilityTreeUpdateRequired = required; + } + + // ================================================================ + // Crypto bridge -- routes com.codename1.security onto the standard + // Android JCE provider. + + private static java.security.SecureRandom androidSecureRandom; + private static final Object androidSecureRandomSync = new Object(); + + private static java.security.SecureRandom androidSecureRandom() { + synchronized (androidSecureRandomSync) { + if (androidSecureRandom == null) { + androidSecureRandom = new java.security.SecureRandom(); + } + return androidSecureRandom; + } + } + + @Override + public void secureRandomBytes(byte[] out) { + if (out == null) return; + androidSecureRandom().nextBytes(out); + } + + @Override + public byte[] aesEncrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] plaintext) { + return androidAes(transformation, key, iv, aad, plaintext, javax.crypto.Cipher.ENCRYPT_MODE); + } + + @Override + public byte[] aesDecrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] ciphertext) { + return androidAes(transformation, key, iv, aad, ciphertext, javax.crypto.Cipher.DECRYPT_MODE); + } + + private static byte[] androidAes(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] input, int mode) { + try { + javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation); + javax.crypto.spec.SecretKeySpec keySpec = new javax.crypto.spec.SecretKeySpec(key, "AES"); + String tu = transformation == null ? "" : transformation.toUpperCase(); + if (tu.indexOf("GCM") >= 0) { + cipher.init(mode, keySpec, new javax.crypto.spec.GCMParameterSpec(128, iv)); + } else if (iv != null) { + cipher.init(mode, keySpec, new javax.crypto.spec.IvParameterSpec(iv)); + } else { + cipher.init(mode, keySpec); + } + if (aad != null && aad.length > 0) { + cipher.updateAAD(aad); + } + return cipher.doFinal(input); + } catch (java.security.GeneralSecurityException e) { + throw new RuntimeException("AES " + (mode == javax.crypto.Cipher.ENCRYPT_MODE ? "encrypt" : "decrypt") + " failed: " + e.getMessage()); + } + } + + /// The RSA transformations this port implements, matched exactly. + /// + /// A substring test for "OAEP" would answer every OAEP name -- including + /// RSA/ECB/OAEPWithSHA-1AndMGF1Padding -- with the SHA-256 parameters below, + /// producing ciphertext no standards-compliant peer could read under the name + /// it asked for. The native ports already accept only these two, so refusing + /// anything else here keeps every port answering the same question. + private static boolean cn1IsOaepTransformation(String transformation) { + return com.codename1.security.Cipher.RSA_OAEP_SHA256.equals(transformation); + } + + private static void cn1CheckRsaTransformation(String transformation) { + if (!cn1IsOaepTransformation(transformation) + && !com.codename1.security.Cipher.RSA_PKCS1.equals(transformation)) { + throw new RuntimeException("unsupported cipher transformation: " + transformation); + } + } + + /// The OAEP parameters every port agrees on. + /// + /// The JCE transformation name "OAEPWithSHA-256AndMGF1Padding" leaves MGF1 on + /// SHA-1 by default, which no other backend here can reproduce: Web Crypto's + /// RSA-OAEP uses one hash for the label and the mask, and so does Apple's + /// SecKey. Naming SHA-256 for both is the only pairing all six ports can + /// produce, so it is what the portable constant means -- stated explicitly + /// rather than inherited from a provider default. + private static javax.crypto.spec.OAEPParameterSpec cn1OaepParameters() { + return new javax.crypto.spec.OAEPParameterSpec("SHA-256", "MGF1", + java.security.spec.MGF1ParameterSpec.SHA256, + javax.crypto.spec.PSource.PSpecified.DEFAULT); + } + + @Override + public byte[] rsaEncrypt(String transformation, byte[] publicKeyX509, byte[] plaintext) { + try { + javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation); + java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); + java.security.PublicKey key = kf.generatePublic(new java.security.spec.X509EncodedKeySpec(publicKeyX509)); + cn1CheckRsaTransformation(transformation); + if (cn1IsOaepTransformation(transformation)) { + cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key, cn1OaepParameters()); + } else { + cipher.init(javax.crypto.Cipher.ENCRYPT_MODE, key); + } + return cipher.doFinal(plaintext); + } catch (java.security.GeneralSecurityException e) { + throw new RuntimeException("RSA encrypt failed: " + e.getMessage()); + } + } + + @Override + public byte[] rsaDecrypt(String transformation, byte[] privateKeyPkcs8, byte[] ciphertext) { + try { + javax.crypto.Cipher cipher = javax.crypto.Cipher.getInstance(transformation); + java.security.KeyFactory kf = java.security.KeyFactory.getInstance("RSA"); + java.security.PrivateKey key = kf.generatePrivate(new java.security.spec.PKCS8EncodedKeySpec(privateKeyPkcs8)); + cn1CheckRsaTransformation(transformation); + if (cn1IsOaepTransformation(transformation)) { + cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key, cn1OaepParameters()); + } else { + cipher.init(javax.crypto.Cipher.DECRYPT_MODE, key); + } + return cipher.doFinal(ciphertext); + } catch (java.security.GeneralSecurityException e) { + throw new RuntimeException("RSA decrypt failed: " + e.getMessage()); + } + } + + @Override + public byte[] cryptoSign(String algorithm, String keyAlgorithm, byte[] privateKeyPkcs8, byte[] data) { + try { + java.security.KeyFactory kf = java.security.KeyFactory.getInstance(keyAlgorithm); + java.security.PrivateKey priv = kf.generatePrivate(new java.security.spec.PKCS8EncodedKeySpec(privateKeyPkcs8)); + java.security.Signature sig = java.security.Signature.getInstance(algorithm); + sig.initSign(priv); + sig.update(data); + return sig.sign(); + } catch (java.security.GeneralSecurityException e) { + throw new RuntimeException("sign failed: " + e.getMessage()); + } + } + + @Override + public boolean cryptoVerify(String algorithm, String keyAlgorithm, byte[] publicKeyX509, byte[] data, byte[] signature) { + try { + java.security.KeyFactory kf = java.security.KeyFactory.getInstance(keyAlgorithm); + java.security.PublicKey pub = kf.generatePublic(new java.security.spec.X509EncodedKeySpec(publicKeyX509)); + java.security.Signature sig = java.security.Signature.getInstance(algorithm); + sig.initVerify(pub); + sig.update(data); + return sig.verify(signature); + } catch (java.security.GeneralSecurityException e) { + throw new RuntimeException("verify failed: " + e.getMessage()); + } + } + + @Override + public byte[][] generateRsaKeyPair(int bits) { + try { + java.security.KeyPairGenerator kpg = java.security.KeyPairGenerator.getInstance("RSA"); + kpg.initialize(bits); + java.security.KeyPair kp = kpg.generateKeyPair(); + return new byte[][]{ kp.getPublic().getEncoded(), kp.getPrivate().getEncoded() }; + } catch (java.security.GeneralSecurityException e) { + throw new RuntimeException("RSA keypair generation failed: " + e.getMessage()); + } + } +} From 20ba08cecc3683e10df6c499fd2193e31b88c75a Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 06:34:38 +0300 Subject: [PATCH 97/99] Do not learn a slug from a link, require the code to look like one, and give each chooser its own callback Three review findings. The slug was learned from an incoming link when the build had none of its own, and the justification that sat there did not survive reading it again: it argued from the BARE link form, which never reaches that branch. The only urls that do are the ones carrying a slug -- and a build whose App Links filter claims /i/ broadly is handed another app's link on the host every enrolled app shares. So the only thing it could ever learn from was a stranger's slug, and every invite minted before the first registration response then advertised their path. The two sources that remain are the build hint and the registration response. extractCode accepted any nonempty final path component, so /i/ on the shared host was consumed, written into PENDING and put through the claim retries -- and on a fresh install the no-match that came back could settle attribution before the Play referrer or the App Clip handoff was looked at. Codes now have to be CODE_CHARS of url-safe base64, the same shape the server checks before letting a registration create a row. What that does NOT buy is stated in the test: 22 crafted characters still get a claim and a no-match. This filters accidents and garbage, not an attacker. Manual short codes are unaffected -- they are entered by the invitee and reach the claim path directly, never through a url. The test codes were placeholders like ABC123 that no mint could produce, so they are now real-shaped; the slugs they sit behind are untouched. And the chooser fix from the previous round traded one bug for another. A single replaceable listener meant two share() calls that both present a chooser before either reports a selection would have the second overwrite the first: picking a target in the first chooser invoked the SECOND call's listener, and the second result was dropped against a field already cleared. The per-call receiver this replaced did not have that fault. Each chooser now carries its own token in the PendingIntent and the listeners live in a map the one receiver reads by token. An entry for a DISMISSED chooser still cannot be reclaimed -- Android reports nothing for one -- so the map is bounded and drops the oldest, which is the same outcome the single field gave and only for shares that old. Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 65 ++++++++++- .../impl/android/AndroidImplementation.java | 68 ++++++++--- .../invite/InviteConsentAndErasureTest.java | 26 ++--- .../analytics/invite/InviteDeliveryTest.java | 20 ++-- .../invite/InviteFunnelEventsTest.java | 10 +- .../invite/InviteResilienceTest.java | 76 ++++++------ .../invite/InviteUrlParsingTest.java | 108 +++++++++++++----- 7 files changed, 257 insertions(+), 116 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index 99bce4dc1a1..ccf3ea7b53e 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1950,13 +1950,66 @@ static String extractCode(String url) { // own, so its later mints advertised their links. return null; } - if (pathSlug != null && (mine == null || mine.length() == 0)) { - // Learned only when this build has no slug of its own to - // contradict: the bare form is what a first offline mint produces, - // and the server hands the slugged one back on registration. - Preferences.set(PREF_SLUG, pathSlug); + // NOT learned from the link, and the justification that used to sit + // here did not survive reading it again. It said the bare form is what + // a first offline mint produces and the server hands the slugged one + // back on registration -- both true, and neither an argument for this + // branch, which only runs when the url DID carry a slug. So the only + // thing it ever learned from was a slug somebody else put in a url. + // + // A build whose App Links filter claims /i/ broadly is handed another + // app's /i// on the host every enrolled app shares. The + // guard above refuses to CLAIM that invite once this build knows its + // own slug; a build that does not yet know one adopted the stranger's, + // and every invite it minted before its first registration response + // then advertised their path. + // + // The two sources that remain are the ones that can be trusted: the + // build hint the builder writes into invite.slug, and the registration + // response. + // + // The CODE has to look like a code. + // + // Any nonempty final path component was accepted, so a same-host url + // like /i/ was consumed, written into the PENDING record and + // put through the claim retries -- and on a fresh install the no-match + // that came back could settle attribution before the referrer or the + // App Clip handoff had been looked at, which is the answer that + // actually mattered. The framework's codes are always CODE_CHARS of + // url-safe base64, the same shape the server checks before letting a + // registration create a row. A manual short code is entered by the + // invitee and reaches the claim path directly, so it never comes + // through here and this does not narrow it. + return isWellFormedCode(code) ? code : null; + } + + /// Whether this is the shape the framework mints: CODE_CHARS characters + /// of url-safe base64, and nothing else. + /// + /// Spelled out rather than done with a regex, because the core has to run + /// where one is not available, and by hand so no locale can fold a + /// character out from under it. + /// + /// #### Parameters + /// + /// - `code`: the candidate, may be null + /// + /// #### Returns + /// + /// true when it could be a code this framework produced + static boolean isWellFormedCode(String code) { + if (code == null || code.length() != CODE_CHARS) { + return false; } - return code.length() == 0 ? null : code; + for (int i = 0; i < code.length(); i++) { + char c = code.charAt(i); + boolean ok = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') + || (c >= '0' && c <= '9') || c == '-' || c == '_'; + if (!ok) { + return false; + } + } + return true; } // Parses a referrer or query string for the invite key. Split on the diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index 2ff1c8bab98..bed65c481e7 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -10112,25 +10112,60 @@ public void share(String text, String image, String mimeType, Rectangle sourceRe // different claim -- one SpotBugs reads as a threading bug, correctly, // because nothing here would make it safe if it were true. // - // pendingShareListener is written from the Codename One EDT and read on - // the Android main thread, which is why it is volatile. That is a native - // boundary crossing, not core framework code. + // Each chooser gets its OWN entry, keyed by a token the PendingIntent + // carries back, and they share the one receiver. + // + // A single replaceable listener was wrong: two share() calls that both + // present a chooser before either reports a selection would have the + // second overwrite the first, so picking a target in the first chooser + // invoked the SECOND call's listener and the second result was then + // dropped against a field that had already been cleared. The per-call + // receiver this replaced did not have that fault -- it gave each chooser + // its own action and its own PendingIntent -- so keeping the leak fixed + // must not cost that. + // + // What cannot be reclaimed is an entry for a chooser the user DISMISSED, + // because Android reports nothing for one. The map is bounded instead: + // beyond MAX_PENDING_SHARES the oldest is dropped, which is the same + // outcome the single field gave and only for shares that old. Insertion + // order is what LinkedHashMap gives, and the oldest outstanding chooser is + // the one the user is least likely to still be looking at. + // + // Touched from the Codename One EDT (share) and the Android main thread + // (onReceive), so every access is synchronized on the map itself. That is + // a native boundary crossing, not core framework code. private BroadcastReceiver shareChooserReceiver; private String shareChooserAction; - private volatile com.codename1.share.ShareResultListener pendingShareListener; + private static final String EXTRA_SHARE_TOKEN = "cn1ShareToken"; + + private static final int MAX_PENDING_SHARES = 8; + + private int nextShareToken = 1; + + private final java.util.LinkedHashMap + pendingShares = + new java.util.LinkedHashMap(); @TargetApi(22) private Intent buildShareChooserWithCallback(Intent shareIntent, final com.codename1.share.ShareResultListener listener) { final Context appCtx = getContext().getApplicationContext(); - // The listener this chooser is for. Set before the receiver can - // possibly fire, and replacing whatever a dismissed chooser left. - pendingShareListener = listener; + // This chooser's own token, recorded before the receiver can fire. + final int token; + synchronized (pendingShares) { + token = nextShareToken++; + pendingShares.put(Integer.valueOf(token), listener); + while (pendingShares.size() > MAX_PENDING_SHARES) { + java.util.Iterator oldest = pendingShares.keySet().iterator(); + oldest.next(); + oldest.remove(); + } + } if (shareChooserReceiver != null) { // Already registered and listening on the same action, so there is // nothing to build but the PendingIntent below. - return chooserFor(appCtx, shareIntent, shareChooserAction); + return chooserFor(appCtx, shareIntent, shareChooserAction, token); } final String action = appCtx.getPackageName() + ".CN1_SHARE_CHOSEN"; shareChooserAction = action; @@ -10141,10 +10176,14 @@ private Intent buildShareChooserWithCallback(Intent shareIntent, final com.coden BroadcastReceiver receiver = new BroadcastReceiver() { @Override public void onReceive(Context ctx, Intent intent) { - // Taken, so a repeat broadcast cannot deliver twice. The - // receiver stays registered for the next share. - com.codename1.share.ShareResultListener target = pendingShareListener; - pendingShareListener = null; + // Taken by token, so this delivers to the chooser it belongs + // to and a repeat broadcast cannot deliver twice. The receiver + // stays registered for the next share. + com.codename1.share.ShareResultListener target; + synchronized (pendingShares) { + target = pendingShares.remove(Integer.valueOf( + intent.getIntExtra(EXTRA_SHARE_TOKEN, -1))); + } if (target == null) { return; } @@ -10180,7 +10219,7 @@ public void onReceive(Context ctx, Intent intent) { // dismissal: there is no public API to observe a user-cancel. // Apps that need a dismissal signal must use Activity-resume. - return chooserFor(appCtx, shareIntent, action); + return chooserFor(appCtx, shareIntent, action, token); } /// The chooser Intent itself, wrapping a broadcast PendingIntent on this @@ -10191,8 +10230,9 @@ public void onReceive(Context ctx, Intent intent) { /// safe to reuse: the same PendingIntent is handed back with this /// chooser's extras, and only one chooser is ever up at a time. @TargetApi(22) - private Intent chooserFor(Context appCtx, Intent shareIntent, String action) { + private Intent chooserFor(Context appCtx, Intent shareIntent, String action, int token) { Intent pi = new Intent(action).setPackage(appCtx.getPackageName()); + pi.putExtra(EXTRA_SHARE_TOKEN, token); int piFlags = PendingIntent.FLAG_UPDATE_CURRENT; if (android.os.Build.VERSION.SDK_INT >= 31) { // FLAG_MUTABLE was introduced in API 31; its numeric value diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java index aa354039c86..3aed269cba0 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java @@ -53,11 +53,11 @@ void resolutionWritesTheReferralDimensions() { implementation.setAutoProcessConnections(false); Invites.handleResolution( - InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + InviteTestSupport.resolvedJson("ABC123xxxxxxxxxxxxxxxx", "spring", "sms"), Invites.MATCH_REFERRER, true); Map dims = Analytics.getDimensions(); - assertEquals("ABC123", dims.get(Invites.DIMENSION_CODE)); + assertEquals("ABC123xxxxxxxxxxxxxxxx", dims.get(Invites.DIMENSION_CODE)); assertEquals("spring", dims.get(Invites.DIMENSION_CAMPAIGN)); assertEquals("sms", dims.get(Invites.DIMENSION_CHANNEL)); assertEquals(Invites.MATCH_REFERRER, dims.get(Invites.DIMENSION_MATCH)); @@ -68,7 +68,7 @@ void theDimensionsRideEveryLaterBatch() { InviteTestSupport.freshInstall(); implementation.setAutoProcessConnections(false); Invites.handleResolution( - InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + InviteTestSupport.resolvedJson("ABC123xxxxxxxxxxxxxxxx", "spring", "sms"), Invites.MATCH_REFERRER, true); Analytics.clearProviders(); @@ -97,7 +97,7 @@ void resetClientIdErasesTheReferralDimensionsAndKeepsTheApplicationsOwn() { implementation.setAutoProcessConnections(false); Analytics.setDimension("plan", "pro"); Invites.handleResolution( - InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + InviteTestSupport.resolvedJson("ABC123xxxxxxxxxxxxxxxx", "spring", "sms"), Invites.MATCH_REFERRER, true); assertNotNull(Invites.getAttribution()); @@ -134,7 +134,7 @@ void anerasedInstallDoesNotStartLookingAgainByItself() { InviteTestSupport.freshInstall(); implementation.setAutoProcessConnections(false); Invites.handleResolution( - InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + InviteTestSupport.resolvedJson("ABC123xxxxxxxxxxxxxxxx", "spring", "sms"), Invites.MATCH_REFERRER, true); assertNotNull(Invites.getAttribution()); @@ -152,7 +152,7 @@ void anerasedInstallDoesNotStartLookingAgainByItself() { // And a NEW invite still reopens it: erasing an identity is not a // decision about an invite the person taps afterwards. - assertTrue(Invites.handleUrl("https://cloud.codenameone.com/i/acme/AFTER1")); + assertTrue(Invites.handleUrl("https://cloud.codenameone.com/i/acme/AFTER1xxxxxxxxxxxxxxxx")); assertEquals(Invites.STATE_PENDING, Invites.getState(), "a direct invite could not reopen attribution after an erasure"); } @@ -170,7 +170,7 @@ void anerasureIsNotReportedDoneWhileTheAttributionSurvives() { InviteTestSupport.freshInstall(); implementation.setAutoProcessConnections(false); Invites.handleResolution( - InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + InviteTestSupport.resolvedJson("ABC123xxxxxxxxxxxxxxxx", "spring", "sms"), Invites.MATCH_REFERRER, true); assertNotNull(Invites.getAttribution()); @@ -231,7 +231,7 @@ void asurvivingCodeIsNotClaimedUnderTheNewIdentity() { // own aftermath. InviteTestSupport.freshInstall(); implementation.setAutoProcessConnections(false); - Invites.handleUrl("https://cloud.codenameone.com/i/ABC123"); + Invites.handleUrl("https://cloud.codenameone.com/i/ABC123xxxxxxxxxxxxxxxx"); implementation.clearQueuedRequests(); InviteStore.failNextDeleteForTest(InviteStore.PENDING); @@ -332,7 +332,7 @@ void registeringTheProviderIsNotMistakenForAnErasure() { InviteTestSupport.freshInstall(); implementation.setAutoProcessConnections(false); Invites.handleResolution( - InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + InviteTestSupport.resolvedJson("ABC123xxxxxxxxxxxxxxxx", "spring", "sms"), Invites.MATCH_REFERRER, true); // addProvider calls init() with the current client id, exactly as @@ -458,7 +458,7 @@ void aResponseInFlightDuringAnErasureIsDiscarded() { Analytics.resetClientId(); Invites.handleResolution( - InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + InviteTestSupport.resolvedJson("ABC123xxxxxxxxxxxxxxxx", "spring", "sms"), Invites.MATCH_REFERRER, true, issuedUnder); assertNull(Invites.getAttribution(), "an erased identity was re-attributed"); @@ -475,7 +475,7 @@ void aResponseInFlightWhenConsentIsWithdrawnIsDiscarded() { Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); Invites.handleResolution( - InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + InviteTestSupport.resolvedJson("ABC123xxxxxxxxxxxxxxxx", "spring", "sms"), Invites.MATCH_REFERRER, true, issuedUnder); assertNull(Invites.getAttribution(), "a refusal was overridden by a late response"); @@ -506,7 +506,7 @@ void revokingConsentClearsTheDimensionsButKeepsTheAttribution() { InviteTestSupport.freshInstall(); implementation.setAutoProcessConnections(false); Invites.handleResolution( - InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + InviteTestSupport.resolvedJson("ABC123xxxxxxxxxxxxxxxx", "spring", "sms"), Invites.MATCH_REFERRER, true); Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); @@ -556,7 +556,7 @@ void anErasureClearsTheReferralDimensionsEvenWithNoProviderRegistered() { // registered transmitted them. An erasure cannot depend on who happens // to be registered when it runs. InviteTestSupport.freshInstall(); - Invites.handleResolution(InviteTestSupport.resolvedJson("CODE1", "spring", "sms"), + Invites.handleResolution(InviteTestSupport.resolvedJson("CODE1xxxxxxxxxxxxxxxxx", "spring", "sms"), Invites.MATCH_DIRECT, false); assertNotNull(Analytics.getDimensions().get("cn1_campaign")); Analytics.setDimension("plan", "pro"); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java index e3667a22f0a..8fdd7cc8b62 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteDeliveryTest.java @@ -66,11 +66,11 @@ void anAttributionIsDeliveredExactlyOnce() { Invites.setInviteListener(capture); Invites.handleResolution( - InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + InviteTestSupport.resolvedJson("ABC123xxxxxxxxxxxxxxxx", "spring", "sms"), Invites.MATCH_REFERRER, true); assertEquals(1, capture.received.size()); - assertEquals("ABC123", capture.received.get(0).getCode()); + assertEquals("ABC123xxxxxxxxxxxxxxxx", capture.received.get(0).getCode()); assertTrue(capture.received.get(0).isDeferred()); // Re-entering the facade, as a later start() would, must not deliver @@ -88,14 +88,14 @@ void anAnswerThatArrivesBeforeTheListenerIsHeldAndDeliveredOnRegistration() { // A cold launch from a link resolves before the application has run // start(), so the answer has to wait rather than be dropped. Invites.handleResolution( - InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + InviteTestSupport.resolvedJson("ABC123xxxxxxxxxxxxxxxx", "spring", "sms"), Invites.MATCH_REFERRER, true); Capture capture = new Capture(); Invites.setInviteListener(capture); assertEquals(1, capture.received.size(), "the held attribution was never delivered"); - assertEquals("ABC123", capture.received.get(0).getCode()); + assertEquals("ABC123xxxxxxxxxxxxxxxx", capture.received.get(0).getCode()); } @FormTest @@ -131,7 +131,7 @@ public boolean discardReferrer() { public void requestReferrer(InstallReferrerCallback callback) { callback.onReferrer( - "utm_source=cn1_invite&utm_medium=referral&cn1_invite=ABC123", + "utm_source=cn1_invite&utm_medium=referral&cn1_invite=ABC123xxxxxxxxxxxxxxxx", 1700000000L, 1700000060L); } }); @@ -148,7 +148,7 @@ public void requestReferrer(InstallReferrerCallback callback) { if (url.endsWith("/invites/claim")) { sawClaim = true; assertTrue(implementation.getQueuedRequests().get(i) - .getRequestBody().contains("ABC123")); + .getRequestBody().contains("ABC123xxxxxxxxxxxxxxxx")); } } assertTrue(sawClaim, "expected a deterministic claim"); @@ -384,14 +384,14 @@ void aclipAnswerThatOutlivedItsLookupIsIgnored() { assertTrue(InviteTestSupport.pendingHandoff.wasAsked()); // A link is tapped while the clip read is still outstanding. - assertTrue(Invites.handleUrl("https://cloud.codenameone.com/i/acme/DIRECTWINS")); - assertEquals("DIRECTWINS", + assertTrue(Invites.handleUrl("https://cloud.codenameone.com/i/acme/DIRECTWINSxxxxxxxxxxxx")); + assertEquals("DIRECTWINSxxxxxxxxxxxx", InviteStore.get(InviteStore.read(InviteStore.PENDING), "code", null)); // The clip finally answers, with something else. InviteTestSupport.pendingHandoff.answer("STALECLIP"); - assertEquals("DIRECTWINS", + assertEquals("DIRECTWINSxxxxxxxxxxxx", InviteStore.get(InviteStore.read(InviteStore.PENDING), "code", null), "a clip answer from before the link overwrote the newer exact claim"); } @@ -404,7 +404,7 @@ void aSecondLinkDoesNotRewriteTheFirstTouchCohort() { InviteTestSupport.resolvedJson("FIRST", "spring", "sms"), Invites.MATCH_REFERRER, true); - Invites.handleUrl("https://cloud.codenameone.com/i/SECOND"); + Invites.handleUrl("https://cloud.codenameone.com/i/SECONDxxxxxxxxxxxxxxxx"); InviteAttribution a = Invites.getAttribution(); assertNotNull(a); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteFunnelEventsTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteFunnelEventsTest.java index 4fe909c5569..0b792a517fe 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteFunnelEventsTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteFunnelEventsTest.java @@ -104,7 +104,7 @@ void conversionCarriesValueAndCurrencyOnceAttributed() { RecordingProvider recorder = InviteTestSupport.freshInstall(); implementation.setAutoProcessConnections(false); Invites.handleResolution( - InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + InviteTestSupport.resolvedJson("ABC123xxxxxxxxxxxxxxxx", "spring", "sms"), Invites.MATCH_REFERRER, true); recorder.clear(); @@ -113,7 +113,7 @@ void conversionCarriesValueAndCurrencyOnceAttributed() { AnalyticsEvent e = recorder.first("invite_converted"); assertNotNull(e, "expected invite_converted, saw " + recorder.names()); assertEquals(Invites.CATEGORY, e.getCategory()); - assertEquals("ABC123", e.getParameters().get("invite_code")); + assertEquals("ABC123xxxxxxxxxxxxxxxx", e.getParameters().get("invite_code")); assertEquals("spring", e.getParameters().get("campaign")); assertEquals("signup", e.getParameters().get("action")); assertEquals("USD", e.getParameters().get("currency")); @@ -127,7 +127,7 @@ void aDeferredResolutionReportsAnInstallRatherThanAnOpen() { recorder.clear(); Invites.handleResolution( - InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + InviteTestSupport.resolvedJson("ABC123xxxxxxxxxxxxxxxx", "spring", "sms"), Invites.MATCH_REFERRER, true); assertNotNull(recorder.first("invite_install")); @@ -143,7 +143,7 @@ void aDirectOpenReportsAnOpenRatherThanAnInstall() { recorder.clear(); Invites.handleResolution( - InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + InviteTestSupport.resolvedJson("ABC123xxxxxxxxxxxxxxxx", "spring", "sms"), Invites.MATCH_DIRECT, false); assertNotNull(recorder.first("invite_opened")); @@ -157,7 +157,7 @@ void everyFunnelEventUsesTheReferralCategory() { Invite invite = Invites.create(InviteRequest.create().build()); Invites.reportShareResult(invite, ShareResult.sharedTo("com.whatsapp")); Invites.handleResolution( - InviteTestSupport.resolvedJson("ABC123", "spring", "sms"), + InviteTestSupport.resolvedJson("ABC123xxxxxxxxxxxxxxxx", "spring", "sms"), Invites.MATCH_REFERRER, true); Invites.conversion("signup"); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 82950b85c63..98a1024dc2a 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -326,7 +326,7 @@ void anErasureKillsAqueuedClaimToo() { implementation.setAutoProcessConnections(false); implementation.clearQueuedRequests(); - Invites.handleUrl("https://cloud.codenameone.com/i/acme/CLAIMKILL"); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/CLAIMKILLxxxxxxxxxxxxx"); java.util.List queued = implementation.getQueuedRequests(); assertTrue(queued.size() > 0, "the fixture queued no claim at all"); @@ -807,7 +807,7 @@ void aPendingReattributionSurvivesAProcessRestart() { assertEquals(Invites.STATE_RESOLVED, Invites.getState()); Invites.setReattribution(true); - Invites.handleUrl("https://cloud.codenameone.com/i/acme/second"); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/secondxxxxxxxxxxxxxxxx"); Invites.forgetLoadedState(); assertEquals(Invites.STATE_PENDING, Invites.getState(), @@ -938,8 +938,8 @@ void aDirectLinkSupersedesADeferredLookupAlreadyOnTheWire() { Invites.checkForInvite(); int deferredEpoch = Invites.currentLookupEpochForTest(); - Invites.handleUrl("https://cloud.codenameone.com/i/acme/DIRECT1"); - Invites.handleResolution(InviteTestSupport.resolvedJson("DIRECT1", "c1", "sms"), + Invites.handleUrl("https://cloud.codenameone.com/i/acme/DIRECT1xxxxxxxxxxxxxxx"); + Invites.handleResolution(InviteTestSupport.resolvedJson("DIRECT1xxxxxxxxxxxxxxx", "c1", "sms"), Invites.MATCH_DIRECT, false); // The deferred answer arrives late, under the epoch it was issued in. @@ -948,7 +948,7 @@ void aDirectLinkSupersedesADeferredLookupAlreadyOnTheWire() { InviteAttribution a = Invites.getAttribution(); assertNotNull(a); - assertEquals("DIRECT1", a.getCode(), + assertEquals("DIRECT1xxxxxxxxxxxxxxx", a.getCode(), "a late statistical match overwrote the exact direct attribution"); } @@ -1122,7 +1122,7 @@ public void attributionUnavailable(String reason) { told[0] = reason; } }); - Invites.handleUrl("https://cloud.codenameone.com/i/acme/CODE1"); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/CODE1xxxxxxxxxxxxxxxxx"); assertEquals(Invites.REASON_CONSENT_DENIED, told[0], "a refused direct link told the listener nothing"); } @@ -1252,11 +1252,11 @@ void asettledClaimWhosePendingRecordSurvivesIsNotAskedAgain() { InviteTestSupport.freshInstall(); implementation.setAutoProcessConnections(false); Invites.setReattribution(true); - Invites.handleUrl("https://cloud.codenameone.com/i/PENDSURV"); + Invites.handleUrl("https://cloud.codenameone.com/i/PENDSURVxxxxxxxxxxxxxx"); InviteStore.failNextDeleteForTest(InviteStore.PENDING); Invites.handleResolution( - InviteTestSupport.resolvedJson("PENDSURV", "spring", "sms"), + InviteTestSupport.resolvedJson("PENDSURVxxxxxxxxxxxxxx", "spring", "sms"), Invites.MATCH_DIRECT, false); assertNotNull(Invites.getAttribution(), "the fixture did not resolve"); @@ -1314,14 +1314,14 @@ public boolean discardReferrer() { } public void requestReferrer(InstallReferrerCallback callback) { - callback.onReferrer("utm_source=cn1_invite&cn1_invite=EXACT9", 0L, 0L); + callback.onReferrer("utm_source=cn1_invite&cn1_invite=EXACT9xxxxxxxxxxxxxxxx", 0L, 0L); } }); Invites.checkForInvite(); Map pending = InviteStore.read(InviteStore.PENDING); assertNotNull(pending, "the pending record was not kept at all"); - assertEquals("EXACT9", InviteStore.get(pending, "code", null), + assertEquals("EXACT9xxxxxxxxxxxxxxxx", InviteStore.get(pending, "code", null), "the exact referrer code was not persisted before the claim"); } @@ -1498,14 +1498,14 @@ void aFailedPendingWriteDoesNotLoseTheDirectCode() { "the fixture already has a code, so the assertion below proves nothing"); InviteStore.failNextWriteForTest(InviteStore.PENDING); - assertTrue(Invites.handleUrl("https://cloud.codenameone.com/i/acme/DIRECT7"), + assertTrue(Invites.handleUrl("https://cloud.codenameone.com/i/acme/DIRECT7xxxxxxxxxxxxxxx"), "the link was not recognised at all"); assertEquals(Invites.STATE_PENDING, Invites.getState()); // And the next read of the record still has the code, and persists it. Map record = Invites.pendingRecordForTest(); assertNotNull(record, "the failed write was never retried"); - assertEquals("DIRECT7", InviteStore.get(record, "code", null), + assertEquals("DIRECT7xxxxxxxxxxxxxxx", InviteStore.get(record, "code", null), "the exact code was lost, so the retry will guess instead"); assertEquals(Invites.MATCH_DIRECT, InviteStore.get(record, "codeMatch", null), "the direct claim lost its provenance"); @@ -1558,8 +1558,8 @@ public void requestReferrer(InstallReferrerCallback callback) { Invites.checkForInvite(); assertNotNull(held[0], "the referrer read was never issued"); - Invites.handleUrl("https://cloud.codenameone.com/i/acme/DIRECT2"); - Invites.handleResolution(InviteTestSupport.resolvedJson("DIRECT2", "c1", "sms"), + Invites.handleUrl("https://cloud.codenameone.com/i/acme/DIRECT2xxxxxxxxxxxxxxx"); + Invites.handleResolution(InviteTestSupport.resolvedJson("DIRECT2xxxxxxxxxxxxxxx", "c1", "sms"), Invites.MATCH_DIRECT, false); // The referrer finally answers, carrying a different code. It must be @@ -1575,7 +1575,7 @@ public void requestReferrer(InstallReferrerCallback callback) { "a stale referrer callback wrote its code and issued a claim"); InviteAttribution a = Invites.getAttribution(); assertNotNull(a); - assertEquals("DIRECT2", a.getCode()); + assertEquals("DIRECT2xxxxxxxxxxxxxxx", a.getCode()); } @Test @@ -1817,7 +1817,7 @@ void aFailedReplacementPutsTheInstallBackWhereItWas() { Invites.handleResolution(InviteTestSupport.resolvedJson("FIRST3", "c1", "sms"), Invites.MATCH_DIRECT, false); Invites.setReattribution(true); - Invites.handleUrl("https://cloud.codenameone.com/i/acme/SECOND3"); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/SECOND3xxxxxxxxxxxxxxx"); assertEquals(Invites.STATE_PENDING, Invites.getState()); Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_DIRECT, false); @@ -1842,7 +1842,7 @@ void aFailedReplacementWhosePendingRecordSurvivesIsNotAskedAgain() { Invites.handleResolution(InviteTestSupport.resolvedJson("FIRST4", "c1", "sms"), Invites.MATCH_DIRECT, false); Invites.setReattribution(true); - Invites.handleUrl("https://cloud.codenameone.com/i/acme/SECOND4"); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/SECOND4xxxxxxxxxxxxxxx"); assertEquals(Invites.STATE_PENDING, Invites.getState()); InviteStore.failNextDeleteForTest(InviteStore.PENDING); @@ -1939,7 +1939,7 @@ void anExhaustedReplacementLeavesTheEarlierAnswerStanding() { Invites.handleResolution(InviteTestSupport.resolvedJson("FIRST4", "c1", "sms"), Invites.MATCH_DIRECT, false); Invites.setReattribution(true); - Invites.handleUrl("https://cloud.codenameone.com/i/acme/SECOND4"); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/SECOND4xxxxxxxxxxxxxxx"); Map pending = InviteStore.read(InviteStore.PENDING); pending.put("attempts", "99"); @@ -1994,7 +1994,7 @@ public void attributionUnavailable(String reason) { told[0]++; } }); - Invites.handleUrl("https://cloud.codenameone.com/i/acme/SECOND5"); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/SECOND5xxxxxxxxxxxxxxx"); assertEquals(0, told[0], "a denied link told an attributed install it had no invite"); Invites.forgetLoadedState(); @@ -2047,10 +2047,10 @@ void aDirectLinkGetsItsOwnWindowAndBudget() { stale.put("attempts", String.valueOf(99)); InviteStore.write(InviteStore.PENDING, stale); - Invites.handleUrl("https://cloud.codenameone.com/i/acme/FRESH1"); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/FRESH1xxxxxxxxxxxxxxxx"); Map pending = InviteStore.read(InviteStore.PENDING); - assertEquals("FRESH1", InviteStore.get(pending, "code", null)); + assertEquals("FRESH1xxxxxxxxxxxxxxxx", InviteStore.get(pending, "code", null)); assertTrue(InviteStore.getLong(pending, "expiresAt", 0) > System.currentTimeMillis(), "the direct claim inherited an expired window"); // One, not zero: the reset puts it back to zero and the claim this @@ -2092,8 +2092,8 @@ void theZeroWindowDoesNotDiscardAnExactCodeWeAreHolding() { // one that needs a window to mean anything. A code already in hand is // an exact answer that needs none, and refusing to send it reported // "unsupported" for an invite the user really did open. - Invites.handleUrl("https://cloud.codenameone.com/i/acme/EXACT9"); - assertEquals("EXACT9", InviteStore.get( + Invites.handleUrl("https://cloud.codenameone.com/i/acme/EXACT9xxxxxxxxxxxxxxxx"); + assertEquals("EXACT9xxxxxxxxxxxxxxxx", InviteStore.get( InviteStore.read(InviteStore.PENDING), "code", null)); Invites.setAttributionWindow(0); @@ -2121,17 +2121,17 @@ void thekillSwitchStillLetsAnExactAnswerLand() { // begins, and refusing a direct claim on the way back in would break // the same exemption from the other end. This is why the guard reads // the deferred flag rather than bumping the epoch, which is global. - Invites.handleUrl("https://cloud.codenameone.com/i/acme/EXACT7"); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/EXACT7xxxxxxxxxxxxxxxx"); int inFlight = Invites.currentLookupEpochForTest(); Invites.setAttributionWindow(0); - Invites.handleResolution(InviteTestSupport.resolvedJson("EXACT7", "c1", "sms"), + Invites.handleResolution(InviteTestSupport.resolvedJson("EXACT7xxxxxxxxxxxxxxxx", "c1", "sms"), Invites.MATCH_DIRECT, false, inFlight); InviteAttribution a = Invites.getAttribution(); assertNotNull(a, "the kill switch discarded an exact answer we had asked for"); - assertEquals("EXACT7", a.getCode()); + assertEquals("EXACT7xxxxxxxxxxxxxxxx", a.getCode()); } @Test @@ -2166,7 +2166,7 @@ void turningOnReattributionLetsTheStateBeReadAgain() { Invites.handleResolution(InviteTestSupport.resolvedJson("FIRST6", "c1", "sms"), Invites.MATCH_DIRECT, false); Invites.setReattribution(true); - Invites.handleUrl("https://cloud.codenameone.com/i/acme/SECOND6"); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/SECOND6xxxxxxxxxxxxxxx"); Invites.setReattribution(false); // A later process: the listener is registered first, caching the state @@ -2192,13 +2192,13 @@ void turningReattributionOffDiscardsAreplacementAlreadyInFlight() { Invites.handleResolution(InviteTestSupport.resolvedJson("FIRST8", "c1", "sms"), Invites.MATCH_DIRECT, false); Invites.setReattribution(true); - Invites.handleUrl("https://cloud.codenameone.com/i/acme/SECOND8"); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/SECOND8xxxxxxxxxxxxxxx"); int inFlight = Invites.currentLookupEpochForTest(); Invites.setReattribution(false); // The response that was already on the wire lands now. - Invites.handleResolution(InviteTestSupport.resolvedJson("SECOND8", "c1", "sms"), + Invites.handleResolution(InviteTestSupport.resolvedJson("SECOND8xxxxxxxxxxxxxxx", "c1", "sms"), Invites.MATCH_DIRECT, false, inFlight); InviteAttribution a = Invites.getAttribution(); @@ -2247,8 +2247,8 @@ void aDirectLinkDiscardsAHeldAnswerThatIsNoLongerTrue() { Invites.handleResolution("{\"resolved\":false}", Invites.MATCH_APP_CLIP, true); assertEquals(Invites.STATE_NONE_FOUND, Invites.getState()); - Invites.handleUrl("https://cloud.codenameone.com/i/acme/LATER6"); - Invites.handleResolution(InviteTestSupport.resolvedJson("LATER6", "c1", "sms"), + Invites.handleUrl("https://cloud.codenameone.com/i/acme/LATER6xxxxxxxxxxxxxxxx"); + Invites.handleResolution(InviteTestSupport.resolvedJson("LATER6xxxxxxxxxxxxxxxx", "c1", "sms"), Invites.MATCH_DIRECT, false); final String[] unavailable = new String[1]; @@ -2274,7 +2274,7 @@ void aSavedExactCodeIsNotSubjectToTheDeferredWindow() { // answer whenever the two coexist -- a zero window, where handleUrl // records an expiry of "now", or a first claim that failed and is // retried after the window ran out. - Invites.handleUrl("https://cloud.codenameone.com/i/acme/SAVED9"); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/SAVED9xxxxxxxxxxxxxxxx"); Map pending = InviteStore.read(InviteStore.PENDING); assertNotNull(pending); pending.put("expiresAt", String.valueOf(System.currentTimeMillis() - 1000L)); @@ -2328,7 +2328,7 @@ void tappingTheSameLinkAgainInALaterRunIsProcessed() { // was ignored for ever: the install lost its invite_opened // re-engagement event, and under re-attribution the later open could // never win. - String url = "https://cloud.codenameone.com/i/acme/TAP1"; + String url = "https://cloud.codenameone.com/i/acme/TAP1xxxxxxxxxxxxxxxxxx"; Display.getInstance().setProperty("AppArg", url); assertTrue(Invites.checkForInvite(), "the first delivery was not handled"); @@ -2356,7 +2356,7 @@ void anInviteArgumentIsConsumedAndAnythingElseIsLeftAlone() { // invite is consumed: an application routing its own deep links must // find its argument exactly as it arrived. Display.getInstance().setProperty("AppArg", - "https://cloud.codenameone.com/i/acme/EATEN1"); + "https://cloud.codenameone.com/i/acme/EATEN1xxxxxxxxxxxxxxxx"); assertTrue(Invites.checkForInvite()); assertNull(Display.getInstance().getProperty("AppArg", null), "the invite argument was left behind for the next read"); @@ -2417,7 +2417,7 @@ void withdrawingConsentDuringAReplacementAbandonsIt() { Invites.handleResolution(InviteTestSupport.resolvedJson("FIRST7", "c1", "sms"), Invites.MATCH_DIRECT, false); Invites.setReattribution(true); - Invites.handleUrl("https://cloud.codenameone.com/i/acme/SECOND7"); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/SECOND7xxxxxxxxxxxxxxx"); final int[] told = new int[1]; Invites.setInviteListener(new InviteListener() { @@ -2463,14 +2463,14 @@ void aDeniedDirectLinkKeepsItsCodeForTheReopening() { // afterwards had the exact claim replaced by a referrer read or a // statistical match, which can miss or credit a different click. Analytics.setConsent(AnalyticsConsent.builder().analytics(false).build()); - Invites.handleUrl("https://cloud.codenameone.com/i/acme/DENIED1"); + Invites.handleUrl("https://cloud.codenameone.com/i/acme/DENIED1xxxxxxxxxxxxxxx"); assertEquals(Invites.STATE_DECLINED, Invites.getState()); Analytics.setConsent(AnalyticsConsent.granted()); Map resumed = InviteStore.read(InviteStore.PENDING); assertNotNull(resumed); - assertEquals("DENIED1", InviteStore.get(resumed, "code", null), + assertEquals("DENIED1xxxxxxxxxxxxxxx", InviteStore.get(resumed, "code", null), "the reopened lookup lost the exact code and fell back to a guess"); } @@ -2739,7 +2739,7 @@ void aRoutedLinkFollowedByTheQueuedCheckClaimsOnce() { implementation.setAutoProcessConnections(false); Invites.setLinkBase("https://cloud.codenameone.com"); - assertTrue(Invites.handleUrl("https://cloud.codenameone.com/i/demo/ONETAP1"), + assertTrue(Invites.handleUrl("https://cloud.codenameone.com/i/demo/ONETAP1xxxxxxxxxxxxxxx"), "the fixture url was not recognised as an invite"); int afterRouting = claimCount(); assertEquals(1, afterRouting, "routing the url did not issue exactly one claim"); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java index a5fe425232f..ef4ee0d50ba 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java @@ -46,41 +46,51 @@ void cleanUp() { @FormTest void recognisesTheSluggedAndBareLinkForms() { InviteTestSupport.freshInstall(); - assertEquals("ABC123", - Invites.extractCode("https://cloud.codenameone.com/i/acme/ABC123")); - // The slug is remembered so later invites mint the precise form, which - // is what keeps two enrolled apps on one device from claiming each - // other's links. - assertEquals("acme", Preferences.get(Invites.PREF_SLUG, "")); + assertEquals("ABC123xxxxxxxxxxxxxxxx", + Invites.extractCode("https://cloud.codenameone.com/i/acme/ABC123xxxxxxxxxxxxxxxx")); + // And the slug is NOT remembered from it. + // + // It used to be, on the reasoning that a build with no slug of its own + // may as well learn one. But the only urls that reach that branch are + // the ones that CARRY a slug, and a build whose App Links filter + // claims /i/ broadly is handed another app's link on the host every + // enrolled app shares -- so the only thing it could learn from was a + // slug somebody else chose. Every invite minted before the first + // registration response then advertised their path. The slug comes + // from the build hint or from a registration response, both of which + // are ours. + assertEquals("", Preferences.get(Invites.PREF_SLUG, ""), + "a slug was learned from an incoming link, so a stranger's link can " + + "rewrite the paths this app mints"); InviteTestSupport.freshInstall(); - assertEquals("ABC123", - Invites.extractCode("https://cloud.codenameone.com/i/ABC123")); + assertEquals("ABC123xxxxxxxxxxxxxxxx", + Invites.extractCode("https://cloud.codenameone.com/i/ABC123xxxxxxxxxxxxxxxx")); } @FormTest void ignoresAForeignHostEvenWhenThePathMatches() { InviteTestSupport.freshInstall(); - assertNull(Invites.extractCode("https://evil.example.com/i/acme/ABC123")); + assertNull(Invites.extractCode("https://evil.example.com/i/acme/ABC123xxxxxxxxxxxxxxxx")); // A prefix of our host is not our host. Matching on startsWith here // would accept a look-alike domain. - assertNull(Invites.extractCode("https://cloud.codenameone.com.evil.test/i/ABC123")); - assertNull(Invites.extractCode("https://staging.cloud.codenameone.com/i/ABC123")); + assertNull(Invites.extractCode("https://cloud.codenameone.com.evil.test/i/ABC123xxxxxxxxxxxxxxxx")); + assertNull(Invites.extractCode("https://staging.cloud.codenameone.com/i/ABC123xxxxxxxxxxxxxxxx")); } @FormTest void hostComparisonIsCaseInsensitiveWithoutCaseFolding() { InviteTestSupport.freshInstall(); - assertEquals("ABC123", - Invites.extractCode("https://CLOUD.CodenameOne.COM/i/ABC123")); + assertEquals("ABC123xxxxxxxxxxxxxxxx", + Invites.extractCode("https://CLOUD.CodenameOne.COM/i/ABC123xxxxxxxxxxxxxxxx")); } @FormTest void readsTheCodeOutOfAReferrerQueryString() { InviteTestSupport.freshInstall(); - assertEquals("ABC123", Invites.codeFromQuery( - "utm_source=cn1_invite&utm_medium=referral&cn1_invite=ABC123")); - assertEquals("ABC123", Invites.codeFromQuery("cn1_invite=ABC123")); + assertEquals("ABC123xxxxxxxxxxxxxxxx", Invites.codeFromQuery( + "utm_source=cn1_invite&utm_medium=referral&cn1_invite=ABC123xxxxxxxxxxxxxxxx")); + assertEquals("ABC123xxxxxxxxxxxxxxxx", Invites.codeFromQuery("cn1_invite=ABC123xxxxxxxxxxxxxxxx")); assertNull(Invites.codeFromQuery("utm_source=cn1_invite&utm_medium=referral")); assertNull(Invites.codeFromQuery("")); assertNull(Invites.codeFromQuery(null)); @@ -110,19 +120,19 @@ void aFragmentIsNotPartOfTheCode() { // An App Link commonly arrives with the fragment still attached, and it // is not part of the path -- so this claimed a code called // "ABC123#section", which exists nowhere. - assertTrue(Invites.handleUrl("https://cloud.codenameone.com/i/acme/ABC123#section")); + assertTrue(Invites.handleUrl("https://cloud.codenameone.com/i/acme/ABC123xxxxxxxxxxxxxxxx#section")); Map pending = InviteStore.read(InviteStore.PENDING); assertNotNull(pending); - assertEquals("ABC123", InviteStore.get(pending, "code", null)); + assertEquals("ABC123xxxxxxxxxxxxxxxx", InviteStore.get(pending, "code", null)); } @Test @EdtTest void aFragmentAfterAQueryIsAlsoStripped() { assertTrue(Invites.handleUrl( - "https://cloud.codenameone.com/i/acme/ABC124?utm_source=x#top")); + "https://cloud.codenameone.com/i/acme/ABC124xxxxxxxxxxxxxxxx?utm_source=x#top")); Map pending = InviteStore.read(InviteStore.PENDING); - assertEquals("ABC124", InviteStore.get(pending, "code", null)); + assertEquals("ABC124xxxxxxxxxxxxxxxx", InviteStore.get(pending, "code", null)); } @Test @@ -164,12 +174,12 @@ void anotherAppsSlugOnTheSharedHostIsNotOurInvite() { Invites.reset(); Preferences.set(Invites.PREF_SLUG, "acme"); - assertNull(Invites.extractCode("https://cloud.codenameone.com/i/other-app/THEIRS1"), + assertNull(Invites.extractCode("https://cloud.codenameone.com/i/other-app/THEIRS1xxxxxxxxxxxxxxx"), "an invite belonging to another app on the shared host was claimed"); assertEquals("acme", Preferences.get(Invites.PREF_SLUG, ""), "the foreign slug was remembered, so later invites mint their links"); - assertEquals("OURS123", - Invites.extractCode("https://cloud.codenameone.com/i/acme/OURS123"), + assertEquals("OURS123xxxxxxxxxxxxxxx", + Invites.extractCode("https://cloud.codenameone.com/i/acme/OURS123xxxxxxxxxxxxxxx"), "our own slugged invite stopped being recognised"); } @@ -184,9 +194,9 @@ void aRoutedInviteUrlIsNotHandledTwice() { // whose epoch bump discarded the answer to the first. Invites.reset(); com.codename1.ui.Display d = com.codename1.ui.Display.getInstance(); - d.setProperty("AppArg", "https://cloud.codenameone.com/i/ROUTED1"); + d.setProperty("AppArg", "https://cloud.codenameone.com/i/ROUTED1xxxxxxxxxxxxxxx"); - assertTrue(Invites.handleUrl("https://cloud.codenameone.com/i/ROUTED1"), + assertTrue(Invites.handleUrl("https://cloud.codenameone.com/i/ROUTED1xxxxxxxxxxxxxxx"), "the fixture url was not recognised as an invite"); assertNull(d.getProperty("AppArg", null), @@ -202,7 +212,7 @@ void anUnrelatedAppArgIsLeftAlone() { com.codename1.ui.Display d = com.codename1.ui.Display.getInstance(); d.setProperty("AppArg", "myapp://somewhere/else"); - Invites.handleUrl("https://cloud.codenameone.com/i/OTHER1"); + Invites.handleUrl("https://cloud.codenameone.com/i/OTHER1xxxxxxxxxxxxxxxx"); assertEquals("myapp://somewhere/else", d.getProperty("AppArg", null), "an unrelated launch argument was cleared"); @@ -247,12 +257,50 @@ void onlyHttpsUrlsCarryInvites() { // host-only test accepted both -- persisting and claiming a code // although nothing the framework mints or the platforms associate is // anything but https. - assertNull(Invites.extractCode("myapp://cloud.codenameone.com/i/SCHEME1"), + assertNull(Invites.extractCode("myapp://cloud.codenameone.com/i/SCHEME1xxxxxxxxxxxxxxx"), "a custom-scheme url was accepted as an invite"); - assertNull(Invites.extractCode("http://cloud.codenameone.com/i/PLAIN1"), + assertNull(Invites.extractCode("http://cloud.codenameone.com/i/PLAIN1xxxxxxxxxxxxxxxx"), "an http url was accepted as an invite"); - assertEquals("REAL123", - Invites.extractCode("https://cloud.codenameone.com/i/REAL123"), + assertEquals("REAL123xxxxxxxxxxxxxxx", + Invites.extractCode("https://cloud.codenameone.com/i/REAL123xxxxxxxxxxxxxxx"), "the https form stopped being recognised"); } + + /** + * A same-host url only yields a code if it looks like one. + * + *

Any nonempty final path component used to be accepted, so + * {@code /i/} on the shared host was consumed, written into the + * PENDING record and put through the claim retries -- and on a fresh + * install the no-match that came back could settle attribution before the + * Play referrer or the App Clip handoff had been looked at, which is the + * answer that actually mattered.

+ * + *

What this does and does not buy is worth being exact about: it stops + * malformed values, not a crafted one. Somebody who supplies 22 url-safe + * characters still gets a claim and a no-match. The grammar is a filter on + * accidents and garbage, not an authentication.

+ */ + @FormTest + void aValueThatCannotBeACodeIsNotTreatedAsOne() { + InviteTestSupport.freshInstall(); + String host = "https://cloud.codenameone.com/i/"; + + assertNull(Invites.extractCode(host + "hello"), + "a short word was accepted as an invite code"); + assertNull(Invites.extractCode(host + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + "an over-long value was accepted as an invite code"); + assertNull(Invites.extractCode(host + "ABC123xxxxxxxxxxxxxxx!"), + "a value with a character no code can contain was accepted"); + assertNull(Invites.extractCode(host + "ABC123xxxxxxxxxxxxxxx."), + "a value with a dot was accepted, and a code is url-safe base64"); + + // Exactly the shape the framework mints still works, in both forms. + assertEquals("ABC123xxxxxxxxxxxxxxxx", + Invites.extractCode(host + "ABC123xxxxxxxxxxxxxxxx"), + "a well formed code stopped being recognised"); + assertEquals("ABC123xxxxxxxxxxxxxxxx", + Invites.extractCode(host + "acme/ABC123xxxxxxxxxxxxxxxx"), + "a well formed slugged code stopped being recognised"); + } } From c30f5a9385634a315d4b99c48a86af60b5365779 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 06:59:03 +0300 Subject: [PATCH 98/99] Validate query-borne codes as well, and mint afresh when the request has changed Two review findings, both of them the same shape as a fix that came before. The code grammar was applied to the PATH form and not the query one, so the malformed value simply moved: a same-host ?cn1_invite= was consumed, persisted and claimed exactly as /i/ had been. Guarding the second caller was not enough either -- there are two, and the Play referrer is device-local and forgeable on a rooted device -- so the check now lives in one entry point both go through. The parser underneath stays as it was and keeps returning the raw value, because the two answer different questions: where the value ENDS, which is the split on the first '=' and can only be observed with a value no code could be, and whether it is a code at all. InviteButton reuses an outstanding invite so a double tap is one invitation rather than two codes with the first left registered and never shared. That was keyed on the field alone, and a dismissed chooser reports nothing -- so a cancelled share left the invite outstanding for the life of the button and every later press shared it regardless of what the application had set since. setCampaign() before the next press was silently ignored and the invite kept reporting the campaign it was minted under. Reuse is now keyed on the request: unchanged campaign, channel and payload reuse, and any change mints afresh. The class javadoc said "mints a fresh invite on every press", which the reuse has never done, so it now describes what the button actually does. The test codes carried in referrer and query strings are real-shaped for the same reason the path ones were. Both revert-probed: without the fixes the new tests fail on "a short word in the query was accepted as an invite code" and "the campaign changed and the press reused the old invite". Co-Authored-By: Claude Opus 5 (1M context) --- .../codename1/analytics/invite/Invites.java | 31 +++++++++++- .../codename1/components/InviteButton.java | 50 +++++++++++++++++-- .../invite/InviteConsentAndErasureTest.java | 6 +-- .../invite/InviteResilienceTest.java | 24 ++++----- .../invite/InviteUrlParsingTest.java | 43 +++++++++++++--- .../components/InviteButtonMintTest.java | 32 ++++++++++++ 6 files changed, 160 insertions(+), 26 deletions(-) diff --git a/CodenameOne/src/com/codename1/analytics/invite/Invites.java b/CodenameOne/src/com/codename1/analytics/invite/Invites.java index ccf3ea7b53e..1b422248adf 100644 --- a/CodenameOne/src/com/codename1/analytics/invite/Invites.java +++ b/CodenameOne/src/com/codename1/analytics/invite/Invites.java @@ -1903,7 +1903,7 @@ static String extractCode(String url) { return null; } if (q >= 0) { - String code = codeFromQuery(url.substring(q + 1)); + String code = validCodeFromQuery(url.substring(q + 1)); if (code != null) { return code; } @@ -2014,6 +2014,33 @@ static boolean isWellFormedCode(String code) { // Parses a referrer or query string for the invite key. Split on the // FIRST '=' only, and compare the key with equals -- never a case fold. + /// The same parse, with the code grammar applied. + /// + /// Both callers want this one, and they get it from a single place on + /// purpose: when the path form was guarded, the query form was not, so a + /// same-host url carrying `cn1_invite=` was consumed and + /// persisted exactly as `/i/` had been. The url query is + /// attacker-supplied and the Play referrer is device-local and forgeable + /// on a rooted device, so neither is trusted. + /// + /// Separate from the parser below because the two answer different + /// questions: where the value ENDS -- the first `=`, which can only be + /// observed with a value no code could be -- and whether it is a code. + /// + /// #### Parameters + /// + /// - `query`: the query or referrer string, may be null + /// + /// #### Returns + /// + /// the code, or null when there is none or it is not one + static String validCodeFromQuery(String query) { + String code = codeFromQuery(query); + return isWellFormedCode(code) ? code : null; + } + + // Parses only: the value as it appears, whatever shape it is in. Callers + // that act on it want validCodeFromQuery() above. static String codeFromQuery(String query) { if (query == null || query.length() == 0) { return null; @@ -2770,7 +2797,7 @@ public void run() { if (issued != lookupEpoch) { return; } - String code = codeFromQuery(rawReferrer); + String code = validCodeFromQuery(rawReferrer); if (code == null) { // The referrer was read and carries no invite. // That is an answer, not an outage. diff --git a/CodenameOne/src/com/codename1/components/InviteButton.java b/CodenameOne/src/com/codename1/components/InviteButton.java index 89799e66b07..123d8e1ea4e 100644 --- a/CodenameOne/src/com/codename1/components/InviteButton.java +++ b/CodenameOne/src/com/codename1/components/InviteButton.java @@ -31,8 +31,15 @@ import com.codename1.ui.FontImage; import com.codename1.ui.events.ActionEvent; -/// A [ShareButton] that mints a fresh invite on every press and shares it, so -/// the whole invite funnel is wired with one component. +/// A [ShareButton] that mints an invite and shares it, so the whole invite +/// funnel is wired with one component. +/// +/// A press mints a new invite unless one is already outstanding for the SAME +/// campaign, channel and payload, in which case that one is shared again. Two +/// presses of an unchanged button are one invitation, not two codes with the +/// first left registered and never shared; changing any of the three before +/// the next press mints afresh, because the invite has to carry what the +/// application last asked for. /// /// ```java /// InviteButton invite = new InviteButton("Invite a friend"); @@ -62,6 +69,14 @@ public class InviteButton extends ShareButton { // outcome is taken, so that outcome can be reported exactly once. private Invite invite; private Invite outstanding; + + // What the outstanding invite was minted from, so a press can tell a + // double tap from a request the application has changed since. + private String outstandingCampaign; + + private String outstandingChannel; + + private String outstandingPayload; private ShareResultListener appListener; // The chained listener super was given. Package private so a test can // deliver a ShareResult without the share sheet -- getShareResultListener() @@ -111,6 +126,20 @@ public InviteButton(String text) { // of adding to a pile of them. The fix is there rather than here because // every ShareButton with a result listener had the same leak, invites or // not. + /// Null-tolerant equality, because every one of these may be unset. + /// + /// #### Parameters + /// + /// - `a`: one value, may be null + /// - `b`: the other, may be null + /// + /// #### Returns + /// + /// true when they are the same value or both unset + private static boolean same(String a, String b) { + return a == null ? b == null : a.equals(b); + } + private void installChain() { chain = new ShareResultListener() { @Override @@ -328,9 +357,24 @@ Invite mintForShare() { // construction. Sharing one code more than once is the ordinary shape // of a referral anyway -- a code is not per recipient, it is the // inviter's -- so nothing is lost by not minting a second. - if (outstanding == null) { + // ... and only while the request is UNCHANGED. + // + // The reuse above is about a double tap, where nothing can have + // changed between the two presses. It was keyed on the field alone, + // and a chooser reports nothing when it is dismissed -- so a cancelled + // share left the invite outstanding for the life of the button, and + // every later press shared it no matter what the application had set + // since. setCampaign() before the next press was silently ignored, and + // the invite kept reporting the campaign it was minted under. + if (outstanding == null + || !same(outstandingCampaign, campaign) + || !same(outstandingChannel, channel) + || !same(outstandingPayload, payload)) { try { outstanding = Invites.create(b.build()); + outstandingCampaign = campaign; + outstandingChannel = channel; + outstandingPayload = payload; } catch (IllegalStateException e) { // The device could not supply secure randomness, so there is // no invite to share. Nothing is presented rather than diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java index 3aed269cba0..0f6873debec 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteConsentAndErasureTest.java @@ -622,7 +622,7 @@ public boolean discardReferrer() { } public void requestReferrer(InstallReferrerCallback callback) { - callback.onReferrer("utm_source=cn1_invite&cn1_invite=SUSPEND1", 0L, 0L); + callback.onReferrer("utm_source=cn1_invite&cn1_invite=SUSPEND1xxxxxxxxxxxxxx", 0L, 0L); } }); Invites.checkForInvite(); @@ -695,7 +695,7 @@ public void requestReferrer(InstallReferrerCallback callback) { callback.onReferrer(null, 0L, 0L); return; } - callback.onReferrer("utm_source=cn1_invite&cn1_invite=PRERESET", 0L, 0L); + callback.onReferrer("utm_source=cn1_invite&cn1_invite=PRERESETxxxxxxxxxxxxxx", 0L, 0L); } }); @@ -709,7 +709,7 @@ public void requestReferrer(InstallReferrerCallback callback) { Invites.checkForInvite(); for (int i = 0; i < implementation.getQueuedRequests().size(); i++) { String body = implementation.getQueuedRequests().get(i).getRequestBody(); - assertTrue(body == null || body.indexOf("PRERESET") < 0, + assertTrue(body == null || body.indexOf("PRERESETxxxxxxxxxxxxxx") < 0, "the pre-reset referral was transmitted under the new client id: " + body); } assertNull(Invites.getAttribution(), diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java index 98a1024dc2a..716262ba9cb 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteResilienceTest.java @@ -651,7 +651,7 @@ public boolean discardReferrer() { } public void requestReferrer(InstallReferrerCallback callback) { - callback.onReferrer("utm_source=cn1_invite&cn1_invite=USELESS1", 0L, 0L); + callback.onReferrer("utm_source=cn1_invite&cn1_invite=USELESS1xxxxxxxxxxxxxx", 0L, 0L); } }); Invites.checkForInvite(); @@ -693,7 +693,7 @@ public boolean discardReferrer() { } public void requestReferrer(InstallReferrerCallback callback) { - callback.onReferrer("utm_source=cn1_invite&cn1_invite=THROTTLE", 0L, 0L); + callback.onReferrer("utm_source=cn1_invite&cn1_invite=THROTTLExxxxxxxxxxxxxx", 0L, 0L); } }); Invites.checkForInvite(); @@ -738,7 +738,7 @@ public boolean discardReferrer() { } public void requestReferrer(InstallReferrerCallback callback) { - callback.onReferrer("utm_source=cn1_invite&cn1_invite=RETRY1", 0L, 0L); + callback.onReferrer("utm_source=cn1_invite&cn1_invite=RETRY1xxxxxxxxxxxxxxxx", 0L, 0L); } }); try { @@ -1173,7 +1173,7 @@ public boolean discardReferrer() { } public void requestReferrer(InstallReferrerCallback callback) { - callback.onReferrer("utm_source=cn1_invite&cn1_invite=LATER1", 0L, 0L); + callback.onReferrer("utm_source=cn1_invite&cn1_invite=LATER1xxxxxxxxxxxxxxxx", 0L, 0L); } }); // After the retry interval, which now applies to this path too: a @@ -1186,7 +1186,7 @@ public void requestReferrer(InstallReferrerCallback callback) { Invites.lookupRetryDelay = 0; Invites.flush(); Map pending = InviteStore.read(InviteStore.PENDING); - assertEquals("LATER1", InviteStore.get(pending, "code", null), + assertEquals("LATER1xxxxxxxxxxxxxxxx", InviteStore.get(pending, "code", null), "the retried referrer was never read"); } @@ -1567,11 +1567,11 @@ public void requestReferrer(InstallReferrerCallback callback) { // pending record and issues a claim -- because once a claim goes out // under the current epoch nothing downstream can tell it apart from a // legitimate one. - held[0].onReferrer("utm_source=cn1_invite&cn1_invite=LATE2", 0L, 0L); + held[0].onReferrer("utm_source=cn1_invite&cn1_invite=LATE2xxxxxxxxxxxxxxxxx", 0L, 0L); Map pending = InviteStore.read(InviteStore.PENDING); String recorded = pending == null ? null : InviteStore.get(pending, "code", null); - assertNotEquals("LATE2", recorded, + assertNotEquals("LATE2xxxxxxxxxxxxxxxxx", recorded, "a stale referrer callback wrote its code and issued a claim"); InviteAttribution a = Invites.getAttribution(); assertNotNull(a); @@ -1800,9 +1800,9 @@ public void requestReferrer(InstallReferrerCallback callback) { assertEquals(issued, Invites.currentLookupEpochForTest(), "flush() superseded a referrer read that was still outstanding"); - held[0].onReferrer("utm_source=cn1_invite&cn1_invite=KEPT1", 0L, 0L); + held[0].onReferrer("utm_source=cn1_invite&cn1_invite=KEPT1xxxxxxxxxxxxxxxxx", 0L, 0L); Map pending = InviteStore.read(InviteStore.PENDING); - assertEquals("KEPT1", InviteStore.get(pending, "code", null), + assertEquals("KEPT1xxxxxxxxxxxxxxxxx", InviteStore.get(pending, "code", null), "the exact referrer answer was discarded"); } @@ -1905,7 +1905,7 @@ public boolean discardReferrer() { } public void requestReferrer(InstallReferrerCallback callback) { - callback.onReferrer("utm_source=cn1_invite&cn1_invite=PROV1", 0L, 0L); + callback.onReferrer("utm_source=cn1_invite&cn1_invite=PROV1xxxxxxxxxxxxxxxxx", 0L, 0L); } }); Invites.checkForInvite(); @@ -1924,7 +1924,7 @@ public void requestReferrer(InstallReferrerCallback callback) { } } assertNotNull(body, "the persisted referrer code was never resent"); - assertTrue(body.contains("PROV1"), body); + assertTrue(body.contains("PROV1xxxxxxxxxxxxxxxxx"), body); assertTrue(body.replace(" ", "").contains("\"source\":\"install_referrer\""), "a referrer answer was resent as a direct link: " + body); } @@ -2693,7 +2693,7 @@ void aResetThatSucceedsOnTheRetryClearsTheOwedMarker() { InviteTestSupport.freshInstall(); implementation.setAutoProcessConnections(false); Invites.handleResolution( - InviteTestSupport.resolvedJson("RETRY1", "spring", "sms"), + InviteTestSupport.resolvedJson("RETRY1xxxxxxxxxxxxxxxx", "spring", "sms"), Invites.MATCH_REFERRER, true); assertNotNull(Invites.getAttribution(), "the fixture did not resolve"); diff --git a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java index ef4ee0d50ba..cc078d217f2 100644 --- a/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/analytics/invite/InviteUrlParsingTest.java @@ -139,12 +139,12 @@ void aFragmentAfterAQueryIsAlsoStripped() { @EdtTest void aFragmentOnAQueryStyleLinkIsAlsoStripped() { // The query branch runs first, so stripping on the path branch alone - // left it parsing "?cn1_invite=ABC125#section" and claiming a code with + // left it parsing "?cn1_invite=ABC125xxxxxxxxxxxxxxxx#section" and claiming a code with // the fragment glued to it. assertTrue(Invites.handleUrl( - "https://cloud.codenameone.com/i/acme?cn1_invite=ABC125#section")); + "https://cloud.codenameone.com/i/acme?cn1_invite=ABC125xxxxxxxxxxxxxxxx#section")); Map pending = InviteStore.read(InviteStore.PENDING); - assertEquals("ABC125", InviteStore.get(pending, "code", null)); + assertEquals("ABC125xxxxxxxxxxxxxxxx", InviteStore.get(pending, "code", null)); } @FormTest @@ -156,11 +156,11 @@ void aForeignUrlCarryingTheKeyIsNotAnInvite() { // install, or a last-touch re-attribution, to whoever wrote a url this // app happens to open. assertNull(Invites.extractCode( - "https://partner.example.com/promo?cn1_invite=STOLEN1"), + "https://partner.example.com/promo?cn1_invite=STOLEN1xxxxxxxxxxxxxxx"), "a url on somebody else's host was accepted as an invite"); // Our own host in the query form is still an invite. - assertEquals("MINE123", Invites.extractCode( - "https://cloud.codenameone.com/anything?cn1_invite=MINE123")); + assertEquals("MINE123xxxxxxxxxxxxxxx", Invites.extractCode( + "https://cloud.codenameone.com/anything?cn1_invite=MINE123xxxxxxxxxxxxxxx")); } @FormTest @@ -303,4 +303,35 @@ void aValueThatCannotBeACodeIsNotTreatedAsOne() { Invites.extractCode(host + "acme/ABC123xxxxxxxxxxxxxxxx"), "a well formed slugged code stopped being recognised"); } + + /** + * A query-borne code is held to the same grammar as a path-borne one. + * + *

The path form was guarded first and this one was not, so the same + * malformed value simply moved: {@code ?cn1_invite=} on the + * shared host was consumed, persisted and claimed exactly as + * {@code /i/} had been. Both callers now go through one + * validating entry point rather than each remembering to check.

+ */ + @FormTest + void aQueryBorneCodeMustLookLikeACodeToo() { + InviteTestSupport.freshInstall(); + String base = "https://cloud.codenameone.com/?cn1_invite="; + + assertNull(Invites.extractCode(base + "hello"), + "a short word in the query was accepted as an invite code"); + assertNull(Invites.extractCode(base + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + "an over-long query value was accepted as an invite code"); + assertEquals("ABC123xxxxxxxxxxxxxxxx", + Invites.extractCode(base + "ABC123xxxxxxxxxxxxxxxx"), + "a well formed query code stopped being recognised"); + + // The parser underneath still answers the question it is for: where + // the value ENDS. That can only be seen with a value no code could be, + // which is why the two are separate. + assertEquals("a=b", Invites.codeFromQuery("cn1_invite=a=b"), + "the parser stopped splitting on the first equals only"); + assertNull(Invites.validCodeFromQuery("cn1_invite=a=b"), + "a value with an equals in it was accepted as a code"); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/components/InviteButtonMintTest.java b/maven/core-unittests/src/test/java/com/codename1/components/InviteButtonMintTest.java index 73c9a2b36e5..51d47aca9a5 100644 --- a/maven/core-unittests/src/test/java/com/codename1/components/InviteButtonMintTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/components/InviteButtonMintTest.java @@ -241,4 +241,36 @@ void presentShare(ActionEvent evt) { assertNotSame(first, second, "the press after a completed share did not mint a fresh invite"); } + + /** + * A press after the application changed the campaign mints afresh. + * + *

The outstanding invite is reused so a double tap is one invitation + * rather than two codes with the first left registered and never shared. + * That was keyed on the field alone -- and a dismissed chooser reports + * nothing, so the invite stayed outstanding for the life of the button and + * every later press shared it no matter what the application had set + * since. setCampaign() before the next press was silently ignored and the + * invite kept reporting the campaign it was minted under.

+ */ + @FormTest + void changingTheCampaignBetweenPressesMintsANewInvite() { + Invites.reset(); + Analytics.setConsentMode(ConsentMode.OPT_IN); + Analytics.setConsent(AnalyticsConsent.builder().analytics(true).build()); + InviteButton b = new InviteButton("Invite"); + b.setCampaign("spring"); + + Invite first = b.mintForShare(); + assertNotNull(first, "the fixture did not mint"); + assertSame(first, b.mintForShare(), + "an unchanged press minted a second code, so a double tap is two invites"); + + b.setCampaign("summer"); + Invite second = b.mintForShare(); + assertNotNull(second, "no invite was minted after the campaign changed"); + assertNotSame(first, second, + "the campaign changed and the press reused the old invite, so the new " + + "campaign is never reported"); + } } From 0e7d89e1e9968eec2f6d2f23952ad80c98bc9b44 Mon Sep 17 00:00:00 2001 From: Shai Almog <67850168+shai-almog@users.noreply.github.com> Date: Sat, 12 Sep 2026 13:18:55 +0300 Subject: [PATCH 99/99] Give each chooser its own PendingIntent, and make the share token unguessable Two findings codex raised in a review BODY and a PR issue comment rather than a review thread, so nothing that watches reviewThreads ever saw them. The per-chooser token map did not actually give each chooser its own callback. The token went into the broadcast Intent's EXTRAS, and Android does not include extras in PendingIntent identity -- with request code 0 and one action per package, every share resolved to the SAME PendingIntent and FLAG_UPDATE_CURRENT overwrote the first chooser's token with the second's. Selecting from the first chooser then delivered the second token, invoked the second listener and stranded the first, which is precisely the bug the map was added to fix. The token is now the REQUEST CODE, which is part of that identity. The comment there asserted the opposite -- that FLAG_UPDATE_CURRENT hands the PendingIntent back "with this chooser's extras, and only one chooser is ever up at a time". Neither half was true, and the second assumed away the very case being defended against. It now says what actually happens. Separately the token was a counter starting at 1, and the receiver is exported -- RECEIVER_EXPORTED on API 33+, and the two-argument registration is externally reachable on older releases. setPackage() constrains the PendingIntent the framework creates and does nothing to a forged explicit broadcast, so any installed app could send .CN1_SHARE_CHOSEN with token 1 and have a fabricated successful ShareResult reported: invite_shared for a share that never happened, plus whatever an application hangs off its own listener. The token is now SecureRandom, and onReceive already returns when the token is not one this process issued, so forgery has to guess a 32-bit value that never leaves the PendingIntent. Registering the receiver non-exported on API 33+ is the better answer and is deliberately NOT done here. A PendingIntent broadcast carries this app's own identity so it should still arrive -- but the failure mode if that reasoning is wrong is silent, the callback simply stopping and invite_shared with it, and this port has no test that would catch it. That wants a device. The comment says so rather than leaving the omission to look like an oversight. Verified by compiling the android module with SpotBugs; there is no unit-test path for the port, so the behaviour itself is not covered here. Co-Authored-By: Claude Opus 5 (1M context) --- .../impl/android/AndroidImplementation.java | 46 ++++++++++++++++--- 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java index bed65c481e7..b9447c3c136 100644 --- a/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java +++ b/Ports/Android/src/com/codename1/impl/android/AndroidImplementation.java @@ -10142,7 +10142,27 @@ public void share(String text, String image, String mimeType, Rectangle sourceRe private static final int MAX_PENDING_SHARES = 8; - private int nextShareToken = 1; + // UNGUESSABLE, because the token is what authenticates the broadcast. + // + // The receiver is registered exported -- RECEIVER_EXPORTED on API 33+, and + // the two-argument registration is externally reachable on older releases + // -- so any installed app can send .CN1_SHARE_CHOSEN. setPackage() + // constrains the PendingIntent the framework creates; it does nothing to a + // forged explicit broadcast. With a counter starting at 1 that forgery was + // trivial, and it reported a fabricated successful ShareResult: + // invite_shared for a share that never happened, and whatever reward logic + // an application hangs off its own listener. + // + // A random token is the half of the repair that can be made from here. + // onReceive looks the token up and returns when it is not one this process + // issued, so a forged broadcast has to guess a 32-bit value that never + // leaves the PendingIntent. Registering the receiver non-exported is the + // better answer on API 33+ and is NOT done blind: a PendingIntent + // broadcast is delivered with this app's own identity, so it should still + // arrive -- but "should" is doing real work in that sentence and the + // failure mode is silent, the callback simply stopping and invite_shared + // stopping with it. That wants a device rather than an inference. + private final java.security.SecureRandom shareTokens = new java.security.SecureRandom(); private final java.util.LinkedHashMap pendingShares = @@ -10154,7 +10174,7 @@ private Intent buildShareChooserWithCallback(Intent shareIntent, final com.coden // This chooser's own token, recorded before the receiver can fire. final int token; synchronized (pendingShares) { - token = nextShareToken++; + token = shareTokens.nextInt(); pendingShares.put(Integer.valueOf(token), listener); while (pendingShares.size() > MAX_PENDING_SHARES) { java.util.Iterator oldest = pendingShares.keySet().iterator(); @@ -10226,9 +10246,23 @@ public void onReceive(Context ctx, Intent intent) { /// action. /// /// Split out because it is built on every share while the receiver behind - /// it is built once. FLAG_UPDATE_CURRENT is what makes the fixed action - /// safe to reuse: the same PendingIntent is handed back with this - /// chooser's extras, and only one chooser is ever up at a time. + /// it is built once. + /// + /// **The token is the REQUEST CODE, not merely an extra.** What stood here + /// said FLAG_UPDATE_CURRENT made a fixed action safe because the + /// PendingIntent "is handed back with this chooser's extras, and only one + /// chooser is ever up at a time". Both halves were wrong. Android does not + /// include extras in PendingIntent identity, so with request code 0 and one + /// action every share resolved to the SAME PendingIntent and + /// FLAG_UPDATE_CURRENT overwrote the first chooser's token with the + /// second's -- selecting from the first chooser then delivered the second + /// token, invoked the second listener and stranded the first, which is the + /// per-chooser callback the token map exists to provide. And a second + /// chooser is the case being defended against, so assuming only one is up + /// assumed the bug away. + /// + /// The request code IS part of that identity, so passing the token makes + /// each chooser's PendingIntent distinct and its extras its own. @TargetApi(22) private Intent chooserFor(Context appCtx, Intent shareIntent, String action, int token) { Intent pi = new Intent(action).setPackage(appCtx.getPackageName()); @@ -10240,7 +10274,7 @@ private Intent chooserFor(Context appCtx, Intent shareIntent, String action, int // still compiles against pre-31 android.jar build deps. piFlags |= 0x02000000; } - PendingIntent pendingIntent = PendingIntent.getBroadcast(appCtx, 0, pi, piFlags); + PendingIntent pendingIntent = PendingIntent.getBroadcast(appCtx, token, pi, piFlags); return Intent.createChooser(shareIntent, "Share with...", pendingIntent.getIntentSender()); }