diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml index e56c5d124f5..8d12dac78fd 100644 --- a/.github/codeql/codeql-config.yml +++ b/.github/codeql/codeql-config.yml @@ -31,3 +31,10 @@ paths-ignore: # against the JVM, and writing the casts out would change the bytecode shape # (the DUP2 of a compound array store) that the comparison is there to cover. - 'vm/tests/src/test/resources/com/codename1/tools/translator/JsNumericDifferentialApp.java' + # The AES of the compatibility tests' headless Codename One implementation, + # a test double that hands the IV the code under test chose straight to the + # JDK. Those IVs are random (com.codename1.security.SecureRandom, filled by + # the JDK's SecureRandom.nextBytes here, or the tests' own nextBytes), but + # CodeQL treats a `new byte[n]` filled in another method as a static IV and + # reports this sink for each of them. + - 'maven/compat-testing/src/main/java/com/codename1/compat/testing/HeadlessAes.java' diff --git a/CodenameOne/src/com/codename1/location/Location.java b/CodenameOne/src/com/codename1/location/Location.java index 2815ef35281..9d64567f936 100644 --- a/CodenameOne/src/com/codename1/location/Location.java +++ b/CodenameOne/src/com/codename1/location/Location.java @@ -131,7 +131,8 @@ public float getAccuracy() { } public void setAccuracy(float accuracy) { - if (Float.isNaN(accuracy)) { + // A NaN reading means the platform has none; keep the previous value. + if (!Float.isNaN(accuracy)) { this.accuracy = accuracy; } } diff --git a/NOTICE b/NOTICE index 39a3940f1b6..afbcc0dc85d 100644 --- a/NOTICE +++ b/NOTICE @@ -177,6 +177,25 @@ NETStandard.Library.Ref is distributed under the MIT license: LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +================================================================================ +Android Open Source Project: SensorManager math +================================================================================ + +maven/android-compat/src/main/java/android/hardware/SensorMath.java is rewritten +from the static methods of android.hardware.SensorManager in the Android Open +Source Project (https://source.android.com), so that rotation matrices, +orientation angles and altitude computed by Android applications on the +Codename One compatibility runtime match the platform's. It retains the +original copyright notice: + + Copyright (C) 2008 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); you may not + use this file except in compliance with the License. You may obtain a copy of + the License at http://www.apache.org/licenses/LICENSE-2.0 + +================================================================================ GroupLayout ================================================================================ diff --git a/docs/developer-guide/Android-Interop.asciidoc b/docs/developer-guide/Android-Interop.asciidoc index 60ad78dee63..4174ed3461e 100644 --- a/docs/developer-guide/Android-Interop.asciidoc +++ b/docs/developer-guide/Android-Interop.asciidoc @@ -165,6 +165,23 @@ view class the Codename One Android runtime implements (android2cn1:E0201)`. * XML: `getResources().getXml()` returns a pull parser over the compiled resource, and `Xml.newPullParser()` and `Xml.newSerializer()` read and write XML text. +* Room: entities, DAOs, type converters, `@Embedded`, `@Upsert`, `@RawQuery`, + `LiveData` queries and migrations. The build generates the `_Impl` classes + that the Room annotation processor would, so nothing is found by reflection. +* WorkManager: one-time and periodic work, chains whose outputs merge into the + next worker's input, unique work, retries with a growing delay and `LiveData` status + queries. Pending work is saved and resumes on the next run. +* Location and sensors: `LocationManager` with its listeners, last known and + current location, and `SensorManager` with the accelerometer, gyroscope, + magnetometer, gravity, rotation vector and orientation, including + `getRotationMatrix()` and `getOrientation()`. +* Services declared in the manifest: started and bound services, + `IntentService`, and `startForeground()`, which runs as a Codename One + `ForegroundService`. Notifications and channels posted through + `NotificationManager` and `NotificationCompat` are shown as local + notifications. +* The `AndroidKeyStore` with AES keys from `KeyGenerator` and + `KeyGenParameterSpec`, used through `Cipher` in GCM or CBC mode. === AndroidX @@ -321,11 +338,13 @@ the gallery and automated tests cover a subset of those behaviors. elements. Code that depends on serialization making a snapshot, creator-side validation, or restoring state after process death needs an explicit storage or copy implementation. -* SQLite uses one Codename One database connection. A competing thread's - operations can throw `SQLiteDatabaseLockedException` while a transaction is - open; Android's connection-pool waiting behavior isn't implemented. - Serialize database work through one worker when sharing a database across - tasks. Full Android SQLite concurrency parity is outside this release's scope. +* SQLite uses one Codename One database connection. While one thread's + transaction is open, another thread's statements wait for it to end, as on + Android, and throw `SQLiteDatabaseLockedException` after ten seconds. On the + main thread the wait is two seconds, because the UI is frozen while it + waits and the other thread may be waiting for the main thread itself. + Threads don't read concurrently: one thread's long transaction holds up every + other thread's queries. * Switching only a locale's script, such as `zh-Hans` to `zh-Hant` with the same region, updates resource selection but doesn't notify or recreate existing activities. Already-inflated text and layouts can retain the old @@ -373,9 +392,32 @@ the gallery and automated tests cover a subset of those behaviors. * Gradle recompiles all Java sources in an imported Android project after a source edit, because relocation rewrites the compiler output. Unchanged builds still use Gradle's up-to-date checks. -* Content providers, services, broadcast intents from the system and - notifications through `NotificationManager` aren't available; use the - corresponding Codename One APIs through native interfaces or directly. +* Content providers and broadcast intents from the system aren't available; + use the corresponding Codename One APIs through native interfaces or + directly. +* `AndroidKeyStore` keys aren't hardware backed or non-extractable. Their + material is kept in Codename One's `SecureStorage` without a prompt: the + keychain on iOS, and a key of the platform's own keystore on Android. A + port without a secure store keeps the material in the application's private + storage, which a rooted device or a backup can read. + `setUserAuthenticationRequired()` isn't enforced; for a key the user must + unlock, use `SecureStorage` with a reason, which prompts for biometrics. + GCM supports only 128-bit tags. +* Room rejects `@Relation`, `suspend` DAO methods, `Flow`, RxJava and Paging + return types, `@AutoMigration`, `@DatabaseView` and full-text tables at + build time, naming the member. `createFromAsset()` fails when the database + is built. +* A `ListenableWorker` subclass returns a `java.util.concurrent.Future` + rather than Guava's `ListenableFuture`; `Worker` subclasses are unaffected. + A network constraint is treated as met, and charging, idle, battery and + storage constraints are checked only where Codename One's `BackgroundWork` + supports them. Work saved by an earlier run resumes once the application + calls `WorkManager.getInstance()`. +* A manifest that asks for location access gets the iOS location usage + descriptions, and with `ACCESS_BACKGROUND_LOCATION` or + `FOREGROUND_SERVICE_LOCATION` the `location` background mode, added to + `codenameone_settings.properties` when the project doesn't set them. Edit + the added text to change what the iOS permission prompt says. * `ViewModelProvider` creates view models without reflection: the build registers every public, concrete `ViewModel` class whose public constructor takes nothing, an `Application`, a `SavedStateHandle`, or an `Application` diff --git a/maven/android-compat/spotbugs-exclude.xml b/maven/android-compat/spotbugs-exclude.xml index b8a84cd1791..a98bdca4b55 100644 --- a/maven/android-compat/spotbugs-exclude.xml +++ b/maven/android-compat/spotbugs-exclude.xml @@ -362,4 +362,13 @@ + + + + + diff --git a/maven/android-compat/src/main/java/android/app/ActiveServices.java b/maven/android-compat/src/main/java/android/app/ActiveServices.java new file mode 100644 index 00000000000..82822c59596 --- /dev/null +++ b/maven/android-compat/src/main/java/android/app/ActiveServices.java @@ -0,0 +1,415 @@ +/* + * 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 android.app; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.IBinder; +import com.codename1.androidcompat.runtime.AndroidApp; +import com.codename1.androidcompat.runtime.AndroidRuntime; +import com.codename1.androidcompat.runtime.CompatReport; +import com.codename1.androidcompat.runtime.MainThread; +import com.codename1.background.ForegroundService; +import com.codename1.ui.Display; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; + +/// The services of this application: which are running, started, bound and +/// in the foreground. `Context.startService`, `bindService` and the rest come +/// here, as does `Service.stopSelf`. +/// +/// A service is created with the `new` the build generated for its manifest +/// `` (`AndroidApp.createService`), so an undeclared class is never +/// started -- as on Android, where the start answers null. +/// +/// Every lifecycle callback is posted to the main thread, so `startService` +/// returns before `onCreate` runs, which is Android's order too. +public final class ActiveServices { + + private ActiveServices() { + } + + static final class Record { + final Class type; + final ComponentName name; + Service service; + boolean started; + IBinder binder; + boolean binderAsked; + boolean rebindOnBind; + final List connections = new ArrayList(); + int foregroundId; + ForegroundService foreground; + CountDownLatch foregroundDone; + + Record(Class type, ComponentName name) { + this.type = type; + this.name = name; + } + } + + private static final Map, Record> RUNNING = new LinkedHashMap, Record>(); + private static final Map BINDINGS = new LinkedHashMap(); + + // What the callers have asked for so far, kept at call time. The + // lifecycle runs later, on the main thread, so a stop or unbind made in + // the same frame as its start or bind is judged against these rather than + // against a service that is not created yet. + // + // Callers may be on any thread (a worker calling stopService is + // ordinary), so each is an immutable set swapped atomically. + private static final CowSet> START_REQUESTED = new CowSet>(); + private static final CowSet BIND_REQUESTED = new CowSet(); + + /// A copy-on-write set: every change publishes a new set with + /// compareAndSet, so readers on any thread see a whole one. + static final class CowSet { + private final java.util.concurrent.atomic.AtomicReference> ref = + new java.util.concurrent.atomic.AtomicReference>(new java.util.HashSet()); + + boolean contains(T t) { + return ref.get().contains(t); + } + + void add(T t) { + while (true) { + java.util.Set cur = ref.get(); + if (cur.contains(t)) { + return; + } + java.util.Set next = new java.util.HashSet(cur); + next.add(t); + if (ref.compareAndSet(cur, next)) { + return; + } + } + } + + /// True when `t` was there. + boolean remove(T t) { + while (true) { + java.util.Set cur = ref.get(); + if (!cur.contains(t)) { + return false; + } + java.util.Set next = new java.util.HashSet(cur); + next.remove(t); + if (ref.compareAndSet(cur, next)) { + return true; + } + } + } + } + + // ------------------------------------------------------------ Context + + public static ComponentName startService(Context caller, final Intent intent) { + final Class type = resolve(intent); + if (type == null) { + return null; + } + final Intent copy = new Intent(intent); + final ComponentName name = new ComponentName(caller, type); + START_REQUESTED.add(type); + MainThread.post(new Runnable() { + @Override + public void run() { + Record r = ensureCreated(type, name); + if (r == null) { + START_REQUESTED.remove(type); + return; + } + // Again here: a stopSelf that ran between the call and this + // delivery cleared it, and this start is still a request. + START_REQUESTED.add(type); + r.started = true; + r.service.onStartCommand(copy, 0, r.service.nextStartId()); + } + }); + return name; + } + + public static boolean stopService(Context caller, Intent intent) { + final Class type = resolve(intent); + if (type == null) { + return false; + } + if (!START_REQUESTED.remove(type)) { + return false; + } + MainThread.post(new Runnable() { + @Override + public void run() { + Record rec = RUNNING.get(type); + if (rec != null) { + rec.started = false; + destroyIfIdle(rec); + } + } + }); + return true; + } + + public static boolean bindService(Context caller, Intent intent, final ServiceConnection conn, int flags) { + if (conn == null) { + throw new IllegalArgumentException("connection is null"); + } + final Class type = resolve(intent); + if (type == null) { + return false; + } + final boolean autoCreate = (flags & Context.BIND_AUTO_CREATE) != 0; + if (!autoCreate && RUNNING.get(type) == null) { + // Android records the binding and connects once something else + // starts the service; a later start does not connect it here. + CompatReport.unsupported("bindService", "binding without BIND_AUTO_CREATE to a stopped service"); + return false; + } + final Intent copy = new Intent(intent); + final ComponentName name = new ComponentName(caller, type); + BIND_REQUESTED.add(conn); + MainThread.post(new Runnable() { + @Override + public void run() { + if (!BIND_REQUESTED.contains(conn)) { + // Unbound before it connected: Android delivers nothing. + return; + } + Record r = ensureCreated(type, name); + if (r == null) { + if (!BINDINGS.containsKey(conn)) { + // Only when no live binding of this connection + // owns the record. + BIND_REQUESTED.remove(conn); + } + return; + } + if (BINDINGS.containsKey(conn)) { + return; + } + if (!r.binderAsked) { + r.binderAsked = true; + r.binder = r.service.onBind(copy); + } else if (r.rebindOnBind && r.connections.isEmpty()) { + r.rebindOnBind = false; + r.service.onRebind(copy); + } + r.connections.add(conn); + BINDINGS.put(conn, r); + if (r.binder == null) { + conn.onNullBinding(r.name); + } else { + conn.onServiceConnected(r.name, r.binder); + } + } + }); + return true; + } + + public static void unbindService(Context caller, final ServiceConnection conn) { + if (!BIND_REQUESTED.remove(conn)) { + throw new IllegalArgumentException("Service not registered: " + conn); + } + MainThread.post(new Runnable() { + @Override + public void run() { + Record r = BINDINGS.remove(conn); + if (r == null) { + return; + } + r.connections.remove(conn); + // An unbind is not a disconnect: Android calls no callback. + if (r.connections.isEmpty() && r.service != null) { + r.rebindOnBind = r.service.onUnbind(new Intent().setClassName(r.name.getPackageName(), + r.name.getClassName())); + destroyIfIdle(r); + } + } + }); + } + + // ------------------------------------------------------------- Service + + /// May be called from the service's own worker thread (IntentService + /// does), so the start id is compared on the main thread, in order with + /// the starts: a start delivered between this call and the stop keeps the + /// service running, as on Android. The answer is the call-time view. + static boolean stopSelf(final Service s, final int startId) { + MainThread.post(new Runnable() { + @Override + public void run() { + Record r = recordOf(s); + if (r == null || (startId >= 0 && startId != s.lastStartId())) { + return; + } + r.started = false; + START_REQUESTED.remove(r.type); + destroyIfIdle(r); + } + }); + return startId < 0 || startId == s.lastStartId(); + } + + static void startForeground(Service s, int id, Notification n) { + Record r = recordOf(s); + if (r == null || n == null) { + return; + } + r.foregroundId = id; + String title = text(n.title()); + String body = text(n.body()); + if (r.foreground != null) { + r.foreground.updateNotification(title, body); + return; + } + if (!Display.isInitialized()) { + return; + } + // Codename One's foreground service lasts as long as its task: this + // one parks a background thread until the service leaves the + // foreground, which is when Android takes the notification away. + final CountDownLatch done = new CountDownLatch(1); + r.foregroundDone = done; + String channel = n.getChannelId() == null ? NotificationChannel.DEFAULT_CHANNEL_ID : n.getChannelId(); + r.foreground = ForegroundService.start(channel, title, body, null, new ForegroundService.Task() { + @Override + public void run(ForegroundService service) { + try { + done.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + }); + } + + static void stopForeground(Service s) { + Record r = recordOf(s); + if (r != null) { + endForeground(r); + } + } + + /// `NotificationManager.notify` with the id of a running foreground + /// service updates that service's notification. + static boolean updateForegroundNotification(int id, Notification n) { + for (Record r : RUNNING.values()) { + if (r.foreground != null && r.foregroundId == id) { + r.foreground.updateNotification(text(n.title()), text(n.body())); + return true; + } + } + return false; + } + + /// Whether `type` is running; for tests and diagnostics. + public static boolean isRunning(Class type) { + return RUNNING.containsKey(type); + } + + // ------------------------------------------------------------- helpers + + private static String text(CharSequence cs) { + return cs == null ? "" : cs.toString(); + } + + private static Record recordOf(Service s) { + for (Record r : RUNNING.values()) { + if (r.service == s) { + return r; + } + } + return null; + } + + private static Class resolve(Intent intent) { + if (intent == null) { + throw new NullPointerException("intent"); + } + AndroidRuntime rt = AndroidRuntime.getInstance(); + if (rt == null) { + throw new IllegalStateException("The Android runtime is not installed"); + } + AndroidApp app = rt.getApp(); + Class type = intent.getComponentClass(); + if (type == null && intent.getComponent() != null) { + type = app.serviceClass(intent.getComponent().getClassName()); + } + if (type == null) { + // Android has required explicit service intents since API 21. + throw new IllegalArgumentException("Service Intent must be explicit: " + intent); + } + if (!app.isService(type)) { + CompatReport.unsupported("service", type.getName() + " (not declared in the manifest)"); + return null; + } + return type; + } + + private static Record ensureCreated(Class type, ComponentName name) { + Record r = RUNNING.get(type); + if (r != null) { + return r; + } + AndroidRuntime rt = AndroidRuntime.getInstance(); + Service s = rt.getApp().createService(type); + if (s == null) { + CompatReport.unsupported("service", type.getName() + " (no generated factory entry)"); + return null; + } + r = new Record(type, name); + r.service = s; + RUNNING.put(type, r); + s.attachForRuntime(rt.getApplication(), rt.getApplication()); + s.onCreate(); + return r; + } + + private static void destroyIfIdle(Record r) { + if (r.started || !r.connections.isEmpty() || RUNNING.get(r.type) != r) { + return; + } + RUNNING.remove(r.type); + // A destroyed service is not started, whatever re-added it. + START_REQUESTED.remove(r.type); + endForeground(r); + r.service.onDestroy(); + } + + private static void endForeground(Record r) { + if (r.foregroundDone != null) { + r.foregroundDone.countDown(); + r.foregroundDone = null; + } + if (r.foreground != null) { + r.foreground.stop(); + r.foreground = null; + } + } +} diff --git a/maven/android-compat/src/main/java/android/app/IntentService.java b/maven/android-compat/src/main/java/android/app/IntentService.java new file mode 100644 index 00000000000..2baeaa4ac8d --- /dev/null +++ b/maven/android-compat/src/main/java/android/app/IntentService.java @@ -0,0 +1,95 @@ +/* + * 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 android.app; + +import android.content.Intent; +import android.os.Handler; +import android.os.HandlerThread; +import android.os.Looper; +import android.os.Message; + +/// Deprecated on Android, still common: each start is handled in turn on one +/// worker thread, and the service stops itself when the queue is empty. +public abstract class IntentService extends Service { + + private final String name; + private Looper serviceLooper; + private Handler serviceHandler; + private boolean redelivery; + + public IntentService(String name) { + this.name = name; + } + + public void setIntentRedelivery(boolean enabled) { + redelivery = enabled; + } + + @Override + public void onCreate() { + super.onCreate(); + HandlerThread thread = new HandlerThread("IntentService[" + name + "]"); + thread.start(); + serviceLooper = thread.getLooper(); + serviceHandler = new Handler(serviceLooper) { + @Override + public void handleMessage(Message msg) { + onHandleIntent((Intent) msg.obj); + stopSelf(msg.arg1); + } + }; + } + + @Override + public void onStart(Intent intent, int startId) { + if (serviceHandler == null) { + // A subclass overrode onCreate without calling super, which + // fails the same way on Android. + throw new IllegalStateException(getClass().getName() + ".onCreate() did not call super.onCreate()"); + } + Message msg = serviceHandler.obtainMessage(); + msg.arg1 = startId; + msg.obj = intent; + serviceHandler.sendMessage(msg); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + onStart(intent, startId); + return redelivery ? START_REDELIVER_INTENT : START_NOT_STICKY; + } + + @Override + public void onDestroy() { + if (serviceLooper != null) { + serviceLooper.quit(); + } + } + + @Override + public android.os.IBinder onBind(Intent intent) { + return null; + } + + protected abstract void onHandleIntent(Intent intent); +} diff --git a/maven/android-compat/src/main/java/android/app/Notification.java b/maven/android-compat/src/main/java/android/app/Notification.java new file mode 100644 index 00000000000..e2a696ae096 --- /dev/null +++ b/maven/android-compat/src/main/java/android/app/Notification.java @@ -0,0 +1,550 @@ +/* + * 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 android.app; + +import android.content.Context; +import android.os.Bundle; + +import java.util.ArrayList; +import java.util.List; + +/// A notification as built by [Notification.Builder] (or AndroidX's +/// `NotificationCompat.Builder`). [NotificationManager] posts it as a Codename +/// One `LocalNotification`, and `Service.startForeground` shows it as the +/// foreground service's notification. Title, text, channel, group, ongoing, +/// progress and the badge number carry over; styles contribute their big text +/// as the body; actions, custom views and large icons are kept but not shown. +public class Notification { + + public static final int FLAG_SHOW_LIGHTS = 0x00000001; + public static final int FLAG_ONGOING_EVENT = 0x00000002; + public static final int FLAG_INSISTENT = 0x00000004; + public static final int FLAG_ONLY_ALERT_ONCE = 0x00000008; + public static final int FLAG_AUTO_CANCEL = 0x00000010; + public static final int FLAG_NO_CLEAR = 0x00000020; + public static final int FLAG_FOREGROUND_SERVICE = 0x00000040; + public static final int FLAG_GROUP_SUMMARY = 0x00000200; + + public static final int DEFAULT_ALL = ~0; + public static final int DEFAULT_SOUND = 1; + public static final int DEFAULT_VIBRATE = 2; + public static final int DEFAULT_LIGHTS = 4; + + public static final int PRIORITY_DEFAULT = 0; + public static final int PRIORITY_LOW = -1; + public static final int PRIORITY_MIN = -2; + public static final int PRIORITY_HIGH = 1; + public static final int PRIORITY_MAX = 2; + + public static final int VISIBILITY_PUBLIC = 1; + public static final int VISIBILITY_PRIVATE = 0; + public static final int VISIBILITY_SECRET = -1; + + public static final int FOREGROUND_SERVICE_DEFAULT = 0; + public static final int FOREGROUND_SERVICE_IMMEDIATE = 1; + public static final int FOREGROUND_SERVICE_DEFERRED = 2; + + public static final String CATEGORY_CALL = "call"; + public static final String CATEGORY_NAVIGATION = "navigation"; + public static final String CATEGORY_MESSAGE = "msg"; + public static final String CATEGORY_EMAIL = "email"; + public static final String CATEGORY_EVENT = "event"; + public static final String CATEGORY_PROMO = "promo"; + public static final String CATEGORY_ALARM = "alarm"; + public static final String CATEGORY_PROGRESS = "progress"; + public static final String CATEGORY_SOCIAL = "social"; + public static final String CATEGORY_ERROR = "err"; + public static final String CATEGORY_TRANSPORT = "transport"; + public static final String CATEGORY_SYSTEM = "sys"; + public static final String CATEGORY_SERVICE = "service"; + public static final String CATEGORY_REMINDER = "reminder"; + public static final String CATEGORY_RECOMMENDATION = "recommendation"; + public static final String CATEGORY_STATUS = "status"; + public static final String CATEGORY_WORKOUT = "workout"; + public static final String CATEGORY_LOCATION_SHARING = "location_sharing"; + + public static final String EXTRA_TITLE = "android.title"; + public static final String EXTRA_TEXT = "android.text"; + public static final String EXTRA_SUB_TEXT = "android.subText"; + public static final String EXTRA_BIG_TEXT = "android.bigText"; + public static final String EXTRA_PROGRESS = "android.progress"; + public static final String EXTRA_PROGRESS_MAX = "android.progressMax"; + public static final String EXTRA_PROGRESS_INDETERMINATE = "android.progressIndeterminate"; + + public long when; + public int icon; + public int number; + public CharSequence tickerText; + public PendingIntent contentIntent; + public PendingIntent deleteIntent; + public int flags; + public int defaults; + public int priority; + public int visibility; + public int color; + public String category; + public Bundle extras = new Bundle(); + public Action[] actions; + + String channelId; + String group; + boolean groupSummary; + int progress; + int progressMax; + boolean progressIndeterminate; + long timeoutAfter; + + public Notification() { + when = System.currentTimeMillis(); + } + + public String getChannelId() { + return channelId; + } + + public String getGroup() { + return group; + } + + public long getTimeoutAfter() { + return timeoutAfter; + } + + public CharSequence title() { + return extras.getCharSequence(EXTRA_TITLE); + } + + /// The body a local notification shows: a style's big text when there is + /// one, otherwise the content text. + CharSequence body() { + CharSequence big = extras.getCharSequence(EXTRA_BIG_TEXT); + return big != null ? big : extras.getCharSequence(EXTRA_TEXT); + } + + /// This notification as a Codename One local notification with `id`. + public com.codename1.notifications.LocalNotification toLocal(String id) { + com.codename1.notifications.LocalNotification ln = new com.codename1.notifications.LocalNotification(); + ln.setId(id); + CharSequence t = title(); + CharSequence b = body(); + ln.setAlertTitle(t == null ? "" : t.toString()); + ln.setAlertBody(b == null ? "" : b.toString()); + if (channelId != null) { + ln.setChannelId(channelId); + } + if (group != null) { + ln.setGroup(group); + ln.setGroupSummary(groupSummary); + } + if (number > 0) { + ln.setBadgeNumber(number); + } + ln.setOngoing((flags & FLAG_ONGOING_EVENT) != 0); + // Codename One shows a determinate bar only; an indeterminate one is + // left off rather than drawn as empty. + if (progressMax > 0 && !progressIndeterminate) { + ln.setProgress(progressMax, progress); + } + return ln; + } + + /// A button on the notification. Kept so code that adds one compiles; + /// Codename One local notifications have no buttons to show it on. + public static class Action { + public int icon; + public CharSequence title; + public PendingIntent actionIntent; + + public Action(int icon, CharSequence title, PendingIntent intent) { + this.icon = icon; + this.title = title; + this.actionIntent = intent; + } + + public static final class Builder { + private final Action action; + + public Builder(int icon, CharSequence title, PendingIntent intent) { + action = new Action(icon, title, intent); + } + + public Builder addExtras(Bundle extras) { + return this; + } + + public Builder setAllowGeneratedReplies(boolean allow) { + return this; + } + + public Action build() { + return action; + } + } + } + + /// Base of the expanded layouts. Each contributes to the extras a local + /// notification reads. + public abstract static class Style { + protected Builder mBuilder; + + public void setBuilder(Builder builder) { + if (mBuilder != builder) { + mBuilder = builder; + if (builder != null) { + builder.setStyle(this); + } + } + } + + void apply(Notification n) { + } + + public Notification build() { + return mBuilder == null ? null : mBuilder.build(); + } + } + + public static class BigTextStyle extends Style { + private CharSequence bigText; + private CharSequence bigTitle; + + public BigTextStyle() { + } + + public BigTextStyle(Builder builder) { + setBuilder(builder); + } + + public BigTextStyle bigText(CharSequence cs) { + bigText = cs; + return this; + } + + public BigTextStyle setBigContentTitle(CharSequence title) { + bigTitle = title; + return this; + } + + public BigTextStyle setSummaryText(CharSequence cs) { + return this; + } + + @Override + void apply(Notification n) { + if (bigText != null) { + n.extras.putCharSequence(EXTRA_BIG_TEXT, bigText); + } + if (bigTitle != null) { + n.extras.putCharSequence(EXTRA_TITLE, bigTitle); + } + } + } + + public static class InboxStyle extends Style { + private final List lines = new ArrayList(); + + public InboxStyle() { + } + + public InboxStyle(Builder builder) { + setBuilder(builder); + } + + public InboxStyle addLine(CharSequence cs) { + lines.add(cs); + return this; + } + + public InboxStyle setBigContentTitle(CharSequence title) { + return this; + } + + public InboxStyle setSummaryText(CharSequence cs) { + return this; + } + + @Override + void apply(Notification n) { + if (!lines.isEmpty()) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < lines.size(); i++) { + if (i > 0) { + sb.append('\n'); + } + sb.append(lines.get(i)); + } + n.extras.putCharSequence(EXTRA_BIG_TEXT, sb.toString()); + } + } + } + + public static class Builder { + private final Notification n = new Notification(); + private final List actions = new ArrayList(); + private Style style; + + public Builder(Context context, String channelId) { + n.channelId = channelId; + } + + /// The pre-Oreo constructor: no channel. + public Builder(Context context) { + this(context, null); + } + + public Builder setContentTitle(CharSequence title) { + n.extras.putCharSequence(EXTRA_TITLE, title); + return this; + } + + public Builder setContentText(CharSequence text) { + n.extras.putCharSequence(EXTRA_TEXT, text); + return this; + } + + public Builder setSubText(CharSequence text) { + n.extras.putCharSequence(EXTRA_SUB_TEXT, text); + return this; + } + + public Builder setSmallIcon(int icon) { + n.icon = icon; + return this; + } + + public Builder setLargeIcon(android.graphics.Bitmap b) { + return this; + } + + public Builder setTicker(CharSequence tickerText) { + n.tickerText = tickerText; + return this; + } + + public Builder setWhen(long when) { + n.when = when; + return this; + } + + public Builder setShowWhen(boolean show) { + return this; + } + + public Builder setUsesChronometer(boolean b) { + return this; + } + + public Builder setNumber(int number) { + n.number = number; + return this; + } + + public Builder setContentIntent(PendingIntent intent) { + n.contentIntent = intent; + return this; + } + + public Builder setDeleteIntent(PendingIntent intent) { + n.deleteIntent = intent; + return this; + } + + public Builder setFullScreenIntent(PendingIntent intent, boolean highPriority) { + return this; + } + + private Builder flag(int mask, boolean value) { + if (value) { + n.flags |= mask; + } else { + n.flags &= ~mask; + } + return this; + } + + public Builder setOngoing(boolean ongoing) { + return flag(FLAG_ONGOING_EVENT, ongoing); + } + + public Builder setAutoCancel(boolean autoCancel) { + return flag(FLAG_AUTO_CANCEL, autoCancel); + } + + public Builder setOnlyAlertOnce(boolean onlyAlertOnce) { + return flag(FLAG_ONLY_ALERT_ONCE, onlyAlertOnce); + } + + public Builder setPriority(int pri) { + n.priority = pri; + return this; + } + + public Builder setCategory(String category) { + n.category = category; + return this; + } + + public Builder setVisibility(int visibility) { + n.visibility = visibility; + return this; + } + + public Builder setColor(int argb) { + n.color = argb; + return this; + } + + public Builder setColorized(boolean colorize) { + return this; + } + + public Builder setDefaults(int defaults) { + n.defaults = defaults; + return this; + } + + public Builder setSound(android.net.Uri sound) { + return this; + } + + public Builder setVibrate(long[] pattern) { + return this; + } + + public Builder setLights(int argb, int onMs, int offMs) { + return this; + } + + public Builder setGroup(String groupKey) { + n.group = groupKey; + return this; + } + + public Builder setGroupSummary(boolean isGroupSummary) { + n.groupSummary = isGroupSummary; + return flag(FLAG_GROUP_SUMMARY, isGroupSummary); + } + + public Builder setSortKey(String sortKey) { + return this; + } + + public Builder setChannelId(String channelId) { + n.channelId = channelId; + return this; + } + + public Builder setTimeoutAfter(long durationMs) { + n.timeoutAfter = durationMs; + return this; + } + + public Builder setProgress(int max, int progress, boolean indeterminate) { + n.progressMax = max; + n.progress = progress; + n.progressIndeterminate = indeterminate; + n.extras.putInt(EXTRA_PROGRESS, progress); + n.extras.putInt(EXTRA_PROGRESS_MAX, max); + n.extras.putBoolean(EXTRA_PROGRESS_INDETERMINATE, indeterminate); + return this; + } + + public Builder setForegroundServiceBehavior(int behavior) { + return this; + } + + public Builder setLocalOnly(boolean localOnly) { + return this; + } + + public Builder setStyle(Style s) { + if (style != s) { + style = s; + if (s != null) { + s.setBuilder(this); + } + } + return this; + } + + public Builder addAction(int icon, CharSequence title, PendingIntent intent) { + actions.add(new Action(icon, title, intent)); + return this; + } + + public Builder addAction(Action action) { + actions.add(action); + return this; + } + + public Builder setExtras(Bundle extras) { + if (extras != null) { + n.extras.putAll(extras); + } + return this; + } + + public Bundle getExtras() { + return n.extras; + } + + public Notification build() { + Notification out = copy(n); + out.actions = actions.toArray(new Action[actions.size()]); + if (style != null) { + style.apply(out); + } + return out; + } + + /// Deprecated alias of [#build()]. + public Notification getNotification() { + return build(); + } + + private static Notification copy(Notification src) { + Notification out = new Notification(); + out.when = src.when; + out.icon = src.icon; + out.number = src.number; + out.tickerText = src.tickerText; + out.contentIntent = src.contentIntent; + out.deleteIntent = src.deleteIntent; + out.flags = src.flags; + out.defaults = src.defaults; + out.priority = src.priority; + out.visibility = src.visibility; + out.color = src.color; + out.category = src.category; + out.extras = new Bundle(src.extras); + out.channelId = src.channelId; + out.group = src.group; + out.groupSummary = src.groupSummary; + out.progress = src.progress; + out.progressMax = src.progressMax; + out.progressIndeterminate = src.progressIndeterminate; + out.timeoutAfter = src.timeoutAfter; + return out; + } + } +} diff --git a/maven/android-compat/src/main/java/android/app/NotificationChannel.java b/maven/android-compat/src/main/java/android/app/NotificationChannel.java new file mode 100644 index 00000000000..789177bab5d --- /dev/null +++ b/maven/android-compat/src/main/java/android/app/NotificationChannel.java @@ -0,0 +1,166 @@ +/* + * 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 android.app; + +/// A notification channel. [NotificationManager#createNotificationChannel] +/// registers it as a Codename One `NotificationChannelBuilder`, which is a real +/// channel on Android and a no-op elsewhere. +public final class NotificationChannel { + + public static final String DEFAULT_CHANNEL_ID = "miscellaneous"; + + private final String id; + private CharSequence name; + private int importance; + private String description; + private String group; + private boolean showBadge = true; + private boolean lights; + private int lightColor; + private boolean vibration; + private long[] vibrationPattern; + private int lockscreenVisibility = Notification.VISIBILITY_PRIVATE; + + public NotificationChannel(String id, CharSequence name, int importance) { + this.id = id; + this.name = name; + this.importance = importance; + } + + public String getId() { + return id; + } + + public CharSequence getName() { + return name; + } + + public void setName(CharSequence name) { + this.name = name; + } + + public int getImportance() { + return importance; + } + + public void setImportance(int importance) { + this.importance = importance; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getGroup() { + return group; + } + + public void setGroup(String groupId) { + group = groupId; + } + + public boolean canShowBadge() { + return showBadge; + } + + public void setShowBadge(boolean showBadge) { + this.showBadge = showBadge; + } + + public boolean shouldShowLights() { + return lights; + } + + public void enableLights(boolean lights) { + this.lights = lights; + } + + public int getLightColor() { + return lightColor; + } + + public void setLightColor(int argb) { + lightColor = argb; + } + + public boolean shouldVibrate() { + return vibration; + } + + public void enableVibration(boolean vibration) { + this.vibration = vibration; + } + + public long[] getVibrationPattern() { + return vibrationPattern; + } + + public void setVibrationPattern(long[] pattern) { + vibrationPattern = pattern; + vibration = pattern != null && pattern.length > 0; + } + + public int getLockscreenVisibility() { + return lockscreenVisibility; + } + + public void setLockscreenVisibility(int visibility) { + lockscreenVisibility = visibility; + } + + /// Sounds name an Android `Uri` and `AudioAttributes`; the channel keeps + /// the platform's default sound. + public void setSound(android.net.Uri sound, Object audioAttributes) { + } + + public void setBypassDnd(boolean bypassDnd) { + } + + com.codename1.notifications.NotificationChannelBuilder toBuilder() { + com.codename1.notifications.NotificationChannelBuilder b = + new com.codename1.notifications.NotificationChannelBuilder(id, name == null ? id : name.toString()); + // Android's IMPORTANCE_* and Codename One's share their numbering. + b.importance(importance); + if (description != null) { + b.description(description); + } + if (group != null) { + b.group(group); + } + b.showBadge(showBadge); + b.enableLights(lights); + if (lightColor != 0) { + b.lightColor(lightColor); + } + b.enableVibration(vibration); + if (vibrationPattern != null) { + b.vibrationPattern(vibrationPattern); + } + b.lockscreenVisibility(lockscreenVisibility); + return b; + } +} diff --git a/maven/android-compat/src/main/java/android/app/NotificationManager.java b/maven/android-compat/src/main/java/android/app/NotificationManager.java new file mode 100644 index 00000000000..37a9a7f5855 --- /dev/null +++ b/maven/android-compat/src/main/java/android/app/NotificationManager.java @@ -0,0 +1,143 @@ +/* + * 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 android.app; + +import com.codename1.ui.Display; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// Posts notifications as Codename One local notifications, delivered now. +/// +/// A notification whose id belongs to a running foreground service updates +/// that service's notification instead, which is how Android code reports +/// progress from a service (`notify(FOREGROUND_ID, builder.build())`). +public class NotificationManager { + + public static final int IMPORTANCE_UNSPECIFIED = -1000; + public static final int IMPORTANCE_NONE = 0; + public static final int IMPORTANCE_MIN = 1; + public static final int IMPORTANCE_LOW = 2; + public static final int IMPORTANCE_DEFAULT = 3; + public static final int IMPORTANCE_HIGH = 4; + public static final int IMPORTANCE_MAX = 5; + + public static final String ACTION_NOTIFICATION_POLICY_ACCESS_GRANTED_CHANGED = + "android.app.action.NOTIFICATION_POLICY_ACCESS_GRANTED_CHANGED"; + + private static NotificationManager instance; + + private final Map channels = new LinkedHashMap(); + + public static NotificationManager getInstance() { + if (instance == null) { + instance = new NotificationManager(); + } + return instance; + } + + NotificationManager() { + } + + public void createNotificationChannel(NotificationChannel channel) { + channels.put(channel.getId(), channel); + if (Display.isInitialized()) { + Display.getInstance().registerNotificationChannel(channel.toBuilder()); + } + } + + public void createNotificationChannels(List list) { + for (int i = 0; i < list.size(); i++) { + createNotificationChannel(list.get(i)); + } + } + + public NotificationChannel getNotificationChannel(String channelId) { + return channels.get(channelId); + } + + public List getNotificationChannels() { + return new ArrayList(channels.values()); + } + + public void deleteNotificationChannel(String channelId) { + channels.remove(channelId); + } + + public boolean areNotificationsEnabled() { + return true; + } + + public int getImportance() { + return IMPORTANCE_DEFAULT; + } + + public void notify(int id, Notification notification) { + notify(null, id, notification); + } + + public void notify(String tag, int id, Notification notification) { + if (tag == null && ActiveServices.updateForegroundNotification(id, notification)) { + return; + } + if (Display.isInitialized()) { + Display.getInstance().scheduleLocalNotification(notification.toLocal(key(tag, id)), + System.currentTimeMillis() + 1, + com.codename1.notifications.LocalNotification.REPEAT_NONE); + } + } + + public void cancel(int id) { + cancel(null, id); + } + + public void cancel(String tag, int id) { + if (Display.isInitialized()) { + Display.getInstance().cancelLocalNotification(key(tag, id)); + } + } + + /// Codename One cancels local notifications one id at a time and does not + /// list the ones already shown, so this cancels nothing it did not post + /// in this run. + public void cancelAll() { + for (int i = 0; i < posted.size(); i++) { + if (Display.isInitialized()) { + Display.getInstance().cancelLocalNotification(posted.get(i)); + } + } + posted.clear(); + } + + private final List posted = new ArrayList(); + + private String key(String tag, int id) { + String k = tag == null ? "cn1compat:" + id : "cn1compat:" + tag + ":" + id; + if (!posted.contains(k)) { + posted.add(k); + } + return k; + } +} diff --git a/maven/android-compat/src/main/java/android/app/PendingIntent.java b/maven/android-compat/src/main/java/android/app/PendingIntent.java new file mode 100644 index 00000000000..6691548bded --- /dev/null +++ b/maven/android-compat/src/main/java/android/app/PendingIntent.java @@ -0,0 +1,148 @@ +/* + * 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 android.app; + +import android.content.Context; +import android.content.Intent; + +/// An intent to run later, on behalf of this application. There is no other +/// application to hand one to here, so a pending intent is the intent and the +/// call that will start it; [#send()] makes that call. Notifications keep the +/// one they are given (tapping a Codename One local notification brings the +/// application forward, it does not run the intent). +public final class PendingIntent { + + public static final int FLAG_ONE_SHOT = 1 << 30; + public static final int FLAG_NO_CREATE = 1 << 29; + public static final int FLAG_CANCEL_CURRENT = 1 << 28; + public static final int FLAG_UPDATE_CURRENT = 1 << 27; + public static final int FLAG_IMMUTABLE = 1 << 26; + public static final int FLAG_MUTABLE = 1 << 25; + public static final int FLAG_ALLOW_UNSAFE_IMPLICIT_INTENT = 1 << 24; + + static final int KIND_ACTIVITY = 0; + static final int KIND_SERVICE = 1; + static final int KIND_FOREGROUND_SERVICE = 2; + static final int KIND_BROADCAST = 3; + + /// Thrown by [#send()] once a one-shot or cancelled intent has been used. + public static class CanceledException extends Exception { + public CanceledException() { + } + + public CanceledException(String name) { + super(name); + } + } + + private final Context context; + private final Intent intent; + private final int kind; + private final int requestCode; + private final int flags; + private boolean cancelled; + + private PendingIntent(Context context, int requestCode, Intent intent, int flags, int kind) { + this.context = context; + this.requestCode = requestCode; + this.intent = intent == null ? new Intent() : new Intent(intent); + this.flags = flags; + this.kind = kind; + } + + public static PendingIntent getActivity(Context context, int requestCode, Intent intent, int flags) { + return new PendingIntent(context, requestCode, intent, flags, KIND_ACTIVITY); + } + + public static PendingIntent getActivity(Context context, int requestCode, Intent intent, int flags, + android.os.Bundle options) { + return getActivity(context, requestCode, intent, flags); + } + + public static PendingIntent getService(Context context, int requestCode, Intent intent, int flags) { + return new PendingIntent(context, requestCode, intent, flags, KIND_SERVICE); + } + + public static PendingIntent getForegroundService(Context context, int requestCode, Intent intent, int flags) { + return new PendingIntent(context, requestCode, intent, flags, KIND_FOREGROUND_SERVICE); + } + + public static PendingIntent getBroadcast(Context context, int requestCode, Intent intent, int flags) { + return new PendingIntent(context, requestCode, intent, flags, KIND_BROADCAST); + } + + public void send() throws CanceledException { + if (cancelled) { + throw new CanceledException(); + } + if ((flags & FLAG_ONE_SHOT) != 0) { + cancelled = true; + } + switch (kind) { + case KIND_ACTIVITY: + context.startActivity(intent); + break; + case KIND_SERVICE: + context.startService(intent); + break; + case KIND_FOREGROUND_SERVICE: + context.startForegroundService(intent); + break; + default: + context.sendBroadcast(intent); + break; + } + } + + public void send(int code) throws CanceledException { + send(); + } + + public void cancel() { + cancelled = true; + } + + public String getCreatorPackage() { + return context.getPackageName(); + } + + public boolean isActivity() { + return kind == KIND_ACTIVITY; + } + + public boolean isService() { + return kind == KIND_SERVICE || kind == KIND_FOREGROUND_SERVICE; + } + + public boolean isBroadcast() { + return kind == KIND_BROADCAST; + } + + public boolean isImmutable() { + return (flags & FLAG_IMMUTABLE) != 0; + } + + int requestCode() { + return requestCode; + } +} diff --git a/maven/android-compat/src/main/java/android/app/Service.java b/maven/android-compat/src/main/java/android/app/Service.java new file mode 100644 index 00000000000..53cbc7103be --- /dev/null +++ b/maven/android-compat/src/main/java/android/app/Service.java @@ -0,0 +1,157 @@ +/* + * 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 android.app; + +import android.content.Context; +import android.content.ContextWrapper; +import android.content.Intent; +import android.os.IBinder; + +/// An Android service, run in this process by [ActiveServices]. +/// +/// The lifecycle is Android's: `onCreate`, then `onStartCommand` for every +/// `startService`, `onBind`/`onUnbind` for bindings, and `onDestroy` once the +/// service is neither started nor bound. Every callback runs on the main +/// thread (Codename One's EDT), so long work belongs on a thread of the +/// service's own -- exactly as on Android. +/// +/// [#startForeground(int, Notification)] runs the service under a Codename +/// One `ForegroundService`: a persistent notification on Android, a +/// background execution window and a notification on iOS. The service object +/// itself does not need the platform to restart it; `START_STICKY` and the +/// other return values are accepted and have no effect, since nothing kills a +/// service separately from the application here. +public abstract class Service extends ContextWrapper { + + public static final int START_STICKY_COMPATIBILITY = 0; + public static final int START_STICKY = 1; + public static final int START_NOT_STICKY = 2; + public static final int START_REDELIVER_INTENT = 3; + public static final int START_FLAG_REDELIVERY = 1; + public static final int START_FLAG_RETRY = 2; + public static final int STOP_FOREGROUND_REMOVE = 1; + public static final int STOP_FOREGROUND_DETACH = 2; + public static final int STOP_FOREGROUND_LEGACY = 0; + + private Application application; + // Read by stopSelfResult on the service's worker thread. + private final java.util.concurrent.atomic.AtomicInteger lastStartId = + new java.util.concurrent.atomic.AtomicInteger(); + + public Service() { + super(null); + } + + final void attachForRuntime(Context base, Application app) { + attachBaseContext(base); + application = app; + } + + public final Application getApplication() { + return application; + } + + public void onCreate() { + } + + /// Deprecated on Android; still called by the default `onStartCommand`. + public void onStart(Intent intent, int startId) { + } + + public int onStartCommand(Intent intent, int flags, int startId) { + onStart(intent, startId); + return START_STICKY; + } + + public abstract IBinder onBind(Intent intent); + + public boolean onUnbind(Intent intent) { + return false; + } + + public void onRebind(Intent intent) { + } + + public void onDestroy() { + } + + public void onTaskRemoved(Intent rootIntent) { + } + + public void onTimeout(int startId) { + } + + public void onTimeout(int startId, int fgsType) { + } + + public void onConfigurationChanged(android.content.res.Configuration newConfig) { + } + + public void onLowMemory() { + } + + public void onTrimMemory(int level) { + } + + public final void stopSelf() { + ActiveServices.stopSelf(this, -1); + } + + public final void stopSelf(int startId) { + stopSelfResult(startId); + } + + /// Stops the service when `startId` is the most recent start, as + /// Android does, so a start that arrived meanwhile keeps it running. + public final boolean stopSelfResult(int startId) { + return ActiveServices.stopSelf(this, startId); + } + + public final void startForeground(int id, Notification notification) { + ActiveServices.startForeground(this, id, notification); + } + + public final void startForeground(int id, Notification notification, int foregroundServiceType) { + startForeground(id, notification); + } + + public final void stopForeground(boolean removeNotification) { + ActiveServices.stopForeground(this); + } + + public final void stopForeground(int notificationBehavior) { + ActiveServices.stopForeground(this); + } + + public final int getForegroundServiceType() { + return 0; + } + + int nextStartId() { + return lastStartId.incrementAndGet(); + } + + int lastStartId() { + return lastStartId.get(); + } +} diff --git a/maven/android-compat/src/main/java/android/content/Context.java b/maven/android-compat/src/main/java/android/content/Context.java index 3cdb6585396..9be566d7723 100644 --- a/maven/android-compat/src/main/java/android/content/Context.java +++ b/maven/android-compat/src/main/java/android/content/Context.java @@ -150,6 +150,15 @@ public String getSystemServiceName(Class serviceClass) { if (serviceClass == android.view.inputmethod.InputMethodManager.class) { return INPUT_METHOD_SERVICE; } + if (serviceClass == android.location.LocationManager.class) { + return LOCATION_SERVICE; + } + if (serviceClass == android.hardware.SensorManager.class) { + return SENSOR_SERVICE; + } + if (serviceClass == android.app.NotificationManager.class) { + return NOTIFICATION_SERVICE; + } return null; } @@ -407,4 +416,34 @@ public void unregisterReceiver(BroadcastReceiver receiver) { public boolean isRestricted() { return false; } + + // ------------------------------------------------------------ services + + /// Starts a service the manifest declares; see `android.app.ActiveServices`. + public ComponentName startService(Intent service) { + return android.app.ActiveServices.startService(this, service); + } + + /// The same start as [#startService(Intent)]: the service is expected to + /// call `startForeground`, which is what puts it in the foreground. + public ComponentName startForegroundService(Intent service) { + return android.app.ActiveServices.startService(this, service); + } + + public boolean stopService(Intent service) { + return android.app.ActiveServices.stopService(this, service); + } + + public boolean bindService(Intent service, ServiceConnection conn, int flags) { + return android.app.ActiveServices.bindService(this, service, conn, flags); + } + + public boolean bindService(Intent service, int flags, java.util.concurrent.Executor executor, + ServiceConnection conn) { + return bindService(service, conn, flags); + } + + public void unbindService(ServiceConnection conn) { + android.app.ActiveServices.unbindService(this, conn); + } } diff --git a/maven/android-compat/src/main/java/android/content/ServiceConnection.java b/maven/android-compat/src/main/java/android/content/ServiceConnection.java new file mode 100644 index 00000000000..f336df233f7 --- /dev/null +++ b/maven/android-compat/src/main/java/android/content/ServiceConnection.java @@ -0,0 +1,38 @@ +/* + * 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 android.content; + +import android.os.IBinder; + +public interface ServiceConnection { + + void onServiceConnected(ComponentName name, IBinder service); + + void onServiceDisconnected(ComponentName name); + + default void onBindingDied(ComponentName name) { + } + + default void onNullBinding(ComponentName name) { + } +} diff --git a/maven/android-compat/src/main/java/android/content/pm/ServiceInfo.java b/maven/android-compat/src/main/java/android/content/pm/ServiceInfo.java new file mode 100644 index 00000000000..5275b3ee241 --- /dev/null +++ b/maven/android-compat/src/main/java/android/content/pm/ServiceInfo.java @@ -0,0 +1,48 @@ +/* + * 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 android.content.pm; + +/// The foreground service types `Service.startForeground` takes. They decide +/// nothing here: the Codename One foreground service is the same whatever the +/// type. +public class ServiceInfo { + + public static final int FOREGROUND_SERVICE_TYPE_NONE = 0; + public static final int FOREGROUND_SERVICE_TYPE_DATA_SYNC = 1; + public static final int FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK = 2; + public static final int FOREGROUND_SERVICE_TYPE_PHONE_CALL = 4; + public static final int FOREGROUND_SERVICE_TYPE_LOCATION = 8; + public static final int FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE = 16; + public static final int FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION = 32; + public static final int FOREGROUND_SERVICE_TYPE_CAMERA = 64; + public static final int FOREGROUND_SERVICE_TYPE_MICROPHONE = 128; + public static final int FOREGROUND_SERVICE_TYPE_HEALTH = 256; + public static final int FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING = 512; + public static final int FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED = 1024; + public static final int FOREGROUND_SERVICE_TYPE_SHORT_SERVICE = 2048; + public static final int FOREGROUND_SERVICE_TYPE_SPECIAL_USE = 0x40000000; + public static final int FOREGROUND_SERVICE_TYPE_MANIFEST = -1; + + public String name; + public String packageName; +} diff --git a/maven/android-compat/src/main/java/android/database/sqlite/SQLiteDatabase.java b/maven/android-compat/src/main/java/android/database/sqlite/SQLiteDatabase.java index 5c9ba1f077e..2ec7e328e26 100644 --- a/maven/android-compat/src/main/java/android/database/sqlite/SQLiteDatabase.java +++ b/maven/android-compat/src/main/java/android/database/sqlite/SQLiteDatabase.java @@ -91,13 +91,15 @@ public interface CursorFactory { /// The thread whose outermost transaction is open on the engine, or null. /// /// There is one engine connection, so a second thread cannot have a - /// transaction of its own beside it. Android would block that thread - /// until the first one ends; this runtime adds no locks (serializing + /// transaction of its own beside it. Android blocks that thread until the + /// first one ends, and Room code relies on it: two executor threads + /// writing at once is ordinary. This runtime adds no locks (serializing /// statements on the connection is the Codename One `Database` - /// implementation's job), so it refuses with - /// [SQLiteDatabaseLockedException] -- SQLite's own answer when a busy - /// wait runs out -- rather than silently folding the second thread's - /// work into the first one's transaction. + /// implementation's job), so the second thread busy-waits for the owner + /// to clear, as SQLite does under its busy timeout, and is refused with + /// [SQLiteDatabaseLockedException] -- SQLite's answer when that wait runs + /// out -- only after [#BUSY_TIMEOUT_MS]. Its work is never folded into the + /// first thread's transaction. /// /// An atomic rather than a lock: the outermost `beginTransaction` claims /// it with `compareAndSet(null, current)` before the engine's BEGIN, so @@ -117,13 +119,69 @@ public interface CursorFactory { /// no-lock reason as `beginTransaction` this refuses it with the same /// [SQLiteDatabaseLockedException]. private void checkNotInOtherThreadsTransaction(String sql) { - Thread owner = mTransactionOwner.get(); - if (owner != null && owner != Thread.currentThread()) { + if (!awaitNoForeignOwner()) { throw new SQLiteDatabaseLockedException("database is locked: another thread's transaction is open, " + "while executing: " + sql); } } + /// How long a thread waits for another thread's transaction to end, in + /// milliseconds; tests shorten it to observe the refusal. + static final java.util.concurrent.atomic.AtomicLong BUSY_TIMEOUT_MS = + new java.util.concurrent.atomic.AtomicLong(10000); + + /// How long the main thread waits, in milliseconds. Shorter: a worker's + /// transaction may itself be waiting on the main thread (a posted + /// callback, a `Future` of main thread work), and the UI is frozen for + /// as long as this waits. + static final java.util.concurrent.atomic.AtomicLong MAIN_BUSY_TIMEOUT_MS = + new java.util.concurrent.atomic.AtomicLong(2000); + + private static long deadline() { + if (com.codename1.androidcompat.runtime.MainThread.isMainThread() + && com.codename1.ui.Display.isInitialized()) { + return System.currentTimeMillis() + MAIN_BUSY_TIMEOUT_MS.get(); + } + return System.currentTimeMillis() + BUSY_TIMEOUT_MS.get(); + } + + private boolean awaitNoForeignOwner() { + Thread current = Thread.currentThread(); + long deadline = deadline(); + while (true) { + Thread owner = mTransactionOwner.get(); + if (owner == null || owner == current) { + return true; + } + if (!pause(deadline)) { + return false; + } + } + } + + private boolean claimOwner(Thread current) { + long deadline = deadline(); + while (!mTransactionOwner.compareAndSet(null, current)) { + if (!pause(deadline)) { + return false; + } + } + return true; + } + + private static boolean pause(long deadline) { + if (System.currentTimeMillis() >= deadline) { + return false; + } + try { + Thread.sleep(2); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + return true; + } + private static final class Transaction { boolean markedSuccessful; boolean childFailed; @@ -387,7 +445,7 @@ public void beginTransactionWithListener(SQLiteTransactionListener transactionLi } if (stack.isEmpty()) { Thread current = Thread.currentThread(); - if (!mTransactionOwner.compareAndSet(null, current)) { + if (!claimOwner(current)) { throw new SQLiteDatabaseLockedException("database is locked: another thread's transaction is open"); } try { diff --git a/maven/android-compat/src/main/java/android/hardware/Sensor.java b/maven/android-compat/src/main/java/android/hardware/Sensor.java new file mode 100644 index 00000000000..1efa41e3b2e --- /dev/null +++ b/maven/android-compat/src/main/java/android/hardware/Sensor.java @@ -0,0 +1,154 @@ +/* + * 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 android.hardware; + +/// One sensor [SensorManager#getDefaultSensor(int)] answered. Only the types +/// Codename One can feed exist; [SensorManager] answers null for the others, +/// exactly as Android does on a device without that hardware. +public final class Sensor { + + public static final int TYPE_ALL = -1; + public static final int TYPE_ACCELEROMETER = 1; + public static final int TYPE_MAGNETIC_FIELD = 2; + /// Deprecated on Android; still delivered, in degrees. + public static final int TYPE_ORIENTATION = 3; + public static final int TYPE_GYROSCOPE = 4; + public static final int TYPE_LIGHT = 5; + public static final int TYPE_PRESSURE = 6; + public static final int TYPE_TEMPERATURE = 7; + public static final int TYPE_PROXIMITY = 8; + public static final int TYPE_GRAVITY = 9; + public static final int TYPE_LINEAR_ACCELERATION = 10; + public static final int TYPE_ROTATION_VECTOR = 11; + public static final int TYPE_RELATIVE_HUMIDITY = 12; + public static final int TYPE_AMBIENT_TEMPERATURE = 13; + public static final int TYPE_MAGNETIC_FIELD_UNCALIBRATED = 14; + public static final int TYPE_GAME_ROTATION_VECTOR = 15; + public static final int TYPE_GYROSCOPE_UNCALIBRATED = 16; + public static final int TYPE_SIGNIFICANT_MOTION = 17; + public static final int TYPE_STEP_DETECTOR = 18; + public static final int TYPE_STEP_COUNTER = 19; + public static final int TYPE_GEOMAGNETIC_ROTATION_VECTOR = 20; + public static final int TYPE_HEART_RATE = 21; + public static final int TYPE_ACCELEROMETER_UNCALIBRATED = 35; + + public static final String STRING_TYPE_ACCELEROMETER = "android.sensor.accelerometer"; + public static final String STRING_TYPE_MAGNETIC_FIELD = "android.sensor.magnetic_field"; + public static final String STRING_TYPE_ORIENTATION = "android.sensor.orientation"; + public static final String STRING_TYPE_GYROSCOPE = "android.sensor.gyroscope"; + public static final String STRING_TYPE_GRAVITY = "android.sensor.gravity"; + public static final String STRING_TYPE_LINEAR_ACCELERATION = "android.sensor.linear_acceleration"; + public static final String STRING_TYPE_ROTATION_VECTOR = "android.sensor.rotation_vector"; + public static final String STRING_TYPE_GEOMAGNETIC_ROTATION_VECTOR = "android.sensor.geomagnetic_rotation_vector"; + + public static final int REPORTING_MODE_CONTINUOUS = 0; + public static final int REPORTING_MODE_ON_CHANGE = 1; + public static final int REPORTING_MODE_ONE_SHOT = 2; + public static final int REPORTING_MODE_SPECIAL_TRIGGER = 3; + + private final int type; + private final String name; + private final String stringType; + private final float maximumRange; + private final float resolution; + + Sensor(int type, String name, String stringType, float maximumRange, float resolution) { + this.type = type; + this.name = name; + this.stringType = stringType; + this.maximumRange = maximumRange; + this.resolution = resolution; + } + + public int getType() { + return type; + } + + public String getName() { + return name; + } + + public String getStringType() { + return stringType; + } + + public String getVendor() { + return "Codename One"; + } + + public int getVersion() { + return 1; + } + + public float getMaximumRange() { + return maximumRange; + } + + public float getResolution() { + return resolution; + } + + /// Unknown here; Android reports milliamperes. + public float getPower() { + return 0; + } + + /// The fastest sampling Codename One asks the platform for, in + /// microseconds. + public int getMinDelay() { + return 10000; + } + + public int getMaxDelay() { + return 1000000; + } + + public int getReportingMode() { + return REPORTING_MODE_CONTINUOUS; + } + + public int getFifoMaxEventCount() { + return 0; + } + + public int getFifoReservedEventCount() { + return 0; + } + + public boolean isWakeUpSensor() { + return false; + } + + public boolean isDynamicSensor() { + return false; + } + + public int getId() { + return type; + } + + @Override + public String toString() { + return "{Sensor name=\"" + name + "\", vendor=\"Codename One\", type=" + type + "}"; + } +} diff --git a/maven/android-compat/src/main/java/android/hardware/SensorEvent.java b/maven/android-compat/src/main/java/android/hardware/SensorEvent.java new file mode 100644 index 00000000000..208ccd5ee47 --- /dev/null +++ b/maven/android-compat/src/main/java/android/hardware/SensorEvent.java @@ -0,0 +1,38 @@ +/* + * 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 android.hardware; + +/// One reading. As on Android the array is the listener's to read during the +/// callback only: the next event of the same registration reuses it. +public class SensorEvent { + + public final float[] values; + public Sensor sensor; + public int accuracy; + /// Nanoseconds on the `SystemClock.elapsedRealtimeNanos()` time base. + public long timestamp; + + SensorEvent(int valueSize) { + values = new float[valueSize]; + } +} diff --git a/maven/android-compat/src/main/java/android/hardware/SensorEventListener.java b/maven/android-compat/src/main/java/android/hardware/SensorEventListener.java new file mode 100644 index 00000000000..8790d6ce52a --- /dev/null +++ b/maven/android-compat/src/main/java/android/hardware/SensorEventListener.java @@ -0,0 +1,30 @@ +/* + * 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 android.hardware; + +public interface SensorEventListener { + + void onSensorChanged(SensorEvent event); + + void onAccuracyChanged(Sensor sensor, int accuracy); +} diff --git a/maven/android-compat/src/main/java/android/hardware/SensorManager.java b/maven/android-compat/src/main/java/android/hardware/SensorManager.java new file mode 100644 index 00000000000..9bd0115e7d2 --- /dev/null +++ b/maven/android-compat/src/main/java/android/hardware/SensorManager.java @@ -0,0 +1,591 @@ +/* + * 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 android.hardware; + +import android.os.Handler; +import com.codename1.sensors.MotionEvent; +import com.codename1.sensors.MotionSensor; +import com.codename1.sensors.MotionSensorListener; +import com.codename1.sensors.MotionSensorManager; + +import java.util.ArrayList; +import java.util.List; + +/// Android's sensor service over `com.codename1.sensors.MotionSensorManager`. +/// +/// The raw sensors map one to one and in the same units (m/s^2, rad/s, +/// microtesla): accelerometer, gyroscope, magnetic field, and Codename One's +/// gravity and linear acceleration. The fused ones Android computes from them +/// are computed here from Codename One's gravity and magnetometer with the +/// same matrix math [#getRotationMatrix] performs: +/// +/// - `TYPE_ROTATION_VECTOR` and `TYPE_GEOMAGNETIC_ROTATION_VECTOR`, the unit +/// quaternion of that matrix (x, y, z, w, and -1 for the unknown heading +/// accuracy); +/// - the deprecated `TYPE_ORIENTATION`, in degrees, azimuth in [0, 360). +/// +/// Both reference magnetic north, as Android's do; add +/// `GeomagneticField.getDeclination()` for true north. Android also fuses the +/// gyroscope into its rotation vector, so this one is noisier between two +/// magnetometer readings -- low-pass the azimuth for a compass needle. +/// +/// A type Codename One cannot feed (light, pressure, proximity, step counter, +/// game rotation vector) is absent: [#getDefaultSensor(int)] answers null for +/// it, which Android code already handles for a device without the hardware. +/// +/// Readings arrive on the main thread (Codename One's EDT), or on the looper +/// of the `Handler` a registration names. The sampling period applies to every +/// sensor at once, because Codename One samples on one interval: the shortest +/// period any registration asked for. +public class SensorManager { + + public static final int SENSOR_DELAY_FASTEST = 0; + public static final int SENSOR_DELAY_GAME = 1; + public static final int SENSOR_DELAY_UI = 2; + public static final int SENSOR_DELAY_NORMAL = 3; + + public static final int SENSOR_STATUS_NO_CONTACT = -1; + public static final int SENSOR_STATUS_UNRELIABLE = 0; + public static final int SENSOR_STATUS_ACCURACY_LOW = 1; + public static final int SENSOR_STATUS_ACCURACY_MEDIUM = 2; + public static final int SENSOR_STATUS_ACCURACY_HIGH = 3; + + public static final float STANDARD_GRAVITY = 9.80665f; + public static final float GRAVITY_EARTH = 9.80665f; + public static final float MAGNETIC_FIELD_EARTH_MAX = 60.0f; + public static final float MAGNETIC_FIELD_EARTH_MIN = 30.0f; + public static final float PRESSURE_STANDARD_ATMOSPHERE = 1013.25f; + + public static final int AXIS_X = 1; + public static final int AXIS_Y = 2; + public static final int AXIS_Z = 3; + public static final int AXIS_MINUS_X = AXIS_X | 0x80; + public static final int AXIS_MINUS_Y = AXIS_Y | 0x80; + public static final int AXIS_MINUS_Z = AXIS_Z | 0x80; + + /// The Codename One manager to use instead of the platform's; set by tests. + static MotionSensorManager override; + + private static SensorManager instance; + + private final Source[] sources = new Source[7]; + private final List registrations = new ArrayList(); + private final float[] matrix = new float[9]; + private final float[] scratch = new float[3]; + + public static SensorManager getInstance() { + if (instance == null) { + instance = new SensorManager(); + } + return instance; + } + + SensorManager() { + } + + static MotionSensorManager platform() { + return override != null ? override : MotionSensorManager.getInstance(); + } + + // -------------------------------------------------------------- sensors + + /// The Codename One sensor types an Android type is computed from. + static int[] sourcesOf(int type) { + switch (type) { + case Sensor.TYPE_ACCELEROMETER: + return new int[] {MotionSensorManager.TYPE_ACCELEROMETER}; + case Sensor.TYPE_MAGNETIC_FIELD: + return new int[] {MotionSensorManager.TYPE_MAGNETOMETER}; + case Sensor.TYPE_GYROSCOPE: + return new int[] {MotionSensorManager.TYPE_GYROSCOPE}; + case Sensor.TYPE_GRAVITY: + return new int[] {MotionSensorManager.TYPE_GRAVITY}; + case Sensor.TYPE_LINEAR_ACCELERATION: + return new int[] {MotionSensorManager.TYPE_LINEAR_ACCELERATION}; + case Sensor.TYPE_ROTATION_VECTOR: + case Sensor.TYPE_GEOMAGNETIC_ROTATION_VECTOR: + case Sensor.TYPE_ORIENTATION: + return new int[] {MotionSensorManager.TYPE_GRAVITY, MotionSensorManager.TYPE_MAGNETOMETER}; + default: + return null; + } + } + + private static Sensor describe(int type) { + switch (type) { + case Sensor.TYPE_ACCELEROMETER: + return new Sensor(type, "Accelerometer", Sensor.STRING_TYPE_ACCELEROMETER, 4 * GRAVITY_EARTH, 0.01f); + case Sensor.TYPE_MAGNETIC_FIELD: + return new Sensor(type, "Magnetic field", Sensor.STRING_TYPE_MAGNETIC_FIELD, 2000f, 0.1f); + case Sensor.TYPE_GYROSCOPE: + return new Sensor(type, "Gyroscope", Sensor.STRING_TYPE_GYROSCOPE, 35f, 0.001f); + case Sensor.TYPE_GRAVITY: + return new Sensor(type, "Gravity", Sensor.STRING_TYPE_GRAVITY, GRAVITY_EARTH, 0.01f); + case Sensor.TYPE_LINEAR_ACCELERATION: + return new Sensor(type, "Linear acceleration", Sensor.STRING_TYPE_LINEAR_ACCELERATION, + 4 * GRAVITY_EARTH, 0.01f); + case Sensor.TYPE_ROTATION_VECTOR: + return new Sensor(type, "Rotation vector", Sensor.STRING_TYPE_ROTATION_VECTOR, 1f, 0.0001f); + case Sensor.TYPE_GEOMAGNETIC_ROTATION_VECTOR: + return new Sensor(type, "Geomagnetic rotation vector", + Sensor.STRING_TYPE_GEOMAGNETIC_ROTATION_VECTOR, 1f, 0.0001f); + case Sensor.TYPE_ORIENTATION: + return new Sensor(type, "Orientation", Sensor.STRING_TYPE_ORIENTATION, 360f, 0.1f); + default: + return null; + } + } + + private static final int[] ALL_TYPES = { + Sensor.TYPE_ACCELEROMETER, Sensor.TYPE_MAGNETIC_FIELD, Sensor.TYPE_ORIENTATION, + Sensor.TYPE_GYROSCOPE, Sensor.TYPE_GRAVITY, Sensor.TYPE_LINEAR_ACCELERATION, + Sensor.TYPE_ROTATION_VECTOR, Sensor.TYPE_GEOMAGNETIC_ROTATION_VECTOR, + }; + + private final Sensor[] described = new Sensor[64]; + + public Sensor getDefaultSensor(int type) { + int[] needs = sourcesOf(type); + if (needs == null) { + return null; + } + MotionSensorManager m = platform(); + for (int i = 0; i < needs.length; i++) { + if (!m.isSensorSupported(needs[i])) { + return null; + } + } + if (described[type] == null) { + described[type] = describe(type); + } + return described[type]; + } + + public Sensor getDefaultSensor(int type, boolean wakeUp) { + return wakeUp ? null : getDefaultSensor(type); + } + + public List getSensorList(int type) { + List out = new ArrayList(); + for (int i = 0; i < ALL_TYPES.length; i++) { + if (type == Sensor.TYPE_ALL || type == ALL_TYPES[i]) { + Sensor s = getDefaultSensor(ALL_TYPES[i]); + if (s != null) { + out.add(s); + } + } + } + return out; + } + + public List getDynamicSensorList(int type) { + return new ArrayList(); + } + + public boolean isDynamicSensorDiscoverySupported() { + return false; + } + + // -------------------------------------------------------- registration + + public boolean registerListener(SensorEventListener listener, Sensor sensor, int samplingPeriodUs) { + return registerListener(listener, sensor, samplingPeriodUs, (Handler) null); + } + + public boolean registerListener(SensorEventListener listener, Sensor sensor, int samplingPeriodUs, + int maxReportLatencyUs) { + return registerListener(listener, sensor, samplingPeriodUs, (Handler) null); + } + + public boolean registerListener(SensorEventListener listener, Sensor sensor, int samplingPeriodUs, + int maxReportLatencyUs, Handler handler) { + return registerListener(listener, sensor, samplingPeriodUs, handler); + } + + public boolean registerListener(SensorEventListener listener, Sensor sensor, int samplingPeriodUs, + Handler handler) { + if (listener == null || sensor == null) { + return false; + } + for (int i = 0; i < registrations.size(); i++) { + Registration r = registrations.get(i); + if (r.listener == listener && r.sensor == sensor) { + // Android keeps the first registration of the pair. + return true; + } + } + int[] needs = sourcesOf(sensor.getType()); + if (needs == null) { + return false; + } + registrations.add(new Registration(listener, sensor, periodUs(samplingPeriodUs), handler)); + for (int i = 0; i < needs.length; i++) { + acquire(needs[i]); + } + retune(); + return true; + } + + public void unregisterListener(SensorEventListener listener) { + unregisterListener(listener, null); + } + + public void unregisterListener(SensorEventListener listener, Sensor sensor) { + for (int i = registrations.size() - 1; i >= 0; i--) { + Registration r = registrations.get(i); + if (r.listener == listener && (sensor == null || r.sensor == sensor)) { + registrations.remove(i); + int[] needs = sourcesOf(r.sensor.getType()); + for (int j = 0; j < needs.length; j++) { + release(needs[j]); + } + } + } + retune(); + } + + public boolean flush(SensorEventListener listener) { + return false; + } + + /// Android's SENSOR_DELAY_* constants or a period in microseconds. + static int periodUs(int samplingPeriodUs) { + switch (samplingPeriodUs) { + case SENSOR_DELAY_FASTEST: + return 0; + case SENSOR_DELAY_GAME: + return 20000; + case SENSOR_DELAY_UI: + return 66667; + case SENSOR_DELAY_NORMAL: + return 200000; + default: + return Math.max(0, samplingPeriodUs); + } + } + + private void retune() { + if (registrations.isEmpty()) { + return; + } + int min = Integer.MAX_VALUE; + for (int i = 0; i < registrations.size(); i++) { + min = Math.min(min, registrations.get(i).periodUs); + } + // Codename One samples in milliseconds; 10ms is as fast as it is asked to go. + platform().setSamplingInterval(Math.max(10, min / 1000)); + } + + private void acquire(int cn1Type) { + Source s = sources[cn1Type]; + if (s == null) { + s = new Source(cn1Type); + sources[cn1Type] = s; + } + if (s.users++ == 0) { + s.start(); + } + } + + private void release(int cn1Type) { + Source s = sources[cn1Type]; + if (s != null && s.users > 0 && --s.users == 0) { + s.stop(); + } + } + + // ------------------------------------------------------------ delivery + + /// A Codename One reading arrived; every registration computed from that + /// sensor gets an event. + void feed(int cn1Type, float x, float y, float z) { + Source src = sources[cn1Type]; + if (src == null) { + return; + } + src.values[0] = x; + src.values[1] = y; + src.values[2] = z; + src.has = true; + long now = android.os.SystemClock.elapsedRealtimeNanos(); + Registration[] snapshot = registrations.toArray(new Registration[registrations.size()]); + for (int i = 0; i < snapshot.length; i++) { + Registration r = snapshot[i]; + if (!registrations.contains(r)) { + // Unregistered by an earlier listener in this pass. + continue; + } + int[] needs = sourcesOf(r.sensor.getType()); + if (!contains(needs, cn1Type)) { + continue; + } + if (r.lastNanos != 0 && now - r.lastNanos < r.periodUs * 1000L) { + continue; + } + if (!compute(r.sensor.getType(), r.event.values)) { + continue; + } + r.lastNanos = now; + r.event.timestamp = now; + r.deliver(); + } + } + + private static boolean contains(int[] a, int v) { + for (int i = 0; i < a.length; i++) { + if (a[i] == v) { + return true; + } + } + return false; + } + + /// The values of one Android event, or false while a source has not + /// reported yet. + private boolean compute(int type, float[] out) { + switch (type) { + case Sensor.TYPE_ACCELEROMETER: + return copy(MotionSensorManager.TYPE_ACCELEROMETER, out); + case Sensor.TYPE_MAGNETIC_FIELD: + return copy(MotionSensorManager.TYPE_MAGNETOMETER, out); + case Sensor.TYPE_GYROSCOPE: + return copy(MotionSensorManager.TYPE_GYROSCOPE, out); + case Sensor.TYPE_GRAVITY: + return copy(MotionSensorManager.TYPE_GRAVITY, out); + case Sensor.TYPE_LINEAR_ACCELERATION: + return copy(MotionSensorManager.TYPE_LINEAR_ACCELERATION, out); + default: + break; + } + Source g = sources[MotionSensorManager.TYPE_GRAVITY]; + Source m = sources[MotionSensorManager.TYPE_MAGNETOMETER]; + if (g == null || m == null || !g.has || !m.has) { + return false; + } + if (!getRotationMatrix(matrix, null, g.values, m.values)) { + // Free fall, or next to a magnet: Android delivers nothing either. + return false; + } + if (type == Sensor.TYPE_ORIENTATION) { + getOrientation(matrix, scratch); + float azimuth = (float) Math.toDegrees(scratch[0]); + out[0] = azimuth < 0 ? azimuth + 360f : azimuth; + out[1] = (float) Math.toDegrees(scratch[1]); + out[2] = (float) Math.toDegrees(scratch[2]); + return true; + } + quaternionFromMatrix(matrix, out); + return true; + } + + private boolean copy(int cn1Type, float[] out) { + Source s = sources[cn1Type]; + if (s == null || !s.has) { + return false; + } + out[0] = s.values[0]; + out[1] = s.values[1]; + out[2] = s.values[2]; + return true; + } + + /// The rotation vector of a device-to-world rotation matrix: the unit + /// quaternion's x, y, z, its w (never negative, as Android's), and -1 + /// for the heading accuracy nobody estimated. Shepperd's method, so it + /// stays accurate near a half turn. + static void quaternionFromMatrix(float[] r, float[] out) { + double m00 = r[0], m01 = r[1], m02 = r[2]; + double m10 = r[3], m11 = r[4], m12 = r[5]; + double m20 = r[6], m21 = r[7], m22 = r[8]; + double trace = m00 + m11 + m22; + double w, x, y, z; + if (trace > 0) { + double s = Math.sqrt(trace + 1.0) * 2; + w = 0.25 * s; + x = (m21 - m12) / s; + y = (m02 - m20) / s; + z = (m10 - m01) / s; + } else if (m00 > m11 && m00 > m22) { + double s = Math.sqrt(1.0 + m00 - m11 - m22) * 2; + w = (m21 - m12) / s; + x = 0.25 * s; + y = (m01 + m10) / s; + z = (m02 + m20) / s; + } else if (m11 > m22) { + double s = Math.sqrt(1.0 + m11 - m00 - m22) * 2; + w = (m02 - m20) / s; + x = (m01 + m10) / s; + y = 0.25 * s; + z = (m12 + m21) / s; + } else { + double s = Math.sqrt(1.0 + m22 - m00 - m11) * 2; + w = (m10 - m01) / s; + x = (m02 + m20) / s; + y = (m12 + m21) / s; + z = 0.25 * s; + } + if (w < 0) { + w = -w; + x = -x; + y = -y; + z = -z; + } + out[0] = (float) x; + out[1] = (float) y; + out[2] = (float) z; + if (out.length > 3) { + out[3] = (float) w; + } + if (out.length > 4) { + out[4] = -1f; + } + } + + // ----------------------------------------------------------- static math + + /// Android's rotation and inclination matrices from a gravity and a + /// geomagnetic vector, both in device coordinates. `R` and `I` may be + /// 9 (3x3) or 16 (4x4) long, row major; either may be null. Answers false + /// in free fall or when the field is parallel to gravity. + public static boolean getRotationMatrix(float[] R, float[] I, float[] gravity, float[] geomagnetic) { + return SensorMath.getRotationMatrix(R, I, gravity, geomagnetic); + } + + /// The magnetic dip angle, in radians, of an inclination matrix. + public static float getInclination(float[] I) { + return SensorMath.getInclination(I); + } + + /// Azimuth, pitch and roll in radians from a rotation matrix: azimuth in + /// [-pi, pi], 0 at magnetic north. + public static float[] getOrientation(float[] R, float[] values) { + return SensorMath.getOrientation(R, values); + } + + /// The rotation matrix of a rotation vector (x, y, z[, w]). + public static void getRotationMatrixFromVector(float[] R, float[] rotationVector) { + SensorMath.getRotationMatrixFromVector(R, rotationVector); + } + + /// The unit quaternion (w, x, y, z) of a rotation vector. + public static void getQuaternionFromVector(float[] Q, float[] rv) { + SensorMath.getQuaternionFromVector(Q, rv); + } + + /// Re-expresses `inR` for a device whose `X` and `Y` axes lie along the + /// given world axes (the AXIS_* constants). + public static boolean remapCoordinateSystem(float[] inR, int X, int Y, float[] outR) { + return SensorMath.remapCoordinateSystem(inR, X, Y, outR); + } + + /// The angle change between two rotation matrices. + public static void getAngleChange(float[] angleChange, float[] R, float[] prevR) { + SensorMath.getAngleChange(angleChange, R, prevR); + } + + /// Altitude in meters from the pressure at sea level and here, in hPa. + public static float getAltitude(float p0, float p) { + return SensorMath.getAltitude(p0, p); + } + + // ------------------------------------------------------------- helpers + + /// One Codename One sensor, listened to while any registration needs it. + final class Source { + final int type; + final float[] values = new float[3]; + boolean has; + int users; + private MotionSensor sensor; + private final MotionSensorListener listener = new MotionSensorListener() { + @Override + public void motionReceived(MotionEvent evt) { + feed(type, evt.getX(), evt.getY(), evt.getZ()); + } + }; + + Source(int type) { + this.type = type; + } + + void start() { + has = false; + if (override == null || sensor == null) { + sensor = platform().getSensor(type); + } + if (sensor != null) { + sensor.addListener(listener); + } + } + + void stop() { + if (sensor != null) { + sensor.removeListener(listener); + } + has = false; + } + } + + final class Registration { + final SensorEventListener listener; + final Sensor sensor; + final int periodUs; + final Handler handler; + final SensorEvent event; + long lastNanos; + private boolean accuracyReported; + + Registration(SensorEventListener listener, Sensor sensor, int periodUs, Handler handler) { + this.listener = listener; + this.sensor = sensor; + this.periodUs = periodUs; + this.handler = handler; + int size = sensor.getType() == Sensor.TYPE_ROTATION_VECTOR + || sensor.getType() == Sensor.TYPE_GEOMAGNETIC_ROTATION_VECTOR ? 5 : 3; + event = new SensorEvent(size); + event.sensor = sensor; + event.accuracy = SENSOR_STATUS_ACCURACY_HIGH; + } + + void deliver() { + Runnable r = new Runnable() { + @Override + public void run() { + if (!registrations.contains(Registration.this)) { + return; + } + if (!accuracyReported) { + accuracyReported = true; + listener.onAccuracyChanged(sensor, event.accuracy); + } + listener.onSensorChanged(event); + } + }; + if (handler != null && !handler.getLooper().isCurrentThread()) { + handler.post(r); + } else { + r.run(); + } + } + } +} diff --git a/maven/android-compat/src/main/java/android/hardware/SensorMath.java b/maven/android-compat/src/main/java/android/hardware/SensorMath.java new file mode 100644 index 00000000000..cef24ca0599 --- /dev/null +++ b/maven/android-compat/src/main/java/android/hardware/SensorMath.java @@ -0,0 +1,259 @@ +/* + * Copyright (C) 2008 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * Modifications copyright (c) 2026, Codename One and/or its affiliates: + * rewritten from the static methods of android.hardware.SensorManager for the + * Codename One compatibility runtime (com.codename1.util.MathUtil in place of + * the trigonometry CLDC lacks). + */ +package android.hardware; + +import com.codename1.util.MathUtil; + +/// The sensor math of Android's `SensorManager`, so a compass computed with +/// `getRotationMatrix` and `getOrientation` reads the same numbers here as on +/// Android. [SensorManager] exposes it under Android's names. +final class SensorMath { + + private SensorMath() { + } + + + /// Android's rotation and inclination matrices from a gravity and a + /// geomagnetic vector, both in device coordinates. `R` and `I` may be + /// 9 (3x3) or 16 (4x4) long, row major; either may be null. Answers false + /// in free fall or when the field is parallel to gravity. + static boolean getRotationMatrix(float[] R, float[] I, float[] gravity, float[] geomagnetic) { + float ax = gravity[0], ay = gravity[1], az = gravity[2]; + final float normsqA = ax * ax + ay * ay + az * az; + final float g = 9.81f; + final float freeFallGravitySquared = 0.01f * g * g; + if (normsqA < freeFallGravitySquared) { + return false; + } + final float ex = geomagnetic[0], ey = geomagnetic[1], ez = geomagnetic[2]; + float hx = ey * az - ez * ay; + float hy = ez * ax - ex * az; + float hz = ex * ay - ey * ax; + final float normH = (float) Math.sqrt(hx * hx + hy * hy + hz * hz); + if (normH < 0.1f) { + return false; + } + final float invH = 1.0f / normH; + hx *= invH; + hy *= invH; + hz *= invH; + final float invA = 1.0f / (float) Math.sqrt(normsqA); + ax *= invA; + ay *= invA; + az *= invA; + final float mx = ay * hz - az * hy; + final float my = az * hx - ax * hz; + final float mz = ax * hy - ay * hx; + if (R != null) { + if (R.length == 9) { + R[0] = hx; R[1] = hy; R[2] = hz; + R[3] = mx; R[4] = my; R[5] = mz; + R[6] = ax; R[7] = ay; R[8] = az; + } else if (R.length == 16) { + R[0] = hx; R[1] = hy; R[2] = hz; R[3] = 0; + R[4] = mx; R[5] = my; R[6] = mz; R[7] = 0; + R[8] = ax; R[9] = ay; R[10] = az; R[11] = 0; + R[12] = 0; R[13] = 0; R[14] = 0; R[15] = 1; + } + } + if (I != null) { + final float invE = 1.0f / (float) Math.sqrt(ex * ex + ey * ey + ez * ez); + final float c = (ex * mx + ey * my + ez * mz) * invE; + final float s = (ex * ax + ey * ay + ez * az) * invE; + if (I.length == 9) { + I[0] = 1; I[1] = 0; I[2] = 0; + I[3] = 0; I[4] = c; I[5] = s; + I[6] = 0; I[7] = -s; I[8] = c; + } else if (I.length == 16) { + I[0] = 1; I[1] = 0; I[2] = 0; I[3] = 0; + I[4] = 0; I[5] = c; I[6] = s; I[7] = 0; + I[8] = 0; I[9] = -s; I[10] = c; I[11] = 0; + I[12] = 0; I[13] = 0; I[14] = 0; I[15] = 1; + } + } + return true; + } + + /// The magnetic dip angle, in radians, of an inclination matrix. + static float getInclination(float[] I) { + if (I.length == 9) { + return (float) MathUtil.atan2(I[5], I[4]); + } + return (float) MathUtil.atan2(I[6], I[5]); + } + + /// Azimuth, pitch and roll in radians from a rotation matrix, as Android + /// computes them: azimuth in [-pi, pi], 0 at magnetic north. + static float[] getOrientation(float[] R, float[] values) { + if (R.length == 9) { + values[0] = (float) MathUtil.atan2(R[1], R[4]); + values[1] = (float) MathUtil.asin(-R[7]); + values[2] = (float) MathUtil.atan2(-R[6], R[8]); + } else { + values[0] = (float) MathUtil.atan2(R[1], R[5]); + values[1] = (float) MathUtil.asin(-R[9]); + values[2] = (float) MathUtil.atan2(-R[8], R[10]); + } + return values; + } + + /// The rotation matrix of a rotation vector (x, y, z[, w]); w is derived + /// when the vector has only three components. + static void getRotationMatrixFromVector(float[] R, float[] rotationVector) { + float q0; + float q1 = rotationVector[0]; + float q2 = rotationVector[1]; + float q3 = rotationVector[2]; + if (rotationVector.length >= 4) { + q0 = rotationVector[3]; + } else { + q0 = 1 - q1 * q1 - q2 * q2 - q3 * q3; + q0 = q0 > 0 ? (float) Math.sqrt(q0) : 0; + } + float sqq1 = 2 * q1 * q1; + float sqq2 = 2 * q2 * q2; + float sqq3 = 2 * q3 * q3; + float q1q2 = 2 * q1 * q2; + float q3q0 = 2 * q3 * q0; + float q1q3 = 2 * q1 * q3; + float q2q0 = 2 * q2 * q0; + float q2q3 = 2 * q2 * q3; + float q1q0 = 2 * q1 * q0; + if (R.length == 9) { + R[0] = 1 - sqq2 - sqq3; R[1] = q1q2 - q3q0; R[2] = q1q3 + q2q0; + R[3] = q1q2 + q3q0; R[4] = 1 - sqq1 - sqq3; R[5] = q2q3 - q1q0; + R[6] = q1q3 - q2q0; R[7] = q2q3 + q1q0; R[8] = 1 - sqq1 - sqq2; + } else if (R.length == 16) { + R[0] = 1 - sqq2 - sqq3; R[1] = q1q2 - q3q0; R[2] = q1q3 + q2q0; R[3] = 0; + R[4] = q1q2 + q3q0; R[5] = 1 - sqq1 - sqq3; R[6] = q2q3 - q1q0; R[7] = 0; + R[8] = q1q3 - q2q0; R[9] = q2q3 + q1q0; R[10] = 1 - sqq1 - sqq2; R[11] = 0; + R[12] = 0; R[13] = 0; R[14] = 0; R[15] = 1; + } + } + + /// The unit quaternion (w, x, y, z) of a rotation vector. + static void getQuaternionFromVector(float[] Q, float[] rv) { + if (rv.length >= 4) { + Q[0] = rv[3]; + } else { + float w = 1 - rv[0] * rv[0] - rv[1] * rv[1] - rv[2] * rv[2]; + Q[0] = w > 0 ? (float) Math.sqrt(w) : 0; + } + Q[1] = rv[0]; + Q[2] = rv[1]; + Q[3] = rv[2]; + } + + /// Re-expresses `inR` for a device held so that its `X` axis lies along + /// world axis `X` and its `Y` along `Y` (the AXIS_* constants), the way + /// landscape compass and camera code calls it. + static boolean remapCoordinateSystem(float[] inR, int X, int Y, float[] outR) { + if (inR == outR) { + float[] temp = new float[outR.length]; + boolean ok = remap(inR, X, Y, temp); + if (ok) { + System.arraycopy(temp, 0, outR, 0, outR.length); + } + return ok; + } + return remap(inR, X, Y, outR); + } + + private static boolean remap(float[] inR, int X, int Y, float[] outR) { + if ((X & 0x7C) != 0 || (Y & 0x7C) != 0) { + return false; + } + if ((X & 0x3) == 0 || (Y & 0x3) == 0) { + return false; + } + if ((X & 0x3) == (Y & 0x3)) { + return false; + } + // Z is X cross Y; its sign follows from the axes and their signs. + int Z = X ^ Y; + final int x = (X & 0x3) - 1; + final int y = (Y & 0x3) - 1; + final int z = (Z & 0x3) - 1; + final int axisY = (z + 1) % 3; + final int axisZ = (z + 2) % 3; + if (((x ^ axisY) | (y ^ axisZ)) != 0) { + Z ^= 0x80; + } + final boolean sx = (X >= 0x80); + final boolean sy = (Y >= 0x80); + final boolean sz = (Z >= 0x80); + final int rowLength = (outR.length == 16) ? 4 : 3; + for (int j = 0; j < 3; j++) { + final int offset = j * rowLength; + for (int i = 0; i < 3; i++) { + if (x == i) { + outR[offset + i] = sx ? -inR[offset + 0] : inR[offset + 0]; + } + if (y == i) { + outR[offset + i] = sy ? -inR[offset + 1] : inR[offset + 1]; + } + if (z == i) { + outR[offset + i] = sz ? -inR[offset + 2] : inR[offset + 2]; + } + } + } + if (outR.length == 16) { + outR[3] = outR[7] = outR[11] = outR[12] = outR[13] = outR[14] = 0; + outR[15] = 1; + } + return true; + } + + /// The angle change between two rotation matrices, as Android defines it. + static void getAngleChange(float[] angleChange, float[] R, float[] prevR) { + float rd1, rd4, rd6, rd7, rd8; + float ri0, ri1, ri2, ri3, ri4, ri5, ri6, ri7, ri8; + float pri0, pri1, pri2, pri3, pri4, pri5, pri6, pri7, pri8; + if (R.length == 9) { + ri0 = R[0]; ri1 = R[1]; ri2 = R[2]; ri3 = R[3]; ri4 = R[4]; + ri5 = R[5]; ri6 = R[6]; ri7 = R[7]; ri8 = R[8]; + } else { + ri0 = R[0]; ri1 = R[1]; ri2 = R[2]; ri3 = R[4]; ri4 = R[5]; + ri5 = R[6]; ri6 = R[8]; ri7 = R[9]; ri8 = R[10]; + } + if (prevR.length == 9) { + pri0 = prevR[0]; pri1 = prevR[1]; pri2 = prevR[2]; pri3 = prevR[3]; pri4 = prevR[4]; + pri5 = prevR[5]; pri6 = prevR[6]; pri7 = prevR[7]; pri8 = prevR[8]; + } else { + pri0 = prevR[0]; pri1 = prevR[1]; pri2 = prevR[2]; pri3 = prevR[4]; pri4 = prevR[5]; + pri5 = prevR[6]; pri6 = prevR[8]; pri7 = prevR[9]; pri8 = prevR[10]; + } + rd1 = pri0 * ri1 + pri3 * ri4 + pri6 * ri7; + rd4 = pri1 * ri1 + pri4 * ri4 + pri7 * ri7; + rd6 = pri2 * ri0 + pri5 * ri3 + pri8 * ri6; + rd7 = pri2 * ri1 + pri5 * ri4 + pri8 * ri7; + rd8 = pri2 * ri2 + pri5 * ri5 + pri8 * ri8; + angleChange[0] = (float) MathUtil.atan2(rd1, rd4); + angleChange[1] = (float) MathUtil.asin(-rd7); + angleChange[2] = (float) MathUtil.atan2(-rd6, rd8); + } + + /// Altitude in meters from the pressure at sea level and here, in hPa. + static float getAltitude(float p0, float p) { + final float coef = 1.0f / 5.255f; + return 44330.0f * (1.0f - (float) MathUtil.pow(p / p0, coef)); + } +} diff --git a/maven/android-compat/src/main/java/android/location/Criteria.java b/maven/android-compat/src/main/java/android/location/Criteria.java new file mode 100644 index 00000000000..3ac78ca609e --- /dev/null +++ b/maven/android-compat/src/main/java/android/location/Criteria.java @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package android.location; + +/// What a caller wants from a provider; only the accuracy is consulted by +/// [LocationManager#getBestProvider(Criteria, boolean)]. +public class Criteria { + + public static final int NO_REQUIREMENT = 0; + public static final int POWER_LOW = 1; + public static final int POWER_MEDIUM = 2; + public static final int POWER_HIGH = 3; + public static final int ACCURACY_FINE = 1; + public static final int ACCURACY_COARSE = 2; + public static final int ACCURACY_LOW = 1; + public static final int ACCURACY_MEDIUM = 2; + public static final int ACCURACY_HIGH = 3; + + private int accuracy = NO_REQUIREMENT; + private int powerRequirement = NO_REQUIREMENT; + private int horizontalAccuracy = NO_REQUIREMENT; + private int verticalAccuracy = NO_REQUIREMENT; + private int speedAccuracy = NO_REQUIREMENT; + private int bearingAccuracy = NO_REQUIREMENT; + private boolean altitudeRequired; + private boolean bearingRequired; + private boolean speedRequired; + private boolean costAllowed = true; + + public Criteria() { + } + + public Criteria(Criteria c) { + accuracy = c.accuracy; + powerRequirement = c.powerRequirement; + horizontalAccuracy = c.horizontalAccuracy; + verticalAccuracy = c.verticalAccuracy; + speedAccuracy = c.speedAccuracy; + bearingAccuracy = c.bearingAccuracy; + altitudeRequired = c.altitudeRequired; + bearingRequired = c.bearingRequired; + speedRequired = c.speedRequired; + costAllowed = c.costAllowed; + } + + public int getAccuracy() { + return accuracy; + } + + public void setAccuracy(int accuracy) { + this.accuracy = accuracy; + } + + public int getPowerRequirement() { + return powerRequirement; + } + + public void setPowerRequirement(int level) { + powerRequirement = level; + } + + public int getHorizontalAccuracy() { + return horizontalAccuracy; + } + + public void setHorizontalAccuracy(int accuracy) { + horizontalAccuracy = accuracy; + } + + public int getVerticalAccuracy() { + return verticalAccuracy; + } + + public void setVerticalAccuracy(int accuracy) { + verticalAccuracy = accuracy; + } + + public int getSpeedAccuracy() { + return speedAccuracy; + } + + public void setSpeedAccuracy(int accuracy) { + speedAccuracy = accuracy; + } + + public int getBearingAccuracy() { + return bearingAccuracy; + } + + public void setBearingAccuracy(int accuracy) { + bearingAccuracy = accuracy; + } + + public boolean isAltitudeRequired() { + return altitudeRequired; + } + + public void setAltitudeRequired(boolean required) { + altitudeRequired = required; + } + + public boolean isBearingRequired() { + return bearingRequired; + } + + public void setBearingRequired(boolean required) { + bearingRequired = required; + } + + public boolean isSpeedRequired() { + return speedRequired; + } + + public void setSpeedRequired(boolean required) { + speedRequired = required; + } + + public boolean isCostAllowed() { + return costAllowed; + } + + public void setCostAllowed(boolean allowed) { + costAllowed = allowed; + } + + /// True when the caller asked for anything finer than coarse. + boolean wantsFine() { + return accuracy == ACCURACY_FINE || horizontalAccuracy == ACCURACY_HIGH + || powerRequirement == POWER_HIGH; + } +} diff --git a/maven/android-compat/src/main/java/android/location/Location.java b/maven/android-compat/src/main/java/android/location/Location.java new file mode 100644 index 00000000000..cdc7f597422 --- /dev/null +++ b/maven/android-compat/src/main/java/android/location/Location.java @@ -0,0 +1,283 @@ +/* + * 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 android.location; + +/// A geographic fix, as Android describes one. Fixes reported by +/// [LocationManager] are converted from `com.codename1.location.Location`; +/// the `has*` flags say which of the optional values the platform supplied. +public class Location { + + private static final double EARTH_RADIUS_METERS = 6371008.8; + + private String provider; + private double latitude; + private double longitude; + private double altitude; + private float accuracy; + private float bearing; + private float speed; + private long time; + private long elapsedRealtimeNanos; + private boolean hasAltitude; + private boolean hasAccuracy; + private boolean hasBearing; + private boolean hasSpeed; + private android.os.Bundle extras; + + public Location(String provider) { + this.provider = provider; + } + + public Location(Location l) { + set(l); + } + + public void set(Location l) { + provider = l.provider; + latitude = l.latitude; + longitude = l.longitude; + altitude = l.altitude; + accuracy = l.accuracy; + bearing = l.bearing; + speed = l.speed; + time = l.time; + elapsedRealtimeNanos = l.elapsedRealtimeNanos; + hasAltitude = l.hasAltitude; + hasAccuracy = l.hasAccuracy; + hasBearing = l.hasBearing; + hasSpeed = l.hasSpeed; + extras = l.extras == null ? null : new android.os.Bundle(l.extras); + } + + public void reset() { + set(new Location((String) null)); + } + + public String getProvider() { + return provider; + } + + public void setProvider(String provider) { + this.provider = provider; + } + + public double getLatitude() { + return latitude; + } + + public void setLatitude(double latitude) { + this.latitude = latitude; + } + + public double getLongitude() { + return longitude; + } + + public void setLongitude(double longitude) { + this.longitude = longitude; + } + + public boolean hasAltitude() { + return hasAltitude; + } + + public double getAltitude() { + return altitude; + } + + public void setAltitude(double altitude) { + this.altitude = altitude; + hasAltitude = true; + } + + public void removeAltitude() { + altitude = 0; + hasAltitude = false; + } + + public boolean hasAccuracy() { + return hasAccuracy; + } + + public float getAccuracy() { + return accuracy; + } + + public void setAccuracy(float accuracy) { + this.accuracy = accuracy; + hasAccuracy = true; + } + + public void removeAccuracy() { + accuracy = 0; + hasAccuracy = false; + } + + public boolean hasBearing() { + return hasBearing; + } + + public float getBearing() { + return bearing; + } + + /// Normalized into [0, 360), as Android does. + public void setBearing(float bearing) { + float b = bearing % 360f; + if (b < 0) { + b += 360f; + } + this.bearing = b; + hasBearing = true; + } + + public void removeBearing() { + bearing = 0; + hasBearing = false; + } + + public boolean hasSpeed() { + return hasSpeed; + } + + public float getSpeed() { + return speed; + } + + public void setSpeed(float speed) { + this.speed = speed; + hasSpeed = true; + } + + public void removeSpeed() { + speed = 0; + hasSpeed = false; + } + + public long getTime() { + return time; + } + + public void setTime(long time) { + this.time = time; + } + + public long getElapsedRealtimeNanos() { + return elapsedRealtimeNanos; + } + + public void setElapsedRealtimeNanos(long nanos) { + elapsedRealtimeNanos = nanos; + } + + public long getElapsedRealtimeMillis() { + return elapsedRealtimeNanos / 1000000L; + } + + public android.os.Bundle getExtras() { + return extras; + } + + public void setExtras(android.os.Bundle extras) { + this.extras = extras; + } + + /// No mock providers exist here. + public boolean isMock() { + return false; + } + + public boolean isFromMockProvider() { + return false; + } + + public float distanceTo(Location dest) { + float[] r = new float[2]; + computeDistanceAndBearing(latitude, longitude, dest.latitude, dest.longitude, r); + return r[0]; + } + + public float bearingTo(Location dest) { + float[] r = new float[2]; + computeDistanceAndBearing(latitude, longitude, dest.latitude, dest.longitude, r); + return r[1]; + } + + /// `results[0]` receives the distance in meters, `results[1]` (when the + /// array is long enough) the initial bearing and `results[2]` the final + /// bearing, in degrees east of true north. + /// + /// Android solves Vincenty's inverse problem on the WGS84 ellipsoid; this + /// uses the haversine formula on a sphere of the mean earth radius, which + /// differs from it by up to about 0.5%. That is well inside GPS error for + /// the distances a phone measures, but a survey-grade comparison against a + /// value Android computed will not match to the meter. + public static void distanceBetween(double startLatitude, double startLongitude, + double endLatitude, double endLongitude, float[] results) { + if (results == null || results.length < 1) { + throw new IllegalArgumentException("results is null or has length < 1"); + } + computeDistanceAndBearing(startLatitude, startLongitude, endLatitude, endLongitude, results); + } + + private static void computeDistanceAndBearing(double lat1, double lon1, double lat2, double lon2, + float[] results) { + double p1 = Math.toRadians(lat1); + double p2 = Math.toRadians(lat2); + double dp = p2 - p1; + double dl = Math.toRadians(lon2 - lon1); + double sinDp = Math.sin(dp / 2); + double sinDl = Math.sin(dl / 2); + double a = sinDp * sinDp + Math.cos(p1) * Math.cos(p2) * sinDl * sinDl; + double c = 2 * com.codename1.util.MathUtil.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + results[0] = (float) (EARTH_RADIUS_METERS * c); + if (results.length > 1) { + results[1] = initialBearing(p1, p2, dl); + } + if (results.length > 2) { + // The final bearing is the reverse course's initial bearing, turned around. + float back = initialBearing(p2, p1, -dl); + results[2] = (float) ((back + 180.0) % 360.0); + } + } + + private static float initialBearing(double p1, double p2, double dl) { + double y = Math.sin(dl) * Math.cos(p2); + double x = Math.cos(p1) * Math.sin(p2) - Math.sin(p1) * Math.cos(p2) * Math.cos(dl); + double deg = Math.toDegrees(com.codename1.util.MathUtil.atan2(y, x)); + // Android reports bearings in (-180, 180]. + if (deg <= -180.0) { + deg += 360.0; + } + return (float) deg; + } + + @Override + public String toString() { + return "Location[" + provider + " " + latitude + "," + longitude + + (hasAccuracy ? " hAcc=" + accuracy : "") + " et=" + getElapsedRealtimeMillis() + + (hasAltitude ? " alt=" + altitude : "") + + (hasSpeed ? " vel=" + speed : "") + + (hasBearing ? " bear=" + bearing : "") + "]"; + } +} diff --git a/maven/android-compat/src/main/java/android/location/LocationListener.java b/maven/android-compat/src/main/java/android/location/LocationListener.java new file mode 100644 index 00000000000..2582792f103 --- /dev/null +++ b/maven/android-compat/src/main/java/android/location/LocationListener.java @@ -0,0 +1,49 @@ +/* + * 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 android.location; + +/// Receives fixes from [LocationManager#requestLocationUpdates]. +public interface LocationListener { + + void onLocationChanged(Location location); + + default void onLocationChanged(java.util.List locations) { + for (int i = 0; i < locations.size(); i++) { + onLocationChanged(locations.get(i)); + } + } + + default void onFlushComplete(int requestCode) { + } + + default void onProviderEnabled(String provider) { + } + + default void onProviderDisabled(String provider) { + } + + /// Deprecated on Android and never called there since API 29; never + /// called here either. + default void onStatusChanged(String provider, int status, android.os.Bundle extras) { + } +} diff --git a/maven/android-compat/src/main/java/android/location/LocationManager.java b/maven/android-compat/src/main/java/android/location/LocationManager.java new file mode 100644 index 00000000000..f8bb10f485e --- /dev/null +++ b/maven/android-compat/src/main/java/android/location/LocationManager.java @@ -0,0 +1,548 @@ +/* + * 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 android.location; + +import android.os.CancellationSignal; +import android.os.Handler; +import android.os.Looper; +import com.codename1.androidcompat.runtime.CompatReport; +import com.codename1.androidcompat.runtime.MainThread; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executor; +import java.util.function.Consumer; + +/// Android's location service over `com.codename1.location.LocationManager`. +/// +/// Codename One has one location stream per application and one listener on +/// it, where Android has any number of registrations, each with its own +/// provider, interval and distance filter. This class owns the single +/// Codename One listener while at least one registration exists and fans each +/// fix out to every registration whose `minTimeMs` and `minDistanceM` it +/// passes. The stream is requested at the shortest interval and the finest +/// accuracy any registration asked for. +/// +/// Providers are names only: there is one platform stream, and `gps`, +/// `network` and `fused` registrations all receive it (with the accuracy the +/// platform reports). `passive` registrations receive it too, which is what +/// Android's passive provider does when another app is not involved. +/// +/// The `PendingIntent` overloads are not provided. Background delivery to a +/// killed application is a platform feature; on iOS keeping the stream alive +/// in the background needs the `ios.background_modes=location` build hint, +/// and a foreground [android.app.Service] keeps the application running on +/// Android. +/// +/// Registrations are kept on the main thread, which is Codename One's EDT; a +/// call from another thread is posted there rather than synchronized. +public class LocationManager { + + public static final String GPS_PROVIDER = "gps"; + public static final String NETWORK_PROVIDER = "network"; + public static final String PASSIVE_PROVIDER = "passive"; + public static final String FUSED_PROVIDER = "fused"; + public static final String KEY_LOCATION_CHANGED = "location"; + public static final String KEY_PROVIDER_ENABLED = "providerEnabled"; + public static final String KEY_STATUS_CHANGED = "status"; + public static final String PROVIDERS_CHANGED_ACTION = "android.location.PROVIDERS_CHANGED"; + public static final String MODE_CHANGED_ACTION = "android.location.MODE_CHANGED"; + + /// How long [#getCurrentLocation] waits for a fix before answering null, + /// as Android's does after roughly thirty seconds. + static final long CURRENT_LOCATION_TIMEOUT_MS = 30000L; + + /// The Codename One manager to use instead of the platform's; set by tests. + static com.codename1.location.LocationManager override; + + private static LocationManager instance; + + private final List registrations = new ArrayList(); + private boolean listening; + private long requestedInterval = -1; + private int requestedPriority = -1; + + private final com.codename1.location.LocationListener bridge = new com.codename1.location.LocationListener() { + @Override + public void locationUpdated(com.codename1.location.Location location) { + deliver(location); + } + + @Override + public void providerStateChanged(int newState) { + providerState(newState); + } + }; + + /// Context.getSystemService(LOCATION_SERVICE) answers one manager per + /// application, as Android does. + public static LocationManager getInstance() { + if (instance == null) { + instance = new LocationManager(); + } + return instance; + } + + LocationManager() { + } + + static com.codename1.location.LocationManager platform() { + if (override != null) { + return override; + } + return com.codename1.location.LocationManager.getLocationManager(); + } + + // ------------------------------------------------------------- providers + + public List getAllProviders() { + List out = new ArrayList(); + out.add(PASSIVE_PROVIDER); + out.add(GPS_PROVIDER); + out.add(NETWORK_PROVIDER); + out.add(FUSED_PROVIDER); + return out; + } + + public List getProviders(boolean enabledOnly) { + List out = new ArrayList(); + for (String p : getAllProviders()) { + if (!enabledOnly || isProviderEnabled(p)) { + out.add(p); + } + } + return out; + } + + public List getProviders(Criteria criteria, boolean enabledOnly) { + return getProviders(enabledOnly); + } + + public String getBestProvider(Criteria criteria, boolean enabledOnly) { + String best = criteria == null || criteria.wantsFine() ? GPS_PROVIDER : NETWORK_PROVIDER; + if (enabledOnly && !isProviderEnabled(best)) { + return isProviderEnabled(NETWORK_PROVIDER) ? NETWORK_PROVIDER : null; + } + return best; + } + + public boolean hasProvider(String provider) { + return getAllProviders().contains(provider); + } + + public boolean isProviderEnabled(String provider) { + if (!hasProvider(provider)) { + return false; + } + com.codename1.location.LocationManager lm = platform(); + if (lm == null) { + return false; + } + if (GPS_PROVIDER.equals(provider) && lm.isGPSDetectionSupported()) { + return lm.isGPSEnabled(); + } + return lm.getStatus() != com.codename1.location.LocationManager.OUT_OF_SERVICE; + } + + public boolean isLocationEnabled() { + return isProviderEnabled(GPS_PROVIDER) || isProviderEnabled(NETWORK_PROVIDER); + } + + // ------------------------------------------------------------ last fix + + public Location getLastKnownLocation(String provider) { + if (!hasProvider(provider)) { + throw new IllegalArgumentException("provider doesn't exist: " + provider); + } + com.codename1.location.LocationManager lm = platform(); + if (lm == null) { + return null; + } + com.codename1.location.Location l = lm.getLastKnownLocation(); + return l == null ? null : convert(l, provider); + } + + /// Answers the next fix, or null when none arrives within thirty + /// seconds or `cancellationSignal` is cancelled first (then nothing is + /// answered at all, as on Android). + public void getCurrentLocation(String provider, CancellationSignal cancellationSignal, + Executor executor, final Consumer consumer) { + if (consumer == null) { + throw new IllegalArgumentException("consumer is null"); + } + final Registration r = new Registration(provider, 0, 0, null, executor, null); + r.consumer = consumer; + r.oneShot = true; + if (cancellationSignal != null) { + cancellationSignal.setOnCancelListener(new CancellationSignal.OnCancelListener() { + @Override + public void onCancel() { + remove(r); + } + }); + } + add(r); + new Handler(Looper.getMainLooper()).postDelayed(new Runnable() { + @Override + public void run() { + if (registrations.contains(r)) { + remove(r); + r.dispatch(null); + } + } + }, CURRENT_LOCATION_TIMEOUT_MS); + } + + public void requestSingleUpdate(String provider, LocationListener listener, Looper looper) { + Registration r = new Registration(provider, 0, 0, listener, null, looper); + r.oneShot = true; + add(r); + } + + public void requestSingleUpdate(Criteria criteria, LocationListener listener, Looper looper) { + requestSingleUpdate(getBestProvider(criteria, false), listener, looper); + } + + // -------------------------------------------------------------- updates + + public void requestLocationUpdates(String provider, long minTimeMs, float minDistanceM, + LocationListener listener) { + requestLocationUpdates(provider, minTimeMs, minDistanceM, listener, (Looper) null); + } + + public void requestLocationUpdates(String provider, long minTimeMs, float minDistanceM, + LocationListener listener, Looper looper) { + if (listener == null) { + throw new IllegalArgumentException("invalid null listener"); + } + if (!hasProvider(provider)) { + throw new IllegalArgumentException("provider doesn't exist: " + provider); + } + replace(new Registration(provider, minTimeMs, minDistanceM, listener, null, looper)); + } + + public void requestLocationUpdates(String provider, long minTimeMs, float minDistanceM, + Executor executor, LocationListener listener) { + if (listener == null || executor == null) { + throw new IllegalArgumentException("invalid null listener or executor"); + } + if (!hasProvider(provider)) { + throw new IllegalArgumentException("provider doesn't exist: " + provider); + } + replace(new Registration(provider, minTimeMs, minDistanceM, listener, executor, null)); + } + + public void requestLocationUpdates(long minTimeMs, float minDistanceM, Criteria criteria, + LocationListener listener, Looper looper) { + requestLocationUpdates(getBestProvider(criteria, false), minTimeMs, minDistanceM, listener, looper); + } + + public void removeUpdates(final LocationListener listener) { + onMain(new Runnable() { + @Override + public void run() { + for (int i = registrations.size() - 1; i >= 0; i--) { + if (registrations.get(i).listener == listener) { + registrations.remove(i).removed.set(true); + } + } + reconfigure(); + } + }); + } + + /// Codename One keeps no per-provider queue to flush; completion is + /// reported straight away. + public void requestFlush(String provider, final LocationListener listener, final int requestCode) { + onMain(new ProviderCall(listener, null, requestCode)); + } + + /// One listener callback, posted where the registration wants it. + private static final class ProviderCall implements Runnable { + private final LocationListener listener; + private final String provider; + private final int code; + + /// `provider` null means a flush with request `code`; otherwise + /// `code` is 1 for enabled and 0 for disabled. + ProviderCall(LocationListener listener, String provider, int code) { + this.listener = listener; + this.provider = provider; + this.code = code; + } + + @Override + public void run() { + if (provider == null) { + listener.onFlushComplete(code); + } else if (code == 1) { + listener.onProviderEnabled(provider); + } else { + listener.onProviderDisabled(provider); + } + } + } + + /// Geofences through a `PendingIntent` have no counterpart that does not + /// need reflection; use `com.codename1.location.GeofenceManager`. + public void addProximityAlert(double latitude, double longitude, float radius, long expiration, + Object intent) { + CompatReport.unsupported("LocationManager", "addProximityAlert"); + } + + // -------------------------------------------------------- registrations + + private void replace(final Registration r) { + onMain(new Runnable() { + @Override + public void run() { + // Android replaces an existing registration of the same listener. + for (int i = registrations.size() - 1; i >= 0; i--) { + if (registrations.get(i).listener == r.listener) { + registrations.remove(i).removed.set(true); + } + } + registrations.add(r); + reconfigure(); + } + }); + } + + private void add(final Registration r) { + onMain(new Runnable() { + @Override + public void run() { + registrations.add(r); + reconfigure(); + } + }); + } + + private void remove(final Registration r) { + onMain(new Runnable() { + @Override + public void run() { + registrations.remove(r); + r.removed.set(true); + reconfigure(); + } + }); + } + + private static void onMain(Runnable r) { + if (MainThread.isMainThread()) { + r.run(); + } else { + MainThread.post(r); + } + } + + /// Installs, retunes or removes the one Codename One listener so it + /// matches what the registrations ask for. + private void reconfigure() { + com.codename1.location.LocationManager lm = platform(); + if (lm == null) { + return; + } + if (registrations.isEmpty()) { + if (listening) { + lm.setLocationListener(null); + listening = false; + requestedInterval = -1; + requestedPriority = -1; + } + return; + } + long interval = Long.MAX_VALUE; + int priority = com.codename1.location.LocationRequest.PRIORITY_LOW_ACCUARCY; + for (int i = 0; i < registrations.size(); i++) { + Registration r = registrations.get(i); + interval = Math.min(interval, r.minTime); + priority = Math.min(priority, r.priority()); + } + if (listening && interval == requestedInterval && priority == requestedPriority) { + return; + } + // Codename One's request takes an int interval; a minTime of zero + // asks for fixes as fast as the platform gives them. + int cn1Interval = (int) Math.min(Integer.MAX_VALUE, Math.max(0, interval)); + lm.setLocationListener(bridge, new com.codename1.location.LocationRequest(priority, cn1Interval)); + listening = true; + requestedInterval = interval; + requestedPriority = priority; + } + + void deliver(com.codename1.location.Location fix) { + if (fix == null) { + return; + } + // Copied first: a one-shot registration removes itself while the + // list is being walked, and a listener may register or remove others. + Registration[] snapshot = registrations.toArray(new Registration[registrations.size()]); + boolean removed = false; + for (int i = 0; i < snapshot.length; i++) { + Registration r = snapshot[i]; + if (!registrations.contains(r)) { + // Removed by an earlier listener's callback in this pass. + continue; + } + Location l = convert(fix, r.provider); + if (!r.accepts(l)) { + continue; + } + if (r.oneShot) { + // Its one fix is the one dispatched below, so it is not + // flagged removed. + registrations.remove(r); + removed = true; + } + r.dispatch(l); + } + if (removed) { + reconfigure(); + } + } + + void providerState(int state) { + Registration[] snapshot = registrations.toArray(new Registration[registrations.size()]); + for (int i = 0; i < snapshot.length; i++) { + final Registration r = snapshot[i]; + if (!registrations.contains(r)) { + continue; + } + if (r.listener == null) { + continue; + } + if (state == com.codename1.location.LocationManager.OUT_OF_SERVICE) { + r.run(new ProviderCall(r.listener, r.provider, 0)); + } else if (state == com.codename1.location.LocationManager.AVAILABLE) { + r.run(new ProviderCall(r.listener, r.provider, 1)); + } + } + } + + /// The Codename One fix as an Android one. Codename One reports zero for + /// a value the platform did not supply, so a zero accuracy, speed or + /// direction is left unset rather than reported as a measurement. + static Location convert(com.codename1.location.Location src, String provider) { + Location l = new Location(provider); + l.setLatitude(src.getLatitude()); + l.setLongitude(src.getLongitude()); + if (src.getAltitude() != 0) { + l.setAltitude(src.getAltitude()); + } + if (src.getAccuracy() > 0) { + l.setAccuracy(src.getAccuracy()); + } + if (src.getVelocity() > 0) { + l.setSpeed(src.getVelocity()); + } + if (src.getDirection() > 0) { + l.setBearing(src.getDirection()); + } + long ts = src.getTimeStamp(); + l.setTime(ts > 0 ? ts : System.currentTimeMillis()); + l.setElapsedRealtimeNanos(android.os.SystemClock.elapsedRealtimeNanos()); + return l; + } + + /// One `requestLocationUpdates` (or one-shot request). + static final class Registration { + /// Set when the registration is removed; a fix already posted to + /// its looper or executor is then dropped, so a removed listener + /// hears nothing more. + final java.util.concurrent.atomic.AtomicBoolean removed = new java.util.concurrent.atomic.AtomicBoolean(); + final String provider; + final long minTime; + final float minDistance; + final LocationListener listener; + final Executor executor; + final Looper looper; + Consumer consumer; + boolean oneShot; + private Location last; + + Registration(String provider, long minTime, float minDistance, LocationListener listener, + Executor executor, Looper looper) { + this.provider = provider; + this.minTime = Math.max(0, minTime); + this.minDistance = Math.max(0, minDistance); + this.listener = listener; + this.executor = executor; + this.looper = looper; + } + + int priority() { + if (NETWORK_PROVIDER.equals(provider)) { + return com.codename1.location.LocationRequest.PRIORITY_MEDIUM_ACCUARCY; + } + if (PASSIVE_PROVIDER.equals(provider)) { + return com.codename1.location.LocationRequest.PRIORITY_LOW_ACCUARCY; + } + return com.codename1.location.LocationRequest.PRIORITY_HIGH_ACCUARCY; + } + + /// Android's filter: at least `minTime` since the last fix this + /// registration was given, and at least `minDistance` from it. + boolean accepts(Location l) { + if (last == null) { + last = l; + return true; + } + if (l.getTime() - last.getTime() < minTime) { + return false; + } + if (minDistance > 0 && last.distanceTo(l) < minDistance) { + return false; + } + last = l; + return true; + } + + void dispatch(final Location l) { + run(new Runnable() { + @Override + public void run() { + if (consumer == null && removed.get()) { + return; + } + if (consumer != null) { + consumer.accept(l); + } else if (l != null) { + listener.onLocationChanged(l); + } + } + }); + } + + void run(Runnable r) { + if (executor != null) { + executor.execute(r); + } else if (looper != null && !looper.isCurrentThread()) { + new Handler(looper).post(r); + } else { + r.run(); + } + } + } +} diff --git a/maven/android-compat/src/main/java/android/os/Binder.java b/maven/android-compat/src/main/java/android/os/Binder.java new file mode 100644 index 00000000000..ef4d77bc176 --- /dev/null +++ b/maven/android-compat/src/main/java/android/os/Binder.java @@ -0,0 +1,82 @@ +/* + * 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 android.os; + +/// A local binder. Subclass it and return the service from a method of your +/// own, as Android's bound-service guide does. +public class Binder implements IBinder { + + private String descriptor; + + public Binder() { + } + + public Binder(String descriptor) { + this.descriptor = descriptor; + } + + public void attachInterface(Object owner, String descriptor) { + this.descriptor = descriptor; + } + + @Override + public String getInterfaceDescriptor() { + return descriptor; + } + + @Override + public boolean pingBinder() { + return true; + } + + @Override + public boolean isBinderAlive() { + return true; + } + + @Override + public void linkToDeath(DeathRecipient recipient, int flags) { + } + + @Override + public boolean unlinkToDeath(DeathRecipient recipient, int flags) { + return true; + } + + /// There is one process and no other caller; Android answers the + /// caller's own ids for a local call, and these stand in for them. + public static int getCallingPid() { + return 1; + } + + public static int getCallingUid() { + return 10000; + } + + public static long clearCallingIdentity() { + return 0; + } + + public static void restoreCallingIdentity(long token) { + } +} diff --git a/maven/android-compat/src/main/java/android/os/IBinder.java b/maven/android-compat/src/main/java/android/os/IBinder.java new file mode 100644 index 00000000000..2389bc4deb8 --- /dev/null +++ b/maven/android-compat/src/main/java/android/os/IBinder.java @@ -0,0 +1,49 @@ +/* + * 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 android.os; + +/// What `Service.onBind` answers. Within one process, as here, a client +/// casts it back to the service's own `Binder` subclass, which is the local +/// binder pattern Android documents; there is no cross-process transaction. +public interface IBinder { + + int FIRST_CALL_TRANSACTION = 0x00000001; + int LAST_CALL_TRANSACTION = 0x00ffffff; + int FLAG_ONEWAY = 0x00000001; + + /// Notified when the binder's process dies, which never happens apart + /// from the application's own. + interface DeathRecipient { + void binderDied(); + } + + String getInterfaceDescriptor(); + + boolean pingBinder(); + + boolean isBinderAlive(); + + void linkToDeath(DeathRecipient recipient, int flags); + + boolean unlinkToDeath(DeathRecipient recipient, int flags); +} diff --git a/maven/android-compat/src/main/java/android/security/keystore/KeyGenParameterSpec.java b/maven/android-compat/src/main/java/android/security/keystore/KeyGenParameterSpec.java new file mode 100644 index 00000000000..e9f5639f4a9 --- /dev/null +++ b/maven/android-compat/src/main/java/android/security/keystore/KeyGenParameterSpec.java @@ -0,0 +1,204 @@ +/* + * 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 android.security.keystore; + +/// Android's `KeyGenParameterSpec`: what `KeyGenerator.init` is told about a +/// key that lives in the `AndroidKeyStore`. +/// +/// It implements the JDK's `AlgorithmParameterSpec` by that name, because the +/// application passes it to `javax.crypto.KeyGenerator`; the remap step points +/// both at the runtime's shim. The user-authentication settings are recorded +/// and answered back but never enforced -- see +/// `com.codename1.androidcompat.jdk.KeyStore` for where keys are kept. +public final class KeyGenParameterSpec implements java.security.spec.AlgorithmParameterSpec { + private final String keystoreAlias; + private final int purposes; + private final int keySize; + private final String[] blockModes; + private final String[] encryptionPaddings; + private final String[] digests; + private final boolean randomizedEncryptionRequired; + private final boolean userAuthenticationRequired; + private final int userAuthenticationValidityDurationSeconds; + private final boolean strongBoxBacked; + private final boolean invalidatedByBiometricEnrollment; + + KeyGenParameterSpec(Builder b) { + keystoreAlias = b.keystoreAlias; + purposes = b.purposes; + keySize = b.keySize; + blockModes = copy(b.blockModes); + encryptionPaddings = copy(b.encryptionPaddings); + digests = copy(b.digests); + randomizedEncryptionRequired = b.randomizedEncryptionRequired; + userAuthenticationRequired = b.userAuthenticationRequired; + userAuthenticationValidityDurationSeconds = b.userAuthenticationValidityDurationSeconds; + strongBoxBacked = b.strongBoxBacked; + invalidatedByBiometricEnrollment = b.invalidatedByBiometricEnrollment; + } + + static String[] copy(String[] a) { + if (a == null) { + return new String[0]; + } + String[] out = new String[a.length]; + System.arraycopy(a, 0, out, 0, a.length); + return out; + } + + public String getKeystoreAlias() { + return keystoreAlias; + } + + public int getPurposes() { + return purposes; + } + + /// The requested size in bits, or -1 when the spec does not say. + public int getKeySize() { + return keySize; + } + + public String[] getBlockModes() { + return copy(blockModes); + } + + public String[] getEncryptionPaddings() { + return copy(encryptionPaddings); + } + + public String[] getDigests() { + return copy(digests); + } + + public boolean isDigestsSpecified() { + return digests.length > 0; + } + + public boolean isRandomizedEncryptionRequired() { + return randomizedEncryptionRequired; + } + + public boolean isUserAuthenticationRequired() { + return userAuthenticationRequired; + } + + public int getUserAuthenticationValidityDurationSeconds() { + return userAuthenticationValidityDurationSeconds; + } + + public boolean isStrongBoxBacked() { + return strongBoxBacked; + } + + public boolean isInvalidatedByBiometricEnrollment() { + return invalidatedByBiometricEnrollment; + } + + /// Android's `KeyGenParameterSpec.Builder`. + public static final class Builder { + private final String keystoreAlias; + private final int purposes; + private int keySize = -1; + private String[] blockModes; + private String[] encryptionPaddings; + private String[] digests; + private boolean randomizedEncryptionRequired = true; + private boolean userAuthenticationRequired; + private int userAuthenticationValidityDurationSeconds; + private boolean strongBoxBacked; + private boolean invalidatedByBiometricEnrollment = true; + + public Builder(String keystoreAlias, int purposes) { + if (keystoreAlias == null) { + throw new NullPointerException("keystoreAlias == null"); + } + if (keystoreAlias.length() == 0) { + throw new IllegalArgumentException("keystoreAlias must not be empty"); + } + this.keystoreAlias = keystoreAlias; + this.purposes = purposes; + } + + public Builder setKeySize(int keySize) { + if (keySize < 0) { + throw new IllegalArgumentException("keySize < 0"); + } + this.keySize = keySize; + return this; + } + + public Builder setBlockModes(String... blockModes) { + this.blockModes = copy(blockModes); + return this; + } + + public Builder setEncryptionPaddings(String... paddings) { + this.encryptionPaddings = copy(paddings); + return this; + } + + public Builder setDigests(String... digests) { + this.digests = copy(digests); + return this; + } + + public Builder setRandomizedEncryptionRequired(boolean required) { + this.randomizedEncryptionRequired = required; + return this; + } + + public Builder setUserAuthenticationRequired(boolean required) { + this.userAuthenticationRequired = required; + return this; + } + + public Builder setUserAuthenticationValidityDurationSeconds(int seconds) { + this.userAuthenticationValidityDurationSeconds = seconds; + return this; + } + + public Builder setUserAuthenticationParameters(int timeoutSeconds, int type) { + this.userAuthenticationValidityDurationSeconds = timeoutSeconds; + return this; + } + + public Builder setIsStrongBoxBacked(boolean strongBox) { + this.strongBoxBacked = strongBox; + return this; + } + + public Builder setInvalidatedByBiometricEnrollment(boolean invalidate) { + this.invalidatedByBiometricEnrollment = invalidate; + return this; + } + + public Builder setUnlockedDeviceRequired(boolean required) { + return this; + } + + public KeyGenParameterSpec build() { + return new KeyGenParameterSpec(this); + } + } +} diff --git a/maven/android-compat/src/main/java/android/security/keystore/KeyProperties.java b/maven/android-compat/src/main/java/android/security/keystore/KeyProperties.java new file mode 100644 index 00000000000..aa755a0b51a --- /dev/null +++ b/maven/android-compat/src/main/java/android/security/keystore/KeyProperties.java @@ -0,0 +1,66 @@ +/* + * 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 android.security.keystore; + +/// Android's `KeyProperties`: the names a key generation spec and a cipher +/// transformation are spelled with. +public abstract class KeyProperties { + public static final int PURPOSE_ENCRYPT = 1; + public static final int PURPOSE_DECRYPT = 2; + public static final int PURPOSE_SIGN = 4; + public static final int PURPOSE_VERIFY = 8; + public static final int PURPOSE_WRAP_KEY = 32; + public static final int PURPOSE_AGREE_KEY = 64; + public static final int PURPOSE_ATTEST_KEY = 128; + + public static final String KEY_ALGORITHM_AES = "AES"; + public static final String KEY_ALGORITHM_HMAC_SHA256 = "HmacSHA256"; + public static final String KEY_ALGORITHM_HMAC_SHA512 = "HmacSHA512"; + public static final String KEY_ALGORITHM_RSA = "RSA"; + public static final String KEY_ALGORITHM_EC = "EC"; + + public static final String BLOCK_MODE_ECB = "ECB"; + public static final String BLOCK_MODE_CBC = "CBC"; + public static final String BLOCK_MODE_CTR = "CTR"; + public static final String BLOCK_MODE_GCM = "GCM"; + + public static final String ENCRYPTION_PADDING_NONE = "NoPadding"; + public static final String ENCRYPTION_PADDING_PKCS7 = "PKCS7Padding"; + public static final String ENCRYPTION_PADDING_RSA_PKCS1 = "PKCS1Padding"; + public static final String ENCRYPTION_PADDING_RSA_OAEP = "OAEPPadding"; + + public static final String DIGEST_NONE = "NONE"; + public static final String DIGEST_SHA256 = "SHA-256"; + public static final String DIGEST_SHA512 = "SHA-512"; + + public static final int AUTH_DEVICE_CREDENTIAL = 1; + public static final int AUTH_BIOMETRIC_STRONG = 2; + + public static final int SECURITY_LEVEL_UNKNOWN = -2; + public static final int SECURITY_LEVEL_SOFTWARE = 0; + public static final int SECURITY_LEVEL_TRUSTED_ENVIRONMENT = 1; + public static final int SECURITY_LEVEL_STRONGBOX = 2; + + private KeyProperties() { + } +} diff --git a/maven/android-compat/src/main/java/androidx/core/app/NotificationCompat.java b/maven/android-compat/src/main/java/androidx/core/app/NotificationCompat.java new file mode 100644 index 00000000000..08af9482b8a --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/core/app/NotificationCompat.java @@ -0,0 +1,347 @@ +/* + * 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 androidx.core.app; + +import android.app.Notification; +import android.app.PendingIntent; +import android.content.Context; +import android.os.Bundle; + +/// AndroidX's notification builder, over the platform one: every setter +/// passes straight through, so what [Notification] carries over to a Codename +/// One local notification is the same either way. +public class NotificationCompat { + + public static final int PRIORITY_DEFAULT = Notification.PRIORITY_DEFAULT; + public static final int PRIORITY_LOW = Notification.PRIORITY_LOW; + public static final int PRIORITY_MIN = Notification.PRIORITY_MIN; + public static final int PRIORITY_HIGH = Notification.PRIORITY_HIGH; + public static final int PRIORITY_MAX = Notification.PRIORITY_MAX; + public static final int VISIBILITY_PUBLIC = Notification.VISIBILITY_PUBLIC; + public static final int VISIBILITY_PRIVATE = Notification.VISIBILITY_PRIVATE; + public static final int VISIBILITY_SECRET = Notification.VISIBILITY_SECRET; + public static final int DEFAULT_ALL = Notification.DEFAULT_ALL; + public static final int DEFAULT_SOUND = Notification.DEFAULT_SOUND; + public static final int DEFAULT_VIBRATE = Notification.DEFAULT_VIBRATE; + public static final int DEFAULT_LIGHTS = Notification.DEFAULT_LIGHTS; + public static final int FLAG_ONGOING_EVENT = Notification.FLAG_ONGOING_EVENT; + public static final int FLAG_AUTO_CANCEL = Notification.FLAG_AUTO_CANCEL; + public static final int FLAG_FOREGROUND_SERVICE = Notification.FLAG_FOREGROUND_SERVICE; + public static final int FOREGROUND_SERVICE_DEFAULT = Notification.FOREGROUND_SERVICE_DEFAULT; + public static final int FOREGROUND_SERVICE_IMMEDIATE = Notification.FOREGROUND_SERVICE_IMMEDIATE; + public static final int FOREGROUND_SERVICE_DEFERRED = Notification.FOREGROUND_SERVICE_DEFERRED; + public static final String CATEGORY_SERVICE = Notification.CATEGORY_SERVICE; + public static final String CATEGORY_PROGRESS = Notification.CATEGORY_PROGRESS; + public static final String CATEGORY_NAVIGATION = Notification.CATEGORY_NAVIGATION; + public static final String CATEGORY_MESSAGE = Notification.CATEGORY_MESSAGE; + public static final String CATEGORY_ALARM = Notification.CATEGORY_ALARM; + public static final String CATEGORY_REMINDER = Notification.CATEGORY_REMINDER; + public static final String CATEGORY_STATUS = Notification.CATEGORY_STATUS; + public static final String CATEGORY_ERROR = Notification.CATEGORY_ERROR; + public static final String CATEGORY_LOCATION_SHARING = Notification.CATEGORY_LOCATION_SHARING; + public static final String EXTRA_TITLE = Notification.EXTRA_TITLE; + public static final String EXTRA_TEXT = Notification.EXTRA_TEXT; + + public static Bundle getExtras(Notification notification) { + return notification.extras; + } + + public static class Action extends Notification.Action { + public Action(int icon, CharSequence title, PendingIntent intent) { + super(icon, title, intent); + } + } + + public abstract static class Style { + Notification.Style platform; + + public void setBuilder(Builder builder) { + if (builder != null) { + builder.setStyle(this); + } + } + } + + public static class BigTextStyle extends Style { + private final Notification.BigTextStyle style = new Notification.BigTextStyle(); + + public BigTextStyle() { + platform = style; + } + + public BigTextStyle(Builder builder) { + this(); + setBuilder(builder); + } + + public BigTextStyle bigText(CharSequence cs) { + style.bigText(cs); + return this; + } + + public BigTextStyle setBigContentTitle(CharSequence title) { + style.setBigContentTitle(title); + return this; + } + + public BigTextStyle setSummaryText(CharSequence cs) { + return this; + } + } + + public static class InboxStyle extends Style { + private final Notification.InboxStyle style = new Notification.InboxStyle(); + + public InboxStyle() { + platform = style; + } + + public InboxStyle(Builder builder) { + this(); + setBuilder(builder); + } + + public InboxStyle addLine(CharSequence cs) { + style.addLine(cs); + return this; + } + + public InboxStyle setBigContentTitle(CharSequence title) { + return this; + } + + public InboxStyle setSummaryText(CharSequence cs) { + return this; + } + } + + public static class Builder { + private final Notification.Builder b; + + public Builder(Context context, String channelId) { + b = new Notification.Builder(context, channelId); + } + + /// Deprecated in AndroidX: no channel. + public Builder(Context context) { + b = new Notification.Builder(context); + } + + public Builder setContentTitle(CharSequence title) { + b.setContentTitle(title); + return this; + } + + public Builder setContentText(CharSequence text) { + b.setContentText(text); + return this; + } + + public Builder setSubText(CharSequence text) { + b.setSubText(text); + return this; + } + + public Builder setSmallIcon(int icon) { + b.setSmallIcon(icon); + return this; + } + + public Builder setLargeIcon(android.graphics.Bitmap icon) { + b.setLargeIcon(icon); + return this; + } + + public Builder setTicker(CharSequence text) { + b.setTicker(text); + return this; + } + + public Builder setWhen(long when) { + b.setWhen(when); + return this; + } + + public Builder setShowWhen(boolean show) { + b.setShowWhen(show); + return this; + } + + public Builder setUsesChronometer(boolean u) { + b.setUsesChronometer(u); + return this; + } + + public Builder setNumber(int number) { + b.setNumber(number); + return this; + } + + public Builder setContentIntent(PendingIntent intent) { + b.setContentIntent(intent); + return this; + } + + public Builder setDeleteIntent(PendingIntent intent) { + b.setDeleteIntent(intent); + return this; + } + + public Builder setFullScreenIntent(PendingIntent intent, boolean highPriority) { + b.setFullScreenIntent(intent, highPriority); + return this; + } + + public Builder setOngoing(boolean ongoing) { + b.setOngoing(ongoing); + return this; + } + + public Builder setAutoCancel(boolean autoCancel) { + b.setAutoCancel(autoCancel); + return this; + } + + public Builder setOnlyAlertOnce(boolean onlyAlertOnce) { + b.setOnlyAlertOnce(onlyAlertOnce); + return this; + } + + public Builder setSilent(boolean silent) { + return this; + } + + public Builder setPriority(int pri) { + b.setPriority(pri); + return this; + } + + public Builder setCategory(String category) { + b.setCategory(category); + return this; + } + + public Builder setVisibility(int visibility) { + b.setVisibility(visibility); + return this; + } + + public Builder setColor(int argb) { + b.setColor(argb); + return this; + } + + public Builder setColorized(boolean colorize) { + b.setColorized(colorize); + return this; + } + + public Builder setDefaults(int defaults) { + b.setDefaults(defaults); + return this; + } + + public Builder setSound(android.net.Uri sound) { + b.setSound(sound); + return this; + } + + public Builder setVibrate(long[] pattern) { + b.setVibrate(pattern); + return this; + } + + public Builder setLights(int argb, int onMs, int offMs) { + b.setLights(argb, onMs, offMs); + return this; + } + + public Builder setGroup(String groupKey) { + b.setGroup(groupKey); + return this; + } + + public Builder setGroupSummary(boolean isGroupSummary) { + b.setGroupSummary(isGroupSummary); + return this; + } + + public Builder setSortKey(String sortKey) { + b.setSortKey(sortKey); + return this; + } + + public Builder setChannelId(String channelId) { + b.setChannelId(channelId); + return this; + } + + public Builder setTimeoutAfter(long durationMs) { + b.setTimeoutAfter(durationMs); + return this; + } + + public Builder setProgress(int max, int progress, boolean indeterminate) { + b.setProgress(max, progress, indeterminate); + return this; + } + + public Builder setForegroundServiceBehavior(int behavior) { + b.setForegroundServiceBehavior(behavior); + return this; + } + + public Builder setLocalOnly(boolean localOnly) { + b.setLocalOnly(localOnly); + return this; + } + + public Builder setStyle(Style style) { + b.setStyle(style == null ? null : style.platform); + return this; + } + + public Builder addAction(int icon, CharSequence title, PendingIntent intent) { + b.addAction(icon, title, intent); + return this; + } + + public Builder addAction(Action action) { + b.addAction(action); + return this; + } + + public Builder setExtras(Bundle extras) { + b.setExtras(extras); + return this; + } + + public Bundle getExtras() { + return b.getExtras(); + } + + public Notification build() { + return b.build(); + } + } +} diff --git a/maven/android-compat/src/main/java/androidx/core/app/NotificationManagerCompat.java b/maven/android-compat/src/main/java/androidx/core/app/NotificationManagerCompat.java new file mode 100644 index 00000000000..3bf2898b90a --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/core/app/NotificationManagerCompat.java @@ -0,0 +1,91 @@ +/* + * 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 androidx.core.app; + +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Context; + +import java.util.List; + +public final class NotificationManagerCompat { + + public static final int IMPORTANCE_UNSPECIFIED = NotificationManager.IMPORTANCE_UNSPECIFIED; + public static final int IMPORTANCE_NONE = NotificationManager.IMPORTANCE_NONE; + public static final int IMPORTANCE_MIN = NotificationManager.IMPORTANCE_MIN; + public static final int IMPORTANCE_LOW = NotificationManager.IMPORTANCE_LOW; + public static final int IMPORTANCE_DEFAULT = NotificationManager.IMPORTANCE_DEFAULT; + public static final int IMPORTANCE_HIGH = NotificationManager.IMPORTANCE_HIGH; + public static final int IMPORTANCE_MAX = NotificationManager.IMPORTANCE_MAX; + + private final NotificationManager manager; + + private NotificationManagerCompat(NotificationManager manager) { + this.manager = manager; + } + + public static NotificationManagerCompat from(Context context) { + return new NotificationManagerCompat(NotificationManager.getInstance()); + } + + public boolean areNotificationsEnabled() { + return manager.areNotificationsEnabled(); + } + + public void notify(int id, Notification notification) { + manager.notify(id, notification); + } + + public void notify(String tag, int id, Notification notification) { + manager.notify(tag, id, notification); + } + + public void cancel(int id) { + manager.cancel(id); + } + + public void cancel(String tag, int id) { + manager.cancel(tag, id); + } + + public void cancelAll() { + manager.cancelAll(); + } + + public void createNotificationChannel(NotificationChannel channel) { + manager.createNotificationChannel(channel); + } + + public void createNotificationChannels(List channels) { + manager.createNotificationChannels(channels); + } + + public NotificationChannel getNotificationChannel(String channelId) { + return manager.getNotificationChannel(channelId); + } + + public void deleteNotificationChannel(String channelId) { + manager.deleteNotificationChannel(channelId); + } +} diff --git a/maven/android-compat/src/main/java/androidx/core/app/ServiceCompat.java b/maven/android-compat/src/main/java/androidx/core/app/ServiceCompat.java new file mode 100644 index 00000000000..95a2a922cde --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/core/app/ServiceCompat.java @@ -0,0 +1,45 @@ +/* + * 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 androidx.core.app; + +import android.app.Notification; +import android.app.Service; + +public final class ServiceCompat { + + public static final int START_STICKY = Service.START_STICKY; + public static final int STOP_FOREGROUND_REMOVE = Service.STOP_FOREGROUND_REMOVE; + public static final int STOP_FOREGROUND_DETACH = Service.STOP_FOREGROUND_DETACH; + + private ServiceCompat() { + } + + public static void startForeground(Service service, int id, Notification notification, + int foregroundServiceType) { + service.startForeground(id, notification, foregroundServiceType); + } + + public static void stopForeground(Service service, int flags) { + service.stopForeground(flags); + } +} diff --git a/maven/android-compat/src/main/java/androidx/core/content/ContextCompat.java b/maven/android-compat/src/main/java/androidx/core/content/ContextCompat.java index 880ab59dab7..24234118e3f 100644 --- a/maven/android-compat/src/main/java/androidx/core/content/ContextCompat.java +++ b/maven/android-compat/src/main/java/androidx/core/content/ContextCompat.java @@ -49,6 +49,10 @@ public static Drawable getDrawable(Context context, int id) { return context.getDrawable(id); } + public static void startForegroundService(Context context, Intent intent) { + context.startForegroundService(intent); + } + public static int checkSelfPermission(Context context, String permission) { return context.checkSelfPermission(permission); } diff --git a/maven/android-compat/src/main/java/androidx/room/AutoMigration.java b/maven/android-compat/src/main/java/androidx/room/AutoMigration.java new file mode 100644 index 00000000000..2b2772d603b --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/AutoMigration.java @@ -0,0 +1,39 @@ +/* + * 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 androidx.room; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Declared for source compatibility; an automatic migration fails the build. +/// +/// Kept in the class files: the Room step of the Android remap reads it there +/// and writes the code AndroidX Room's annotation processor would. +@Target({}) +@Retention(RetentionPolicy.CLASS) +public @interface AutoMigration { + int from(); + int to(); + Class spec() default Object.class; +} diff --git a/maven/android-compat/src/main/java/androidx/room/BuiltInTypeConverters.java b/maven/android-compat/src/main/java/androidx/room/BuiltInTypeConverters.java new file mode 100644 index 00000000000..2b8d41777f5 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/BuiltInTypeConverters.java @@ -0,0 +1,43 @@ +/* + * 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 androidx.room; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Whether the built-in converters (enums) apply. +/// +/// Kept in the class files: the Room step of the Android remap reads it there +/// and writes the code AndroidX Room's annotation processor would. +@Target({}) +@Retention(RetentionPolicy.CLASS) +public @interface BuiltInTypeConverters { + State enums() default State.INHERITED; + State uuid() default State.INHERITED; + + /// On, off, or as the enclosing scope says. + enum State { + ENABLED, DISABLED, INHERITED + } +} diff --git a/maven/android-compat/src/main/java/androidx/room/ColumnInfo.java b/maven/android-compat/src/main/java/androidx/room/ColumnInfo.java new file mode 100644 index 00000000000..1ebc9d6da92 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/ColumnInfo.java @@ -0,0 +1,56 @@ +/* + * 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 androidx.room; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Names and types a column. +/// +/// Kept in the class files: the Room step of the Android remap reads it there +/// and writes the code AndroidX Room's annotation processor would. +@Target({ElementType.FIELD, ElementType.METHOD}) +@Retention(RetentionPolicy.CLASS) +public @interface ColumnInfo { + String INHERIT_FIELD_NAME = "[field-name]"; + int UNDEFINED = 1; + int TEXT = 2; + int INTEGER = 3; + int REAL = 4; + int BLOB = 5; + int UNSPECIFIED = 1; + int BINARY = 2; + int NOCASE = 3; + int RTRIM = 4; + int LOCALIZED = 5; + int UNICODE = 6; + String VALUE_UNSPECIFIED = "[value-unspecified]"; + + String name() default INHERIT_FIELD_NAME; + int typeAffinity() default UNDEFINED; + boolean index() default false; + int collate() default UNSPECIFIED; + String defaultValue() default VALUE_UNSPECIFIED; +} diff --git a/maven/android-compat/src/main/java/androidx/room/Dao.java b/maven/android-compat/src/main/java/androidx/room/Dao.java new file mode 100644 index 00000000000..32f5a886741 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/Dao.java @@ -0,0 +1,37 @@ +/* + * 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 androidx.room; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Marks an interface or abstract class whose implementation the build generates. +/// +/// Kept in the class files: the Room step of the Android remap reads it there +/// and writes the code AndroidX Room's annotation processor would. +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.CLASS) +public @interface Dao { +} diff --git a/maven/android-compat/src/main/java/androidx/room/Database.java b/maven/android-compat/src/main/java/androidx/room/Database.java new file mode 100644 index 00000000000..6a4f22cb614 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/Database.java @@ -0,0 +1,42 @@ +/* + * 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 androidx.room; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Marks the abstract database class; the build generates its implementation. +/// +/// Kept in the class files: the Room step of the Android remap reads it there +/// and writes the code AndroidX Room's annotation processor would. +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.CLASS) +public @interface Database { + Class[] entities(); + Class[] views() default {}; + int version(); + boolean exportSchema() default true; + AutoMigration[] autoMigrations() default {}; +} diff --git a/maven/android-compat/src/main/java/androidx/room/DatabaseView.java b/maven/android-compat/src/main/java/androidx/room/DatabaseView.java new file mode 100644 index 00000000000..9ef0bf7de94 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/DatabaseView.java @@ -0,0 +1,39 @@ +/* + * 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 androidx.room; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// A view; the Codename One Room build refuses it, naming the class. +/// +/// Kept in the class files: the Room step of the Android remap reads it there +/// and writes the code AndroidX Room's annotation processor would. +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.CLASS) +public @interface DatabaseView { + String value() default ""; + String viewName() default ""; +} diff --git a/maven/android-compat/src/main/java/androidx/room/Delete.java b/maven/android-compat/src/main/java/androidx/room/Delete.java new file mode 100644 index 00000000000..c4273833948 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/Delete.java @@ -0,0 +1,38 @@ +/* + * 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 androidx.room; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Deletes the entities a DAO method is passed, by primary key. +/// +/// Kept in the class files: the Room step of the Android remap reads it there +/// and writes the code AndroidX Room's annotation processor would. +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.CLASS) +public @interface Delete { + Class entity() default Object.class; +} diff --git a/maven/android-compat/src/main/java/androidx/room/Embedded.java b/maven/android-compat/src/main/java/androidx/room/Embedded.java new file mode 100644 index 00000000000..6056eb5e54a --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/Embedded.java @@ -0,0 +1,38 @@ +/* + * 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 androidx.room; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Stores an object's fields as columns of the enclosing entity. +/// +/// Kept in the class files: the Room step of the Android remap reads it there +/// and writes the code AndroidX Room's annotation processor would. +@Target({ElementType.FIELD, ElementType.METHOD}) +@Retention(RetentionPolicy.CLASS) +public @interface Embedded { + String prefix() default ""; +} diff --git a/maven/android-compat/src/main/java/androidx/room/Entity.java b/maven/android-compat/src/main/java/androidx/room/Entity.java new file mode 100644 index 00000000000..3a403711ea1 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/Entity.java @@ -0,0 +1,43 @@ +/* + * 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 androidx.room; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Marks a class as a Room table. The build generates its table, column mapping and indices. +/// +/// Kept in the class files: the Room step of the Android remap reads it there +/// and writes the code AndroidX Room's annotation processor would. +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.CLASS) +public @interface Entity { + String tableName() default ""; + Index[] indices() default {}; + boolean inheritSuperIndices() default false; + String[] primaryKeys() default {}; + ForeignKey[] foreignKeys() default {}; + String[] ignoredColumns() default {}; +} diff --git a/maven/android-compat/src/main/java/androidx/room/ForeignKey.java b/maven/android-compat/src/main/java/androidx/room/ForeignKey.java new file mode 100644 index 00000000000..05fdd24aa20 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/ForeignKey.java @@ -0,0 +1,48 @@ +/* + * 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 androidx.room; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// A foreign key of an entity's table. +/// +/// Kept in the class files: the Room step of the Android remap reads it there +/// and writes the code AndroidX Room's annotation processor would. +@Target({}) +@Retention(RetentionPolicy.CLASS) +public @interface ForeignKey { + int NO_ACTION = 1; + int RESTRICT = 2; + int SET_NULL = 3; + int SET_DEFAULT = 4; + int CASCADE = 5; + + Class entity(); + String[] parentColumns(); + String[] childColumns(); + int onDelete() default NO_ACTION; + int onUpdate() default NO_ACTION; + boolean deferred() default false; +} diff --git a/maven/android-compat/src/main/java/androidx/room/Fts3.java b/maven/android-compat/src/main/java/androidx/room/Fts3.java new file mode 100644 index 00000000000..e5aa9105823 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/Fts3.java @@ -0,0 +1,37 @@ +/* + * 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 androidx.room; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// A full-text entity; the Codename One Room build refuses it, naming the class. +/// +/// Kept in the class files: the Room step of the Android remap reads it there +/// and writes the code AndroidX Room's annotation processor would. +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.CLASS) +public @interface Fts3 { +} diff --git a/maven/android-compat/src/main/java/androidx/room/Fts4.java b/maven/android-compat/src/main/java/androidx/room/Fts4.java new file mode 100644 index 00000000000..354179b1f40 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/Fts4.java @@ -0,0 +1,37 @@ +/* + * 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 androidx.room; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// A full-text entity; the Codename One Room build refuses it, naming the class. +/// +/// Kept in the class files: the Room step of the Android remap reads it there +/// and writes the code AndroidX Room's annotation processor would. +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.CLASS) +public @interface Fts4 { +} diff --git a/maven/android-compat/src/main/java/androidx/room/Ignore.java b/maven/android-compat/src/main/java/androidx/room/Ignore.java new file mode 100644 index 00000000000..76e8a6c0bc2 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/Ignore.java @@ -0,0 +1,37 @@ +/* + * 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 androidx.room; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Leaves a field or constructor out of the mapping. +/// +/// Kept in the class files: the Room step of the Android remap reads it there +/// and writes the code AndroidX Room's annotation processor would. +@Target({ElementType.FIELD, ElementType.METHOD, ElementType.CONSTRUCTOR}) +@Retention(RetentionPolicy.CLASS) +public @interface Ignore { +} diff --git a/maven/android-compat/src/main/java/androidx/room/Index.java b/maven/android-compat/src/main/java/androidx/room/Index.java new file mode 100644 index 00000000000..5fb9a9bafaf --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/Index.java @@ -0,0 +1,39 @@ +/* + * 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 androidx.room; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// An index of an entity's table. +/// +/// Kept in the class files: the Room step of the Android remap reads it there +/// and writes the code AndroidX Room's annotation processor would. +@Target({}) +@Retention(RetentionPolicy.CLASS) +public @interface Index { + String[] value(); + String name() default ""; + boolean unique() default false; +} diff --git a/maven/android-compat/src/main/java/androidx/room/Insert.java b/maven/android-compat/src/main/java/androidx/room/Insert.java new file mode 100644 index 00000000000..b5f7a3784d8 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/Insert.java @@ -0,0 +1,39 @@ +/* + * 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 androidx.room; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Inserts the entities a DAO method is passed. +/// +/// Kept in the class files: the Room step of the Android remap reads it there +/// and writes the code AndroidX Room's annotation processor would. +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.CLASS) +public @interface Insert { + Class entity() default Object.class; + int onConflict() default OnConflictStrategy.ABORT; +} diff --git a/maven/android-compat/src/main/java/androidx/room/InvalidationTracker.java b/maven/android-compat/src/main/java/androidx/room/InvalidationTracker.java new file mode 100644 index 00000000000..e4e0bce8c39 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/InvalidationTracker.java @@ -0,0 +1,209 @@ +/* + * 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 androidx.room; + +import androidx.lifecycle.LiveData; + +import com.codename1.androidcompat.runtime.MainThread; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.Callable; + +/// Tells observers which tables changed. Room on Android learns this from +/// triggers; here the generated DAOs report every table a write names, and a +/// change made inside a transaction is reported once the outermost +/// transaction ends. A statement the application runs itself through +/// `getOpenHelper()` is not seen, as no trigger watches it. +public class InvalidationTracker { + + private final RoomDatabase mDatabase; + private final String[] mTableNames; + private final List mObservers = new ArrayList(); + /// Tables written by the current thread and not yet reported. Per + /// thread because a transaction is: its writes are reported when that + /// thread's outermost transaction ends, never by another thread. + private final ThreadLocal> mPending = new ThreadLocal>(); + + /// An observer of a set of tables, named case-insensitively. + public abstract static class Observer { + final String[] mTables; + + protected Observer(String firstTable, String... rest) { + this(join(firstTable, rest)); + } + + public Observer(String[] tables) { + mTables = new String[tables.length]; + for (int i = 0; i < tables.length; i++) { + mTables[i] = asciiLower(tables[i]); + } + } + + public abstract void onInvalidated(Set tables); + + boolean isRemote() { + return false; + } + + private static String[] join(String first, String[] rest) { + String[] all = new String[rest.length + 1]; + all[0] = first; + System.arraycopy(rest, 0, all, 1, rest.length); + return all; + } + } + + public InvalidationTracker(RoomDatabase database, String... tableNames) { + mDatabase = database; + mTableNames = new String[tableNames.length]; + for (int i = 0; i < tableNames.length; i++) { + mTableNames[i] = asciiLower(tableNames[i]); + } + } + + /// Call on the main thread, as `LiveData` does from `onActive`; observers + /// are notified there. + public void addObserver(Observer observer) { + for (String t : observer.mTables) { + if (!knows(t)) { + throw new IllegalArgumentException("There is no table with name " + t); + } + } + if (!mObservers.contains(observer)) { + mObservers.add(observer); + } + } + + public void removeObserver(Observer observer) { + mObservers.remove(observer); + } + + /// Delivers what is pending. Room schedules this after a write; here a + /// write outside a transaction is delivered at once. + public void refreshVersionsAsync() { + flush(); + } + + public void refreshVersionsSync() { + flush(); + } + + /// Records that the named tables changed. Generated DAOs call it after + /// every write. + public void notifyTablesChanged(String... tables) { + Set pending = mPending.get(); + if (pending == null) { + pending = new HashSet(); + mPending.set(pending); + } + for (String t : tables) { + pending.add(asciiLower(t)); + } + if (!mDatabase.inTransaction()) { + flush(); + } + } + + /// Reports the current thread's pending tables. Observers are called on + /// the main thread, which is the only thread that changes the observer + /// list (LiveData registers from `onActive`), so a writer on a worker + /// thread posts the report instead of walking the list itself. + void flush() { + Set pending = mPending.get(); + if (pending == null || pending.isEmpty()) { + return; + } + final Set changed = new HashSet(pending); + pending.clear(); + if (MainThread.isMainThread()) { + dispatch(changed); + } else { + MainThread.post(new Runnable() { + @Override + public void run() { + dispatch(changed); + } + }); + } + } + + private void dispatch(Set changed) { + List copy = new ArrayList(mObservers); + for (Observer o : copy) { + if (!mObservers.contains(o)) { + // Removed by an earlier observer's callback in this pass. + continue; + } + Set hit = null; + for (String t : o.mTables) { + if (changed.contains(t)) { + if (hit == null) { + hit = new HashSet(); + } + hit.add(t); + } + } + if (hit != null) { + o.onInvalidated(Collections.unmodifiableSet(hit)); + } + } + } + + /// A live query: computed when it gains an active observer and again + /// after every change to `tableNames` while it has one. + public LiveData createLiveData(String[] tableNames, boolean inTransaction, Callable computeFunction) { + return new RoomTrackingLiveData(mDatabase, tableNames, inTransaction, computeFunction); + } + + public LiveData createLiveData(String[] tableNames, Callable computeFunction) { + return createLiveData(tableNames, false, computeFunction); + } + + private boolean knows(String table) { + for (String t : mTableNames) { + if (t.equals(table)) { + return true; + } + } + return false; + } + + /// ASCII-only lower case: `toLowerCase()` follows the device locale. + static String asciiLower(String s) { + char[] out = null; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c >= 'A' && c <= 'Z') { + if (out == null) { + out = s.toCharArray(); + } + out[i] = (char) (c + ('a' - 'A')); + } + } + return out == null ? s : new String(out); + } +} diff --git a/maven/android-compat/src/main/java/androidx/room/Junction.java b/maven/android-compat/src/main/java/androidx/room/Junction.java new file mode 100644 index 00000000000..874d05fc23b --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/Junction.java @@ -0,0 +1,39 @@ +/* + * 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 androidx.room; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// The cross-reference table of a many-to-many [Relation]. +/// +/// Kept in the class files: the Room step of the Android remap reads it there +/// and writes the code AndroidX Room's annotation processor would. +@Target({}) +@Retention(RetentionPolicy.CLASS) +public @interface Junction { + Class value(); + String parentColumn() default ""; + String entityColumn() default ""; +} diff --git a/maven/android-compat/src/main/java/androidx/room/OnConflictStrategy.java b/maven/android-compat/src/main/java/androidx/room/OnConflictStrategy.java new file mode 100644 index 00000000000..75e030f34de --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/OnConflictStrategy.java @@ -0,0 +1,37 @@ +/* + * 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 androidx.room; + +/// What an insert or update does when it breaks a constraint. +public final class OnConflictStrategy { + + public static final int NONE = 0; + public static final int REPLACE = 1; + public static final int ROLLBACK = 2; + public static final int ABORT = 3; + public static final int FAIL = 4; + public static final int IGNORE = 5; + + private OnConflictStrategy() { + } +} diff --git a/maven/android-compat/src/main/java/androidx/room/PrimaryKey.java b/maven/android-compat/src/main/java/androidx/room/PrimaryKey.java new file mode 100644 index 00000000000..1407fc86c5e --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/PrimaryKey.java @@ -0,0 +1,38 @@ +/* + * 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 androidx.room; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// The primary key column of an entity. +/// +/// Kept in the class files: the Room step of the Android remap reads it there +/// and writes the code AndroidX Room's annotation processor would. +@Target({ElementType.FIELD, ElementType.METHOD}) +@Retention(RetentionPolicy.CLASS) +public @interface PrimaryKey { + boolean autoGenerate() default false; +} diff --git a/maven/android-compat/src/main/java/androidx/room/ProvidedTypeConverter.java b/maven/android-compat/src/main/java/androidx/room/ProvidedTypeConverter.java new file mode 100644 index 00000000000..ce8b084db47 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/ProvidedTypeConverter.java @@ -0,0 +1,37 @@ +/* + * 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 androidx.room; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// A converter whose instance the application supplies through `Builder.addTypeConverter`. +/// +/// Kept in the class files: the Room step of the Android remap reads it there +/// and writes the code AndroidX Room's annotation processor would. +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.CLASS) +public @interface ProvidedTypeConverter { +} diff --git a/maven/android-compat/src/main/java/androidx/room/Query.java b/maven/android-compat/src/main/java/androidx/room/Query.java new file mode 100644 index 00000000000..a6d4a626347 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/Query.java @@ -0,0 +1,38 @@ +/* + * 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 androidx.room; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// A SQL statement run by a DAO method; `:name` binds a parameter. +/// +/// Kept in the class files: the Room step of the Android remap reads it there +/// and writes the code AndroidX Room's annotation processor would. +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.CLASS) +public @interface Query { + String value(); +} diff --git a/maven/android-compat/src/main/java/androidx/room/RawQuery.java b/maven/android-compat/src/main/java/androidx/room/RawQuery.java new file mode 100644 index 00000000000..70353204e2a --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/RawQuery.java @@ -0,0 +1,38 @@ +/* + * 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 androidx.room; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// A DAO method that runs the query it is passed. +/// +/// Kept in the class files: the Room step of the Android remap reads it there +/// and writes the code AndroidX Room's annotation processor would. +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.CLASS) +public @interface RawQuery { + Class[] observedEntities() default {}; +} diff --git a/maven/android-compat/src/main/java/androidx/room/Relation.java b/maven/android-compat/src/main/java/androidx/room/Relation.java new file mode 100644 index 00000000000..940e5ddc13a --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/Relation.java @@ -0,0 +1,42 @@ +/* + * 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 androidx.room; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// A relation between entities; the Codename One Room build refuses it, naming the field. +/// +/// Kept in the class files: the Room step of the Android remap reads it there +/// and writes the code AndroidX Room's annotation processor would. +@Target({ElementType.FIELD, ElementType.METHOD}) +@Retention(RetentionPolicy.CLASS) +public @interface Relation { + Class entity() default Object.class; + String parentColumn(); + String entityColumn(); + Junction associateBy() default @Junction(Object.class); + String[] projection() default {}; +} diff --git a/maven/android-compat/src/main/java/androidx/room/RewriteQueriesToDropUnusedColumns.java b/maven/android-compat/src/main/java/androidx/room/RewriteQueriesToDropUnusedColumns.java new file mode 100644 index 00000000000..1afb364a554 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/RewriteQueriesToDropUnusedColumns.java @@ -0,0 +1,37 @@ +/* + * 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 androidx.room; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Accepted and ignored. +/// +/// Kept in the class files: the Room step of the Android remap reads it there +/// and writes the code AndroidX Room's annotation processor would. +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.CLASS) +public @interface RewriteQueriesToDropUnusedColumns { +} diff --git a/maven/android-compat/src/main/java/androidx/room/Room.java b/maven/android-compat/src/main/java/androidx/room/Room.java new file mode 100644 index 00000000000..4bdf7a1b215 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/Room.java @@ -0,0 +1,50 @@ +/* + * 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 androidx.room; + +import android.content.Context; + +/// Creates Room databases. +public final class Room { + + public static final String MASTER_TABLE_NAME = RoomDatabase.MASTER_TABLE; + + private Room() { + } + + /// A database stored under `name`. + public static RoomDatabase.Builder databaseBuilder(Context context, Class klass, + String name) { + if (name == null || name.trim().length() == 0) { + throw new IllegalArgumentException("Cannot build a database with null or empty name." + + " If you are trying to create an in memory database, use Room.inMemoryDatabaseBuilder"); + } + return new RoomDatabase.Builder(context, klass, name); + } + + /// A database that lives until it is closed. + public static RoomDatabase.Builder inMemoryDatabaseBuilder(Context context, + Class klass) { + return new RoomDatabase.Builder(context, klass, null); + } +} diff --git a/maven/android-compat/src/main/java/androidx/room/RoomDatabase.java b/maven/android-compat/src/main/java/androidx/room/RoomDatabase.java new file mode 100644 index 00000000000..d4805051dbe --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/RoomDatabase.java @@ -0,0 +1,656 @@ +/* + * 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 androidx.room; + +import android.content.Context; +import android.database.Cursor; +import androidx.room.migration.Migration; +import androidx.sqlite.db.SimpleSQLiteQuery; +import androidx.sqlite.db.SupportSQLiteDatabase; +import androidx.sqlite.db.SupportSQLiteOpenHelper; +import androidx.sqlite.db.SupportSQLiteQuery; +import androidx.sqlite.db.SupportSQLiteStatement; +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory; +import com.codename1.androidcompat.runtime.MainThread; +import com.codename1.androidcompat.runtime.RoomFactory; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.Executor; + +/// The base of an application's `@Database` class. The build generates the +/// subclass (`Name_Impl`) that knows the schema; this class opens the +/// database, creates the tables on the first open, runs the migrations a +/// version change needs and checks that the schema did not change without +/// one, as Room does. +public abstract class RoomDatabase { + + public static final int MAX_BIND_PARAMETER_CNT = 999; + static final String MASTER_TABLE = "room_master_table"; + private static final int MASTER_ID = 42; + + private SupportSQLiteOpenHelper mOpenHelper; + private InvalidationTracker mInvalidationTracker; + private boolean mAllowMainThreadQueries; + private Executor mQueryExecutor; + private Executor mTransactionExecutor; + private final Map, Object> mTypeConverters = new HashMap, Object>(); + private List mCallbacks; + private MigrationContainer mMigrations; + private boolean mDestructive; + private boolean mDestructiveOnDowngrade; + private Set mDestructiveFrom; + private boolean mOpen; + /// Live queries recompute on the main thread when that is where the + /// change happened; they are exempt from the main-thread check, which + /// is there for the application's own calls. Touched on the main thread + /// only. + + public RoomDatabase() { + } + + // ---------------------------------------------------------- generated + + /// The `version` of the `@Database` annotation. + protected abstract int roomSchemaVersion(); + + /// The statements creating every table and index, in order. + protected abstract String[] roomCreateStatements(); + + /// The entity tables. + protected abstract String[] roomTableNames(); + + /// Each entity table followed by its columns, which a migration must + /// leave in place; null skips the check. + protected String[][] roomTableColumns() { + return null; + } + + /// A new instance of a `@TypeConverters` class the application did not + /// supply, or null for one it must (`@ProvidedTypeConverter`). + protected Object roomCreateTypeConverter(Class converterClass) { + return null; + } + + // ---------------------------------------------------------- lifecycle + + void init(Builder b) { + mAllowMainThreadQueries = b.mAllowMainThreadQueries; + mQueryExecutor = b.mQueryExecutor; + mTransactionExecutor = b.mTransactionExecutor != null ? b.mTransactionExecutor : b.mQueryExecutor; + mCallbacks = b.mCallbacks; + mMigrations = b.mMigrationContainer; + mDestructive = b.mDestructive; + mDestructiveOnDowngrade = b.mDestructiveOnDowngrade; + mDestructiveFrom = b.mDestructiveFrom; + for (Object c : b.mTypeConverters) { + mTypeConverters.put(c.getClass(), c); + } + mInvalidationTracker = new InvalidationTracker(this, roomTableNames()); + SupportSQLiteOpenHelper.Factory factory = b.mFactory != null ? b.mFactory + : new FrameworkSQLiteOpenHelperFactory(); + mOpenHelper = factory.create(new SupportSQLiteOpenHelper.Configuration(b.mContext, b.mName, + new OpenCallback(roomSchemaVersion()))); + mOpen = true; + } + + public SupportSQLiteOpenHelper getOpenHelper() { + return mOpenHelper; + } + + public InvalidationTracker getInvalidationTracker() { + return mInvalidationTracker; + } + + public Executor getQueryExecutor() { + return mQueryExecutor; + } + + public Executor getTransactionExecutor() { + return mTransactionExecutor; + } + + public boolean isOpen() { + return mOpen; + } + + public void close() { + if (mOpen) { + mOpen = false; + mOpenHelper.close(); + } + } + + /// The instance of a `@TypeConverters` class: the one the builder was + /// given, or one created for it. + @SuppressWarnings("unchecked") + public T getTypeConverter(Class klass) { + Object c = mTypeConverters.get(klass); + if (c == null) { + c = roomCreateTypeConverter(klass); + if (c == null) { + throw new IllegalArgumentException("A required type converter (" + klass.getName() + + ") for " + getClass().getName() + " is missing in the database configuration."); + } + mTypeConverters.put(klass, c); + } + return (T) c; + } + + // ---------------------------------------------------------- access + + /// Throws on the main thread unless `allowMainThreadQueries()` was set. + public void assertNotMainThread() { + if (mAllowMainThreadQueries) { + return; + } + if (MainThread.isMainThread() && com.codename1.ui.Display.isInitialized()) { + throw new IllegalStateException("Cannot access database on the main thread since" + + " it may potentially lock the UI for a long period of time."); + } + } + + public Cursor query(String query, Object[] args) { + return query(new SimpleSQLiteQuery(query, args)); + } + + public Cursor query(SupportSQLiteQuery query) { + assertNotMainThread(); + return mOpenHelper.getWritableDatabase().query(query); + } + + public SupportSQLiteStatement compileStatement(String sql) { + assertNotMainThread(); + return mOpenHelper.getWritableDatabase().compileStatement(sql); + } + + public void beginTransaction() { + assertNotMainThread(); + mOpenHelper.getWritableDatabase().beginTransaction(); + } + + public void endTransaction() { + SupportSQLiteDatabase db = mOpenHelper.getWritableDatabase(); + db.endTransaction(); + if (!db.inTransaction()) { + mInvalidationTracker.flush(); + } + } + + public void setTransactionSuccessful() { + mOpenHelper.getWritableDatabase().setTransactionSuccessful(); + } + + public boolean inTransaction() { + return mOpen && mOpenHelper.getWritableDatabase().inTransaction(); + } + + public void runInTransaction(Runnable body) { + beginTransaction(); + try { + body.run(); + setTransactionSuccessful(); + } finally { + endTransaction(); + } + } + + public V runInTransaction(Callable body) { + beginTransaction(); + try { + V result = body.call(); + setTransactionSuccessful(); + return result; + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + RoomDatabase.sneakyThrow(e); + return null; + } finally { + endTransaction(); + } + } + + /// Rethrows a checked exception unwrapped, as Room's `SneakyThrow` does. + @SuppressWarnings("unchecked") + private static void sneakyThrow(Throwable t) throws E { + throw (E) t; + } + + /// Deletes every row of every entity table, foreign keys deferred. + public void clearAllTables() { + assertNotMainThread(); + SupportSQLiteDatabase db = mOpenHelper.getWritableDatabase(); + boolean fk = hasForeignKeys(); + try { + if (fk) { + db.execSQL("PRAGMA defer_foreign_keys = TRUE"); + } + db.beginTransaction(); + try { + for (String t : roomTableNames()) { + db.execSQL("DELETE FROM `" + t + "`"); + } + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } finally { + if (!db.inTransaction()) { + db.execSQL("VACUUM"); + } + } + mInvalidationTracker.notifyTablesChanged(roomTableNames()); + } + + // ---------------------------------------------------------- schema + + private boolean hasForeignKeys() { + for (String s : roomCreateStatements()) { + if (s.indexOf(" REFERENCES ") >= 0) { + return true; + } + } + return false; + } + + /// The schema's fingerprint, stored in `room_master_table`. A database + /// opened at its version with another fingerprint changed its schema + /// without a version bump. + String identityHash() { + StringBuilder all = new StringBuilder(); + for (String s : roomCreateStatements()) { + all.append(s).append('\n'); + } + String text = all.toString(); + long h = 1125899906842597L; + for (int i = 0; i < text.length(); i++) { + h = 31 * h + text.charAt(i); + } + String hex = Long.toHexString(h); + while (hex.length() < 16) { + hex = "0" + hex; + } + return hex + Integer.toHexString(text.length()); + } + + void createAllTables(SupportSQLiteDatabase db) { + for (String s : roomCreateStatements()) { + db.execSQL(s); + } + writeIdentity(db); + } + + private void writeIdentity(SupportSQLiteDatabase db) { + db.execSQL("CREATE TABLE IF NOT EXISTS " + MASTER_TABLE + " (id INTEGER PRIMARY KEY,identity_hash TEXT)"); + db.execSQL("INSERT OR REPLACE INTO " + MASTER_TABLE + " (id,identity_hash) VALUES(" + MASTER_ID + ", '" + + identityHash() + "')"); + } + + private void dropAllTables(SupportSQLiteDatabase db) { + for (String t : roomTableNames()) { + db.execSQL("DROP TABLE IF EXISTS `" + t + "`"); + } + db.execSQL("DROP TABLE IF EXISTS " + MASTER_TABLE); + } + + void migrate(SupportSQLiteDatabase db, int from, int to) { + List path = mMigrations.findMigrationPath(from, to); + if (path != null) { + for (Migration m : path) { + m.migrate(db); + } + validateSchema(db); + writeIdentity(db); + return; + } + boolean downgrade = from > to; + boolean allowed = mDestructive || (downgrade && mDestructiveOnDowngrade) + || (mDestructiveFrom != null && mDestructiveFrom.contains(Integer.valueOf(from))); + if (!allowed) { + throw new IllegalStateException("A migration from " + from + " to " + to + " was required but not" + + " found. Please provide the necessary Migration path via" + + " RoomDatabase.Builder.addMigration(Migration ...) or allow for destructive migrations via" + + " one of the RoomDatabase.Builder.fallbackToDestructiveMigration* methods."); + } + dropAllTables(db); + if (mCallbacks != null) { + for (Callback c : mCallbacks) { + c.onDestructiveMigration(db); + } + } + createAllTables(db); + } + + /// Room refuses a migration that left a table without the columns its + /// entity declares, rather than stamping the database valid and failing + /// at the first query. This compares column names; types, nullability and + /// indices are not checked. + private void validateSchema(SupportSQLiteDatabase db) { + String[][] expected = roomTableColumns(); + if (expected == null) { + return; + } + for (String[] table : expected) { + java.util.Set want = new java.util.TreeSet(); + for (int i = 1; i < table.length; i++) { + want.add(InvalidationTracker.asciiLower(table[i])); + } + java.util.Set found = new java.util.TreeSet(); + Cursor c = db.query("PRAGMA table_info(`" + table[0] + "`)"); + try { + int name = c.getColumnIndex("name"); + while (c.moveToNext()) { + found.add(InvalidationTracker.asciiLower(c.getString(name))); + } + } finally { + c.close(); + } + if (!found.equals(want)) { + throw new IllegalStateException("Migration didn't properly handle: " + table[0] + + ".\n Expected columns: " + want + "\n Found columns: " + found); + } + } + } + + void checkIdentity(SupportSQLiteDatabase db) { + Cursor c = db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name='" + MASTER_TABLE + "'"); + boolean hasMaster; + try { + hasMaster = c.moveToFirst(); + } finally { + c.close(); + } + if (!hasMaster) { + writeIdentity(db); + return; + } + String found = null; + c = db.query("SELECT identity_hash FROM " + MASTER_TABLE + " WHERE id = " + MASTER_ID + " LIMIT 1"); + try { + if (c.moveToFirst()) { + found = c.getString(0); + } + } finally { + c.close(); + } + String expected = identityHash(); + if (!expected.equals(found)) { + throw new IllegalStateException("Room cannot verify the data integrity. Looks like you've changed" + + " schema but forgot to update the version number. You can simply fix this by increasing" + + " the version number. Expected identity hash: " + expected + ", found: " + found); + } + } + + /// The open hooks: create, migrate, verify. + private final class OpenCallback extends SupportSQLiteOpenHelper.Callback { + OpenCallback(int version) { + super(version); + } + + @Override + public void onCreate(SupportSQLiteDatabase db) { + createAllTables(db); + if (mCallbacks != null) { + for (Callback c : mCallbacks) { + c.onCreate(db); + } + } + } + + @Override + public void onUpgrade(SupportSQLiteDatabase db, int oldVersion, int newVersion) { + migrate(db, oldVersion, newVersion); + } + + @Override + public void onDowngrade(SupportSQLiteDatabase db, int oldVersion, int newVersion) { + migrate(db, oldVersion, newVersion); + } + + @Override + public void onOpen(SupportSQLiteDatabase db) { + checkIdentity(db); + if (hasForeignKeys()) { + db.execSQL("PRAGMA foreign_keys = ON"); + } + if (mCallbacks != null) { + for (Callback c : mCallbacks) { + c.onOpen(db); + } + } + } + } + + // ---------------------------------------------------------- nested + + /// Hooks an application adds with `Builder.addCallback`. + public abstract static class Callback { + public void onCreate(SupportSQLiteDatabase db) { + } + + public void onOpen(SupportSQLiteDatabase db) { + } + + public void onDestructiveMigration(SupportSQLiteDatabase db) { + } + } + + /// The journal mode; accepted, and left to the platform. + public enum JournalMode { + AUTOMATIC, TRUNCATE, WRITE_AHEAD_LOGGING + } + + /// The migrations a builder was given, by start and end version. + public static class MigrationContainer { + private final Map> mMigrations = + new HashMap>(); + + public void addMigrations(Migration... migrations) { + for (Migration m : migrations) { + addMigration(m); + } + } + + void addMigration(Migration m) { + Integer start = Integer.valueOf(m.startVersion); + Map ends = mMigrations.get(start); + if (ends == null) { + ends = new HashMap(); + mMigrations.put(start, ends); + } + ends.put(Integer.valueOf(m.endVersion), m); + } + + /// Room's walk: from each version, the migration that goes furthest + /// toward `end` without passing it. + public List findMigrationPath(int start, int end) { + if (start == end) { + return new ArrayList(); + } + boolean upgrade = end > start; + List result = new ArrayList(); + int current = start; + while (upgrade ? current < end : current > end) { + Map targets = mMigrations.get(Integer.valueOf(current)); + if (targets == null) { + return null; + } + Migration best = null; + for (Map.Entry e : targets.entrySet()) { + int to = e.getKey().intValue(); + boolean usable = upgrade ? to <= end && to > current : to >= end && to < current; + if (usable && (best == null || (upgrade ? to > best.endVersion : to < best.endVersion))) { + best = e.getValue(); + } + } + if (best == null) { + return null; + } + result.add(best); + current = best.endVersion; + } + return result; + } + } + + /// Configures and creates a database. + public static class Builder { + final Class mDatabaseClass; + final String mName; + final Context mContext; + final List mCallbacks = new ArrayList(); + final List mTypeConverters = new ArrayList(); + final MigrationContainer mMigrationContainer = new MigrationContainer(); + boolean mAllowMainThreadQueries; + boolean mDestructive; + boolean mDestructiveOnDowngrade; + Set mDestructiveFrom; + Executor mQueryExecutor; + Executor mTransactionExecutor; + SupportSQLiteOpenHelper.Factory mFactory; + String mUnsupported; + + Builder(Context context, Class klass, String name) { + mContext = context; + mDatabaseClass = klass; + mName = name; + } + + public Builder addMigrations(Migration... migrations) { + mMigrationContainer.addMigrations(migrations); + return this; + } + + public Builder addCallback(Callback callback) { + mCallbacks.add(callback); + return this; + } + + public Builder addTypeConverter(Object typeConverter) { + mTypeConverters.add(typeConverter); + return this; + } + + public Builder allowMainThreadQueries() { + mAllowMainThreadQueries = true; + return this; + } + + public Builder fallbackToDestructiveMigration() { + mDestructive = true; + return this; + } + + public Builder fallbackToDestructiveMigration(boolean dropAllTables) { + mDestructive = true; + return this; + } + + public Builder fallbackToDestructiveMigrationOnDowngrade() { + mDestructiveOnDowngrade = true; + return this; + } + + /// Destructive only when the database is at one of these versions. + public Builder fallbackToDestructiveMigrationFrom(int... startVersions) { + if (mDestructiveFrom == null) { + mDestructiveFrom = new HashSet(); + } + for (int v : startVersions) { + mDestructiveFrom.add(Integer.valueOf(v)); + } + return this; + } + + public Builder setQueryExecutor(Executor executor) { + mQueryExecutor = executor; + return this; + } + + public Builder setTransactionExecutor(Executor executor) { + mTransactionExecutor = executor; + return this; + } + + public Builder setJournalMode(JournalMode journalMode) { + return this; + } + + public Builder openHelperFactory(SupportSQLiteOpenHelper.Factory factory) { + mFactory = factory; + return this; + } + + public Builder enableMultiInstanceInvalidation() { + return this; + } + + public Builder createFromAsset(String databaseFilePath) { + mUnsupported = "createFromAsset(\"" + databaseFilePath + "\")"; + return this; + } + + public Builder createFromFile(java.io.File databaseFile) { + mUnsupported = "createFromFile(" + databaseFile + ")"; + return this; + } + + public T build() { + Object impl = RoomFactory.create(mDatabaseClass.getName()); + if (impl == null) { + throw new RuntimeException("Cannot find implementation for " + mDatabaseClass.getName() + ". " + + mDatabaseClass.getName() + "_Impl does not exist"); + } + @SuppressWarnings("unchecked") + T db = (T) impl; + return buildWith(db); + } + + /// Configures an implementation the caller created. + T buildWith(T db) { + if (mUnsupported != null) { + throw new UnsupportedOperationException("Room." + mUnsupported + + " is not supported by the Codename One Room runtime"); + } + if (mContext == null) { + throw new IllegalArgumentException("Cannot provide null context for the database."); + } + if (mQueryExecutor == null) { + mQueryExecutor = new Executor() { + @Override + public void execute(Runnable command) { + new Thread(command, "room-query").start(); + } + }; + } + db.init(this); + return db; + } + } +} diff --git a/maven/android-compat/src/main/java/androidx/room/RoomTrackingLiveData.java b/maven/android-compat/src/main/java/androidx/room/RoomTrackingLiveData.java new file mode 100644 index 00000000000..1468ed0ff67 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/RoomTrackingLiveData.java @@ -0,0 +1,138 @@ +/* + * 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 androidx.room; + +import androidx.lifecycle.LiveData; +import com.codename1.androidcompat.runtime.MainThread; + +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicBoolean; + +/// The `LiveData` a DAO query returns, refreshed as AndroidX's is: an +/// invalidation is noted on the main thread, the query runs on the +/// database's query executor, and the value is posted back. One refresh +/// runs at a time, and an invalidation that arrives while one runs makes it +/// go round again, so the last value delivered is never older than the last +/// write. +final class RoomTrackingLiveData extends LiveData { + + private final RoomDatabase mDatabase; + private final boolean mInTransaction; + private final Callable mCompute; + private final InvalidationTracker.Observer mObserver; + private final AtomicBoolean mInvalid = new AtomicBoolean(true); + private final AtomicBoolean mComputing = new AtomicBoolean(false); + private boolean mRegistered; + + private final Runnable mRefresh = new Runnable() { + @Override + public void run() { + boolean computed; + do { + computed = false; + if (mComputing.compareAndSet(false, true)) { + try { + T value = null; + while (mInvalid.compareAndSet(true, false)) { + computed = true; + value = compute(); + } + if (computed) { + postValue(value); + } + } finally { + mComputing.set(false); + } + } + } while (computed && mInvalid.get()); + } + }; + + private final Runnable mInvalidated = new Runnable() { + @Override + public void run() { + boolean active = hasActiveObservers(); + if (mInvalid.compareAndSet(false, true) && active) { + mDatabase.getQueryExecutor().execute(mRefresh); + } + } + }; + + RoomTrackingLiveData(RoomDatabase database, String[] tables, boolean inTransaction, Callable compute) { + mDatabase = database; + mInTransaction = inTransaction; + mCompute = compute; + mObserver = new InvalidationTracker.Observer(tables) { + @Override + public void onInvalidated(Set changed) { + if (MainThread.isMainThread()) { + mInvalidated.run(); + } else { + MainThread.post(mInvalidated); + } + } + }; + } + + @Override + protected void onActive() { + if (!mRegistered) { + mDatabase.getInvalidationTracker().addObserver(mObserver); + mRegistered = true; + } + mDatabase.getQueryExecutor().execute(mRefresh); + } + + @Override + protected void onInactive() { + if (mRegistered) { + mDatabase.getInvalidationTracker().removeObserver(mObserver); + mRegistered = false; + } + } + + private T compute() { + if (!mInTransaction) { + return call(); + } + mDatabase.beginTransaction(); + try { + T value = call(); + mDatabase.setTransactionSuccessful(); + return value; + } finally { + mDatabase.endTransaction(); + } + } + + private T call() { + try { + return mCompute.call(); + } catch (RuntimeException e) { + throw e; + } catch (Exception e) { + throw new RuntimeException("Exception while computing database live data.", e); + } + } +} diff --git a/maven/android-compat/src/main/java/androidx/room/SkipQueryVerification.java b/maven/android-compat/src/main/java/androidx/room/SkipQueryVerification.java new file mode 100644 index 00000000000..4562f03e6af --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/SkipQueryVerification.java @@ -0,0 +1,37 @@ +/* + * 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 androidx.room; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Accepted and ignored: queries are not verified at build time. +/// +/// Kept in the class files: the Room step of the Android remap reads it there +/// and writes the code AndroidX Room's annotation processor would. +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD}) +@Retention(RetentionPolicy.CLASS) +public @interface SkipQueryVerification { +} diff --git a/maven/android-compat/src/main/java/androidx/room/Transaction.java b/maven/android-compat/src/main/java/androidx/room/Transaction.java new file mode 100644 index 00000000000..7fafd3f4c8c --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/Transaction.java @@ -0,0 +1,37 @@ +/* + * 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 androidx.room; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Runs a DAO method inside one transaction. +/// +/// Kept in the class files: the Room step of the Android remap reads it there +/// and writes the code AndroidX Room's annotation processor would. +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.CLASS) +public @interface Transaction { +} diff --git a/maven/android-compat/src/main/java/androidx/room/TypeConverter.java b/maven/android-compat/src/main/java/androidx/room/TypeConverter.java new file mode 100644 index 00000000000..9a58ece7f59 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/TypeConverter.java @@ -0,0 +1,37 @@ +/* + * 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 androidx.room; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// A method converting a field type to a column type or back. +/// +/// Kept in the class files: the Room step of the Android remap reads it there +/// and writes the code AndroidX Room's annotation processor would. +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.CLASS) +public @interface TypeConverter { +} diff --git a/maven/android-compat/src/main/java/androidx/room/TypeConverters.java b/maven/android-compat/src/main/java/androidx/room/TypeConverters.java new file mode 100644 index 00000000000..2b4f5d53c81 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/TypeConverters.java @@ -0,0 +1,39 @@ +/* + * 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 androidx.room; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// The converter classes a database uses. +/// +/// Kept in the class files: the Room step of the Android remap reads it there +/// and writes the code AndroidX Room's annotation processor would. +@Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.CLASS) +public @interface TypeConverters { + Class[] value() default {}; + BuiltInTypeConverters builtInTypeConverters() default @BuiltInTypeConverters; +} diff --git a/maven/android-compat/src/main/java/androidx/room/Update.java b/maven/android-compat/src/main/java/androidx/room/Update.java new file mode 100644 index 00000000000..e5737ca96ce --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/Update.java @@ -0,0 +1,39 @@ +/* + * 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 androidx.room; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Updates the entities a DAO method is passed, by primary key. +/// +/// Kept in the class files: the Room step of the Android remap reads it there +/// and writes the code AndroidX Room's annotation processor would. +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.CLASS) +public @interface Update { + Class entity() default Object.class; + int onConflict() default OnConflictStrategy.ABORT; +} diff --git a/maven/android-compat/src/main/java/androidx/room/Upsert.java b/maven/android-compat/src/main/java/androidx/room/Upsert.java new file mode 100644 index 00000000000..0339c315638 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/Upsert.java @@ -0,0 +1,38 @@ +/* + * 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 androidx.room; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/// Inserts the entities a DAO method is passed, updating those whose key exists. +/// +/// Kept in the class files: the Room step of the Android remap reads it there +/// and writes the code AndroidX Room's annotation processor would. +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.CLASS) +public @interface Upsert { + Class entity() default Object.class; +} diff --git a/maven/android-compat/src/main/java/androidx/room/migration/Migration.java b/maven/android-compat/src/main/java/androidx/room/migration/Migration.java new file mode 100644 index 00000000000..76a86e57ee2 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/room/migration/Migration.java @@ -0,0 +1,40 @@ +/* + * 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 androidx.room.migration; + +import androidx.sqlite.db.SupportSQLiteDatabase; + +/// Moves a database from one schema version to another. It runs inside the +/// transaction that opens the database. +public abstract class Migration { + + public final int startVersion; + public final int endVersion; + + public Migration(int startVersion, int endVersion) { + this.startVersion = startVersion; + this.endVersion = endVersion; + } + + public abstract void migrate(SupportSQLiteDatabase database); +} diff --git a/maven/android-compat/src/main/java/androidx/sqlite/db/SimpleSQLiteQuery.java b/maven/android-compat/src/main/java/androidx/sqlite/db/SimpleSQLiteQuery.java new file mode 100644 index 00000000000..755f8bbf527 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/sqlite/db/SimpleSQLiteQuery.java @@ -0,0 +1,86 @@ +/* + * 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 androidx.sqlite.db; + +/// A query over an array of arguments, each bound by its own type. +public final class SimpleSQLiteQuery implements SupportSQLiteQuery { + + private final String mQuery; + private final Object[] mBindArgs; + + public SimpleSQLiteQuery(String query, Object[] bindArgs) { + mQuery = query; + mBindArgs = bindArgs; + } + + public SimpleSQLiteQuery(String query) { + this(query, null); + } + + @Override + public String getSql() { + return mQuery; + } + + @Override + public void bindTo(SupportSQLiteProgram statement) { + bind(statement, mBindArgs); + } + + @Override + public int getArgCount() { + return mBindArgs == null ? 0 : mBindArgs.length; + } + + /// Binds `bindArgs[i]` at index `i + 1`, by the argument's type. A + /// boolean binds as 1 or 0, as Room stores it. + public static void bind(SupportSQLiteProgram statement, Object[] bindArgs) { + if (bindArgs == null) { + return; + } + for (int i = 0; i < bindArgs.length; i++) { + bind(statement, i + 1, bindArgs[i]); + } + } + + private static void bind(SupportSQLiteProgram statement, int index, Object arg) { + if (arg == null) { + statement.bindNull(index); + } else if (arg instanceof byte[]) { + statement.bindBlob(index, (byte[]) arg); + } else if (arg instanceof Float || arg instanceof Double) { + statement.bindDouble(index, ((Number) arg).doubleValue()); + } else if (arg instanceof Number) { + statement.bindLong(index, ((Number) arg).longValue()); + } else if (arg instanceof Boolean) { + statement.bindLong(index, ((Boolean) arg).booleanValue() ? 1 : 0); + } else if (arg instanceof Character) { + statement.bindLong(index, ((Character) arg).charValue()); + } else if (arg instanceof String) { + statement.bindString(index, (String) arg); + } else { + throw new IllegalArgumentException("Cannot bind " + arg + " at index " + index + + " Supported types: Null, ByteArray, Float, Double, Long, Int, Short, Byte, String"); + } + } +} diff --git a/maven/android-compat/src/main/java/androidx/sqlite/db/SupportSQLiteDatabase.java b/maven/android-compat/src/main/java/androidx/sqlite/db/SupportSQLiteDatabase.java new file mode 100644 index 00000000000..4e98903a211 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/sqlite/db/SupportSQLiteDatabase.java @@ -0,0 +1,94 @@ +/* + * 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 androidx.sqlite.db; + +import android.content.ContentValues; +import android.database.Cursor; + +/// The database a Room migration, callback or open helper works on. +public interface SupportSQLiteDatabase { + + SupportSQLiteStatement compileStatement(String sql); + + void beginTransaction(); + + void beginTransactionNonExclusive(); + + void endTransaction(); + + void setTransactionSuccessful(); + + boolean inTransaction(); + + boolean isDbLockedByCurrentThread(); + + boolean yieldIfContendedSafely(); + + int getVersion(); + + void setVersion(int version); + + long getMaximumSize(); + + long setMaximumSize(long numBytes); + + long getPageSize(); + + void setPageSize(long numBytes); + + Cursor query(String query); + + Cursor query(String query, Object[] bindArgs); + + Cursor query(SupportSQLiteQuery query); + + long insert(String table, int conflictAlgorithm, ContentValues values); + + int delete(String table, String whereClause, Object[] whereArgs); + + int update(String table, int conflictAlgorithm, ContentValues values, String whereClause, Object[] whereArgs); + + void execSQL(String sql); + + void execSQL(String sql, Object[] bindArgs); + + boolean isReadOnly(); + + boolean isOpen(); + + boolean needUpgrade(int newVersion); + + String getPath(); + + void setForeignKeyConstraintsEnabled(boolean enabled); + + boolean enableWriteAheadLogging(); + + void disableWriteAheadLogging(); + + boolean isWriteAheadLoggingEnabled(); + + boolean isDatabaseIntegrityOk(); + + void close(); +} diff --git a/maven/android-compat/src/main/java/androidx/sqlite/db/SupportSQLiteOpenHelper.java b/maven/android-compat/src/main/java/androidx/sqlite/db/SupportSQLiteOpenHelper.java new file mode 100644 index 00000000000..9b4186be90e --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/sqlite/db/SupportSQLiteOpenHelper.java @@ -0,0 +1,142 @@ +/* + * 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 androidx.sqlite.db; + +import android.content.Context; + +/// Opens, creates and upgrades a database, as `SQLiteOpenHelper` does. +public interface SupportSQLiteOpenHelper { + + String getDatabaseName(); + + void setWriteAheadLoggingEnabled(boolean enabled); + + SupportSQLiteDatabase getWritableDatabase(); + + SupportSQLiteDatabase getReadableDatabase(); + + void close(); + + /// The schema hooks the helper calls while it opens a database. + abstract class Callback { + + public final int version; + + public Callback(int version) { + this.version = version; + } + + public void onConfigure(SupportSQLiteDatabase db) { + } + + public abstract void onCreate(SupportSQLiteDatabase db); + + public abstract void onUpgrade(SupportSQLiteDatabase db, int oldVersion, int newVersion); + + public void onDowngrade(SupportSQLiteDatabase db, int oldVersion, int newVersion) { + throw new android.database.sqlite.SQLiteException("Can't downgrade database from version " + + oldVersion + " to " + newVersion); + } + + public void onOpen(SupportSQLiteDatabase db) { + } + + public void onCorruption(SupportSQLiteDatabase db) { + } + } + + /// What a factory needs to create a helper. + final class Configuration { + + public final Context context; + public final String name; + public final Callback callback; + public final boolean useNoBackupDirectory; + public final boolean allowDataLossOnRecovery; + + public Configuration(Context context, String name, Callback callback) { + this(context, name, callback, false, false); + } + + public Configuration(Context context, String name, Callback callback, boolean useNoBackupDirectory, + boolean allowDataLossOnRecovery) { + this.context = context; + this.name = name; + this.callback = callback; + this.useNoBackupDirectory = useNoBackupDirectory; + this.allowDataLossOnRecovery = allowDataLossOnRecovery; + } + + public static Builder builder(Context context) { + return new Builder(context); + } + + /// Builds a [Configuration]. + public static final class Builder { + private final Context context; + private String name; + private Callback callback; + private boolean noBackup; + private boolean allowDataLoss; + + Builder(Context context) { + this.context = context; + } + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder callback(Callback callback) { + this.callback = callback; + return this; + } + + public Builder noBackupDirectory(boolean noBackup) { + this.noBackup = noBackup; + return this; + } + + public Builder allowDataLossOnRecovery(boolean allow) { + this.allowDataLoss = allow; + return this; + } + + public Configuration build() { + if (callback == null) { + throw new IllegalArgumentException("Must set a callback to create the configuration."); + } + if (context == null) { + throw new IllegalArgumentException("Must set a non-null context to create the configuration."); + } + return new Configuration(context, name, callback, noBackup, allowDataLoss); + } + } + } + + /// Creates helpers. + interface Factory { + SupportSQLiteOpenHelper create(Configuration configuration); + } +} diff --git a/maven/android-compat/src/main/java/androidx/sqlite/db/SupportSQLiteProgram.java b/maven/android-compat/src/main/java/androidx/sqlite/db/SupportSQLiteProgram.java new file mode 100644 index 00000000000..2655bb76b7c --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/sqlite/db/SupportSQLiteProgram.java @@ -0,0 +1,41 @@ +/* + * 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 androidx.sqlite.db; + +/// A compiled statement's bindings; indices start at 1. +public interface SupportSQLiteProgram { + + void bindNull(int index); + + void bindLong(int index, long value); + + void bindDouble(int index, double value); + + void bindString(int index, String value); + + void bindBlob(int index, byte[] value); + + void clearBindings(); + + void close(); +} diff --git a/maven/android-compat/src/main/java/androidx/sqlite/db/SupportSQLiteQuery.java b/maven/android-compat/src/main/java/androidx/sqlite/db/SupportSQLiteQuery.java new file mode 100644 index 00000000000..c5fd69ed9c6 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/sqlite/db/SupportSQLiteQuery.java @@ -0,0 +1,33 @@ +/* + * 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 androidx.sqlite.db; + +/// A query and the arguments it binds. +public interface SupportSQLiteQuery { + + String getSql(); + + void bindTo(SupportSQLiteProgram statement); + + int getArgCount(); +} diff --git a/maven/android-compat/src/main/java/androidx/sqlite/db/SupportSQLiteStatement.java b/maven/android-compat/src/main/java/androidx/sqlite/db/SupportSQLiteStatement.java new file mode 100644 index 00000000000..d70641906c7 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/sqlite/db/SupportSQLiteStatement.java @@ -0,0 +1,37 @@ +/* + * 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 androidx.sqlite.db; + +/// A statement compiled once and run many times. +public interface SupportSQLiteStatement extends SupportSQLiteProgram { + + void execute(); + + int executeUpdateDelete(); + + long executeInsert(); + + long simpleQueryForLong(); + + String simpleQueryForString(); +} diff --git a/maven/android-compat/src/main/java/androidx/sqlite/db/framework/FrameworkSQLiteDatabase.java b/maven/android-compat/src/main/java/androidx/sqlite/db/framework/FrameworkSQLiteDatabase.java new file mode 100644 index 00000000000..3e728f1bb64 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/sqlite/db/framework/FrameworkSQLiteDatabase.java @@ -0,0 +1,318 @@ +/* + * 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 androidx.sqlite.db.framework; + +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteCursor; +import android.database.sqlite.SQLiteCursorDriver; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteQuery; +import androidx.sqlite.db.SimpleSQLiteQuery; +import androidx.sqlite.db.SupportSQLiteDatabase; +import androidx.sqlite.db.SupportSQLiteProgram; +import androidx.sqlite.db.SupportSQLiteQuery; +import androidx.sqlite.db.SupportSQLiteStatement; + +/// A [SupportSQLiteDatabase] over the platform database. Queries bind their +/// arguments by type, through a cursor factory, as AndroidX does: the +/// platform's `rawQuery` takes strings only. +public final class FrameworkSQLiteDatabase implements SupportSQLiteDatabase { + + private static final String[] CONFLICT_VALUES = {"", " OR ROLLBACK ", " OR ABORT ", " OR FAIL ", " OR IGNORE ", + " OR REPLACE "}; + private static final String[] EMPTY_STRING_ARRAY = new String[0]; + + private final SQLiteDatabase mDelegate; + + public FrameworkSQLiteDatabase(SQLiteDatabase delegate) { + mDelegate = delegate; + } + + /// The platform database this one wraps. + public SQLiteDatabase getDelegate() { + return mDelegate; + } + + @Override + public SupportSQLiteStatement compileStatement(String sql) { + return new FrameworkSQLiteStatement(mDelegate.compileStatement(sql)); + } + + @Override + public void beginTransaction() { + mDelegate.beginTransaction(); + } + + @Override + public void beginTransactionNonExclusive() { + mDelegate.beginTransactionNonExclusive(); + } + + @Override + public void endTransaction() { + mDelegate.endTransaction(); + } + + @Override + public void setTransactionSuccessful() { + mDelegate.setTransactionSuccessful(); + } + + @Override + public boolean inTransaction() { + return mDelegate.inTransaction(); + } + + @Override + public boolean isDbLockedByCurrentThread() { + return mDelegate.isDbLockedByCurrentThread(); + } + + @Override + public boolean yieldIfContendedSafely() { + return mDelegate.yieldIfContendedSafely(); + } + + @Override + public int getVersion() { + return mDelegate.getVersion(); + } + + @Override + public void setVersion(int version) { + mDelegate.setVersion(version); + } + + @Override + public long getMaximumSize() { + return mDelegate.getMaximumSize(); + } + + @Override + public long setMaximumSize(long numBytes) { + return mDelegate.setMaximumSize(numBytes); + } + + @Override + public long getPageSize() { + return mDelegate.getPageSize(); + } + + @Override + public void setPageSize(long numBytes) { + mDelegate.setPageSize(numBytes); + } + + @Override + public Cursor query(String query) { + return query(new SimpleSQLiteQuery(query)); + } + + @Override + public Cursor query(String query, Object[] bindArgs) { + return query(new SimpleSQLiteQuery(query, bindArgs)); + } + + @Override + public Cursor query(SupportSQLiteQuery supportQuery) { + return mDelegate.rawQueryWithFactory(new TypedCursorFactory(supportQuery), supportQuery.getSql(), + EMPTY_STRING_ARRAY, null); + } + + @Override + public long insert(String table, int conflictAlgorithm, ContentValues values) { + return mDelegate.insertWithOnConflict(table, null, values, conflictAlgorithm); + } + + @Override + public int delete(String table, String whereClause, Object[] whereArgs) { + String sql = "DELETE FROM " + table + (isEmpty(whereClause) ? "" : " WHERE " + whereClause); + SupportSQLiteStatement statement = compileStatement(sql); + try { + SimpleSQLiteQuery.bind(statement, whereArgs); + return statement.executeUpdateDelete(); + } finally { + statement.close(); + } + } + + @Override + public int update(String table, int conflictAlgorithm, ContentValues values, String whereClause, + Object[] whereArgs) { + if (values == null || values.size() == 0) { + throw new IllegalArgumentException("Empty values"); + } + StringBuilder sql = new StringBuilder(120); + sql.append("UPDATE ").append(CONFLICT_VALUES[conflictAlgorithm]).append(table).append(" SET "); + int setValuesSize = values.size(); + int bindArgsSize = whereArgs == null ? setValuesSize : setValuesSize + whereArgs.length; + Object[] bindArgs = new Object[bindArgsSize]; + int i = 0; + for (String colName : values.keySet()) { + sql.append(i > 0 ? "," : ""); + sql.append(colName); + bindArgs[i++] = values.get(colName); + sql.append("=?"); + } + if (whereArgs != null) { + for (i = setValuesSize; i < bindArgsSize; i++) { + bindArgs[i] = whereArgs[i - setValuesSize]; + } + } + if (!isEmpty(whereClause)) { + sql.append(" WHERE ").append(whereClause); + } + SupportSQLiteStatement statement = compileStatement(sql.toString()); + try { + SimpleSQLiteQuery.bind(statement, bindArgs); + return statement.executeUpdateDelete(); + } finally { + statement.close(); + } + } + + @Override + public void execSQL(String sql) { + mDelegate.execSQL(sql); + } + + @Override + public void execSQL(String sql, Object[] bindArgs) { + mDelegate.execSQL(sql, bindArgs); + } + + @Override + public boolean isReadOnly() { + return mDelegate.isReadOnly(); + } + + @Override + public boolean isOpen() { + return mDelegate.isOpen(); + } + + @Override + public boolean needUpgrade(int newVersion) { + return mDelegate.needUpgrade(newVersion); + } + + @Override + public String getPath() { + return mDelegate.getPath(); + } + + @Override + public void setForeignKeyConstraintsEnabled(boolean enabled) { + mDelegate.setForeignKeyConstraintsEnabled(enabled); + } + + @Override + public boolean enableWriteAheadLogging() { + return mDelegate.enableWriteAheadLogging(); + } + + @Override + public void disableWriteAheadLogging() { + mDelegate.disableWriteAheadLogging(); + } + + @Override + public boolean isWriteAheadLoggingEnabled() { + return mDelegate.isWriteAheadLoggingEnabled(); + } + + @Override + public boolean isDatabaseIntegrityOk() { + return mDelegate.isDatabaseIntegrityOk(); + } + + @Override + public void close() { + mDelegate.close(); + } + + private static boolean isEmpty(String s) { + return s == null || s.length() == 0; + } + + /// Binds a support query's arguments, by type, before the cursor runs it. + private static final class TypedCursorFactory implements SQLiteDatabase.CursorFactory { + private final SupportSQLiteQuery mSupportQuery; + + TypedCursorFactory(SupportSQLiteQuery supportQuery) { + mSupportQuery = supportQuery; + } + + @Override + public Cursor newCursor(SQLiteDatabase db, SQLiteCursorDriver masterQuery, String editTable, + SQLiteQuery query) { + mSupportQuery.bindTo(new QueryProgram(query)); + return new SQLiteCursor(masterQuery, editTable, query); + } + } + + /// Binds a support query's arguments into the platform query. + private static final class QueryProgram implements SupportSQLiteProgram { + private final SQLiteQuery mQuery; + + QueryProgram(SQLiteQuery query) { + mQuery = query; + } + + @Override + public void bindNull(int index) { + mQuery.bindNull(index); + } + + @Override + public void bindLong(int index, long value) { + mQuery.bindLong(index, value); + } + + @Override + public void bindDouble(int index, double value) { + mQuery.bindDouble(index, value); + } + + @Override + public void bindString(int index, String value) { + mQuery.bindString(index, value); + } + + @Override + public void bindBlob(int index, byte[] value) { + mQuery.bindBlob(index, value); + } + + @Override + public void clearBindings() { + mQuery.clearBindings(); + } + + @Override + public void close() { + // The cursor owns the query. + } + } +} diff --git a/maven/android-compat/src/main/java/androidx/sqlite/db/framework/FrameworkSQLiteOpenHelper.java b/maven/android-compat/src/main/java/androidx/sqlite/db/framework/FrameworkSQLiteOpenHelper.java new file mode 100644 index 00000000000..992f8635e10 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/sqlite/db/framework/FrameworkSQLiteOpenHelper.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 androidx.sqlite.db.framework; + +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; +import androidx.sqlite.db.SupportSQLiteDatabase; +import androidx.sqlite.db.SupportSQLiteOpenHelper; + +/// A [SupportSQLiteOpenHelper] over the platform `SQLiteOpenHelper`, so the +/// version check, the transaction around a create or upgrade and the +/// minimum-version handling are the platform's. +final class FrameworkSQLiteOpenHelper implements SupportSQLiteOpenHelper { + + private final Helper mHelper; + + FrameworkSQLiteOpenHelper(SupportSQLiteOpenHelper.Configuration configuration) { + mHelper = new Helper(configuration); + } + + @Override + public String getDatabaseName() { + return mHelper.getDatabaseName(); + } + + @Override + public void setWriteAheadLoggingEnabled(boolean enabled) { + mHelper.setWriteAheadLoggingEnabled(enabled); + } + + @Override + public SupportSQLiteDatabase getWritableDatabase() { + return mHelper.wrap(mHelper.getWritableDatabase()); + } + + @Override + public SupportSQLiteDatabase getReadableDatabase() { + return mHelper.wrap(mHelper.getReadableDatabase()); + } + + @Override + public void close() { + mHelper.close(); + } + + /// Hands every hook the one wrapper of the database it is given. + private static final class Helper extends SQLiteOpenHelper { + private final SupportSQLiteOpenHelper.Callback mCallback; + private FrameworkSQLiteDatabase mWrapped; + + Helper(SupportSQLiteOpenHelper.Configuration configuration) { + super(configuration.context, configuration.name, null, configuration.callback.version); + mCallback = configuration.callback; + } + + FrameworkSQLiteDatabase wrap(SQLiteDatabase db) { + if (mWrapped == null || mWrapped.getDelegate() != db) { + mWrapped = new FrameworkSQLiteDatabase(db); + } + return mWrapped; + } + + @Override + public void onConfigure(SQLiteDatabase db) { + mCallback.onConfigure(wrap(db)); + } + + @Override + public void onCreate(SQLiteDatabase db) { + mCallback.onCreate(wrap(db)); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + mCallback.onUpgrade(wrap(db), oldVersion, newVersion); + } + + @Override + public void onDowngrade(SQLiteDatabase db, int oldVersion, int newVersion) { + mCallback.onDowngrade(wrap(db), oldVersion, newVersion); + } + + @Override + public void onOpen(SQLiteDatabase db) { + mCallback.onOpen(wrap(db)); + } + + @Override + public void close() { + super.close(); + mWrapped = null; + } + } +} diff --git a/maven/android-compat/src/main/java/androidx/sqlite/db/framework/FrameworkSQLiteOpenHelperFactory.java b/maven/android-compat/src/main/java/androidx/sqlite/db/framework/FrameworkSQLiteOpenHelperFactory.java new file mode 100644 index 00000000000..e9b6047bcda --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/sqlite/db/framework/FrameworkSQLiteOpenHelperFactory.java @@ -0,0 +1,34 @@ +/* + * 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 androidx.sqlite.db.framework; + +import androidx.sqlite.db.SupportSQLiteOpenHelper; + +/// Creates helpers over the platform database, Room's default. +public final class FrameworkSQLiteOpenHelperFactory implements SupportSQLiteOpenHelper.Factory { + + @Override + public SupportSQLiteOpenHelper create(SupportSQLiteOpenHelper.Configuration configuration) { + return new FrameworkSQLiteOpenHelper(configuration); + } +} diff --git a/maven/android-compat/src/main/java/androidx/sqlite/db/framework/FrameworkSQLiteStatement.java b/maven/android-compat/src/main/java/androidx/sqlite/db/framework/FrameworkSQLiteStatement.java new file mode 100644 index 00000000000..71ad275c6f6 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/sqlite/db/framework/FrameworkSQLiteStatement.java @@ -0,0 +1,96 @@ +/* + * 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 androidx.sqlite.db.framework; + +import android.database.sqlite.SQLiteStatement; +import androidx.sqlite.db.SupportSQLiteStatement; + +/// A [SupportSQLiteStatement] over the platform statement. +final class FrameworkSQLiteStatement implements SupportSQLiteStatement { + + private final SQLiteStatement mDelegate; + + FrameworkSQLiteStatement(SQLiteStatement delegate) { + mDelegate = delegate; + } + + @Override + public void bindNull(int index) { + mDelegate.bindNull(index); + } + + @Override + public void bindLong(int index, long value) { + mDelegate.bindLong(index, value); + } + + @Override + public void bindDouble(int index, double value) { + mDelegate.bindDouble(index, value); + } + + @Override + public void bindString(int index, String value) { + mDelegate.bindString(index, value); + } + + @Override + public void bindBlob(int index, byte[] value) { + mDelegate.bindBlob(index, value); + } + + @Override + public void clearBindings() { + mDelegate.clearBindings(); + } + + @Override + public void close() { + mDelegate.close(); + } + + @Override + public void execute() { + mDelegate.execute(); + } + + @Override + public int executeUpdateDelete() { + return mDelegate.executeUpdateDelete(); + } + + @Override + public long executeInsert() { + return mDelegate.executeInsert(); + } + + @Override + public long simpleQueryForLong() { + return mDelegate.simpleQueryForLong(); + } + + @Override + public String simpleQueryForString() { + return mDelegate.simpleQueryForString(); + } +} diff --git a/maven/android-compat/src/main/java/androidx/work/BackoffPolicy.java b/maven/android-compat/src/main/java/androidx/work/BackoffPolicy.java new file mode 100644 index 00000000000..56227e81e2d --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/work/BackoffPolicy.java @@ -0,0 +1,27 @@ +/* + * 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 androidx.work; + +public enum BackoffPolicy { + EXPONENTIAL, LINEAR +} diff --git a/maven/android-compat/src/main/java/androidx/work/Configuration.java b/maven/android-compat/src/main/java/androidx/work/Configuration.java new file mode 100644 index 00000000000..134204a5e39 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/work/Configuration.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 androidx.work; + +import java.util.concurrent.Executor; + +public final class Configuration { + + final WorkerFactory workerFactory; + final Executor executor; + + Configuration(Builder b) { + workerFactory = b.workerFactory; + executor = b.executor; + } + + public WorkerFactory getWorkerFactory() { + return workerFactory; + } + + public Executor getExecutor() { + return executor; + } + + public interface Provider { + Configuration getWorkManagerConfiguration(); + } + + public static final class Builder { + WorkerFactory workerFactory; + Executor executor; + + public Builder setWorkerFactory(WorkerFactory workerFactory) { + this.workerFactory = workerFactory; + return this; + } + + public Builder setExecutor(Executor executor) { + this.executor = executor; + return this; + } + + public Builder setTaskExecutor(Executor executor) { + return this; + } + + public Builder setMinimumLoggingLevel(int level) { + return this; + } + + public Builder setJobSchedulerJobIdRange(int min, int max) { + return this; + } + + public Builder setMaxSchedulerLimit(int limit) { + return this; + } + + public Builder setDefaultProcessName(String name) { + return this; + } + + public Configuration build() { + return new Configuration(this); + } + } +} diff --git a/maven/android-compat/src/main/java/androidx/work/Constraints.java b/maven/android-compat/src/main/java/androidx/work/Constraints.java new file mode 100644 index 00000000000..2f28920a678 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/work/Constraints.java @@ -0,0 +1,113 @@ +/* + * 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 androidx.work; + +/// What must hold before a request runs. Codename One's background scheduler +/// enforces all of them for work it runs while the application is in the +/// background; see [WorkManager] for what is checked in the foreground. +public final class Constraints { + + public static final Constraints NONE = new Builder().build(); + + final NetworkType networkType; + final boolean requiresCharging; + final boolean requiresBatteryNotLow; + final boolean requiresDeviceIdle; + final boolean requiresStorageNotLow; + + Constraints(NetworkType networkType, boolean charging, boolean batteryNotLow, boolean idle, + boolean storageNotLow) { + this.networkType = networkType; + this.requiresCharging = charging; + this.requiresBatteryNotLow = batteryNotLow; + this.requiresDeviceIdle = idle; + this.requiresStorageNotLow = storageNotLow; + } + + public NetworkType getRequiredNetworkType() { + return networkType; + } + + public boolean requiresCharging() { + return requiresCharging; + } + + public boolean requiresBatteryNotLow() { + return requiresBatteryNotLow; + } + + public boolean requiresDeviceIdle() { + return requiresDeviceIdle; + } + + public boolean requiresStorageNotLow() { + return requiresStorageNotLow; + } + + /// Constraints only the platform scheduler can judge: there is no + /// Codename One API that reports charging, idle or storage state. + boolean needsPlatformScheduler() { + return requiresCharging || requiresBatteryNotLow || requiresDeviceIdle || requiresStorageNotLow; + } + + public static final class Builder { + private NetworkType networkType = NetworkType.NOT_REQUIRED; + private boolean charging; + private boolean batteryNotLow; + private boolean idle; + private boolean storageNotLow; + + public Builder setRequiredNetworkType(NetworkType networkType) { + this.networkType = networkType; + return this; + } + + public Builder setRequiresCharging(boolean requiresCharging) { + charging = requiresCharging; + return this; + } + + public Builder setRequiresBatteryNotLow(boolean requiresBatteryNotLow) { + batteryNotLow = requiresBatteryNotLow; + return this; + } + + public Builder setRequiresDeviceIdle(boolean requiresDeviceIdle) { + idle = requiresDeviceIdle; + return this; + } + + public Builder setRequiresStorageNotLow(boolean requiresStorageNotLow) { + storageNotLow = requiresStorageNotLow; + return this; + } + + public Builder addContentUriTrigger(android.net.Uri uri, boolean triggerForDescendants) { + return this; + } + + public Constraints build() { + return new Constraints(networkType, charging, batteryNotLow, idle, storageNotLow); + } + } +} diff --git a/maven/android-compat/src/main/java/androidx/work/Data.java b/maven/android-compat/src/main/java/androidx/work/Data.java new file mode 100644 index 00000000000..6db03e8f940 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/work/Data.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 androidx.work; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/// The immutable key/value input and output of a worker. Values are the +/// primitive types, `String` and their arrays, as on Android; the limit of +/// [#MAX_DATA_BYTES] is checked against the serialized form this runtime +/// persists. +public final class Data { + + public static final int MAX_DATA_BYTES = 10 * 1024; + public static final Data EMPTY = new Data(new HashMap()); + + final Map values; + + Data(Map values) { + this.values = values; + } + + public Data(Data other) { + this(new HashMap(other.values)); + } + + public boolean getBoolean(String key, boolean defaultValue) { + Object v = values.get(key); + return v instanceof Boolean ? ((Boolean) v).booleanValue() : defaultValue; + } + + public boolean[] getBooleanArray(String key) { + Object v = values.get(key); + return v instanceof boolean[] ? (boolean[]) v : null; + } + + public byte getByte(String key, byte defaultValue) { + Object v = values.get(key); + return v instanceof Byte ? ((Byte) v).byteValue() : defaultValue; + } + + public byte[] getByteArray(String key) { + Object v = values.get(key); + return v instanceof byte[] ? (byte[]) v : null; + } + + public int getInt(String key, int defaultValue) { + Object v = values.get(key); + return v instanceof Integer ? ((Integer) v).intValue() : defaultValue; + } + + public int[] getIntArray(String key) { + Object v = values.get(key); + return v instanceof int[] ? (int[]) v : null; + } + + public long getLong(String key, long defaultValue) { + Object v = values.get(key); + return v instanceof Long ? ((Long) v).longValue() : defaultValue; + } + + public long[] getLongArray(String key) { + Object v = values.get(key); + return v instanceof long[] ? (long[]) v : null; + } + + public float getFloat(String key, float defaultValue) { + Object v = values.get(key); + return v instanceof Float ? ((Float) v).floatValue() : defaultValue; + } + + public float[] getFloatArray(String key) { + Object v = values.get(key); + return v instanceof float[] ? (float[]) v : null; + } + + public double getDouble(String key, double defaultValue) { + Object v = values.get(key); + return v instanceof Double ? ((Double) v).doubleValue() : defaultValue; + } + + public double[] getDoubleArray(String key) { + Object v = values.get(key); + return v instanceof double[] ? (double[]) v : null; + } + + public String getString(String key) { + Object v = values.get(key); + return v instanceof String ? (String) v : null; + } + + public String[] getStringArray(String key) { + Object v = values.get(key); + return v instanceof String[] ? (String[]) v : null; + } + + public Map getKeyValueMap() { + return Collections.unmodifiableMap(values); + } + + public boolean hasKeyWithValueOfType(String key, Class klass) { + Object v = values.get(key); + return v != null && klass.isInstance(v); + } + + public int size() { + return values.size(); + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof Data)) { + return false; + } + Data other = (Data) o; + if (other.values.size() != values.size()) { + return false; + } + for (Map.Entry e : values.entrySet()) { + Object a = e.getValue(); + Object b = other.values.get(e.getKey()); + if (!WorkStore.valueEquals(a, b)) { + return false; + } + } + return true; + } + + @Override + public int hashCode() { + return values.keySet().hashCode(); + } + + @Override + public String toString() { + return "Data " + values.keySet(); + } + + public static final class Builder { + private final Map values = new HashMap(); + + public Builder putBoolean(String key, boolean value) { + values.put(key, Boolean.valueOf(value)); + return this; + } + + public Builder putBooleanArray(String key, boolean[] value) { + values.put(key, value == null ? null : value.clone()); + return this; + } + + public Builder putByte(String key, byte value) { + values.put(key, Byte.valueOf(value)); + return this; + } + + public Builder putByteArray(String key, byte[] value) { + values.put(key, value == null ? null : value.clone()); + return this; + } + + public Builder putInt(String key, int value) { + values.put(key, Integer.valueOf(value)); + return this; + } + + public Builder putIntArray(String key, int[] value) { + values.put(key, value == null ? null : value.clone()); + return this; + } + + public Builder putLong(String key, long value) { + values.put(key, Long.valueOf(value)); + return this; + } + + public Builder putLongArray(String key, long[] value) { + values.put(key, value == null ? null : value.clone()); + return this; + } + + public Builder putFloat(String key, float value) { + values.put(key, Float.valueOf(value)); + return this; + } + + public Builder putFloatArray(String key, float[] value) { + values.put(key, value == null ? null : value.clone()); + return this; + } + + public Builder putDouble(String key, double value) { + values.put(key, Double.valueOf(value)); + return this; + } + + public Builder putDoubleArray(String key, double[] value) { + values.put(key, value == null ? null : value.clone()); + return this; + } + + public Builder putString(String key, String value) { + values.put(key, value); + return this; + } + + public Builder putStringArray(String key, String[] value) { + values.put(key, value == null ? null : value.clone()); + return this; + } + + public Builder putAll(Data data) { + values.putAll(data.values); + return this; + } + + /// Values of an unsupported type are refused, as on Android. + public Builder putAll(Map map) { + for (Map.Entry e : map.entrySet()) { + put(e.getKey(), e.getValue()); + } + return this; + } + + public Builder put(String key, Object value) { + if (value != null && !WorkStore.isSupportedValue(value)) { + throw new IllegalArgumentException("Key " + key + " has invalid type " + value.getClass()); + } + values.put(key, value); + return this; + } + + public Data build() { + Data d = new Data(new HashMap(values)); + if (WorkStore.encodedSize(d) > MAX_DATA_BYTES) { + throw new IllegalStateException("Data cannot occupy more than " + MAX_DATA_BYTES + " bytes when serialized"); + } + return d; + } + } +} diff --git a/maven/android-compat/src/main/java/androidx/work/DelegatingWorkerFactory.java b/maven/android-compat/src/main/java/androidx/work/DelegatingWorkerFactory.java new file mode 100644 index 00000000000..f563aab1de7 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/work/DelegatingWorkerFactory.java @@ -0,0 +1,49 @@ +/* + * 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 androidx.work; + +import android.content.Context; + +import java.util.ArrayList; +import java.util.List; + +public class DelegatingWorkerFactory extends WorkerFactory { + + private final List factories = new ArrayList(); + + public final void addFactory(WorkerFactory factory) { + factories.add(factory); + } + + @Override + public final ListenableWorker createWorker(Context appContext, String workerClassName, + WorkerParameters workerParameters) { + for (int i = 0; i < factories.size(); i++) { + ListenableWorker w = factories.get(i).createWorker(appContext, workerClassName, workerParameters); + if (w != null) { + return w; + } + } + return null; + } +} diff --git a/maven/android-compat/src/main/java/androidx/work/ExistingPeriodicWorkPolicy.java b/maven/android-compat/src/main/java/androidx/work/ExistingPeriodicWorkPolicy.java new file mode 100644 index 00000000000..0cc9d6dea01 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/work/ExistingPeriodicWorkPolicy.java @@ -0,0 +1,27 @@ +/* + * 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 androidx.work; + +public enum ExistingPeriodicWorkPolicy { + REPLACE, KEEP, UPDATE, CANCEL_AND_REENQUEUE +} diff --git a/maven/android-compat/src/main/java/androidx/work/ExistingWorkPolicy.java b/maven/android-compat/src/main/java/androidx/work/ExistingWorkPolicy.java new file mode 100644 index 00000000000..c17ad0f395e --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/work/ExistingWorkPolicy.java @@ -0,0 +1,27 @@ +/* + * 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 androidx.work; + +public enum ExistingWorkPolicy { + REPLACE, KEEP, APPEND, APPEND_OR_REPLACE +} diff --git a/maven/android-compat/src/main/java/androidx/work/ForegroundInfo.java b/maven/android-compat/src/main/java/androidx/work/ForegroundInfo.java new file mode 100644 index 00000000000..4b5a7e64de8 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/work/ForegroundInfo.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 androidx.work; + +import android.app.Notification; + +public final class ForegroundInfo { + + private final int notificationId; + private final Notification notification; + private final int foregroundServiceType; + + public ForegroundInfo(int notificationId, Notification notification) { + this(notificationId, notification, 0); + } + + public ForegroundInfo(int notificationId, Notification notification, int foregroundServiceType) { + this.notificationId = notificationId; + this.notification = notification; + this.foregroundServiceType = foregroundServiceType; + } + + public int getNotificationId() { + return notificationId; + } + + public Notification getNotification() { + return notification; + } + + public int getForegroundServiceType() { + return foregroundServiceType; + } +} diff --git a/maven/android-compat/src/main/java/androidx/work/ListenableWorker.java b/maven/android-compat/src/main/java/androidx/work/ListenableWorker.java new file mode 100644 index 00000000000..5c227d11bcd --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/work/ListenableWorker.java @@ -0,0 +1,195 @@ +/* + * 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 androidx.work; + +import android.content.Context; + +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.Future; + +/// The base of every worker. [#startWork()] is called on a background thread +/// and its future is waited for there. +/// +/// AndroidX returns Guava's `ListenableFuture` from `startWork`, +/// `setProgressAsync` and `setForegroundAsync`. Guava is not part of this +/// runtime, so they return `java.util.concurrent.Future`: a [Worker] is +/// unaffected, and a direct subclass must return a plain future. +public abstract class ListenableWorker { + + private final Context appContext; + private final WorkerParameters params; + private boolean stopped; + private boolean used; + + public ListenableWorker(Context appContext, WorkerParameters workerParams) { + if (appContext == null) { + throw new IllegalArgumentException("Application Context is null"); + } + if (workerParams == null) { + throw new IllegalArgumentException("WorkerParameters is null"); + } + this.appContext = appContext; + this.params = workerParams; + } + + public final Context getApplicationContext() { + return appContext; + } + + public final UUID getId() { + return params.getId(); + } + + public final Data getInputData() { + return params.getInputData(); + } + + public final Set getTags() { + return params.getTags(); + } + + public final int getRunAttemptCount() { + return params.getRunAttemptCount(); + } + + public abstract Future startWork(); + + public final boolean isStopped() { + return stopped; + } + + public void onStopped() { + } + + public Future setProgressAsync(Data data) { + params.progress.set(getId(), data); + return WorkEngine.done(null); + } + + /// Runs the rest of this work under a foreground notification, which on + /// this runtime is the notification itself: posted through + /// `NotificationManager`, so a long worker is visible as on Android. + public final Future setForegroundAsync(ForegroundInfo info) { + android.app.NotificationManager.getInstance().notify(info.getNotificationId(), info.getNotification()); + return WorkEngine.done(null); + } + + final void stop() { + stopped = true; + onStopped(); + } + + final boolean markUsed() { + boolean was = used; + used = true; + return was; + } + + public abstract static class Result { + + Result() { + } + + public static Result success() { + return new Success(Data.EMPTY); + } + + public static Result success(Data outputData) { + return new Success(outputData); + } + + public static Result retry() { + return new Retry(); + } + + public static Result failure() { + return new Failure(Data.EMPTY); + } + + public static Result failure(Data outputData) { + return new Failure(outputData); + } + + public abstract Data getOutputData(); + + public static final class Success extends Result { + private final Data output; + + public Success() { + this(Data.EMPTY); + } + + public Success(Data output) { + this.output = output == null ? Data.EMPTY : output; + } + + @Override + public Data getOutputData() { + return output; + } + + @Override + public String toString() { + return "Success {mOutputData=" + output + "}"; + } + } + + public static final class Failure extends Result { + private final Data output; + + public Failure() { + this(Data.EMPTY); + } + + public Failure(Data output) { + this.output = output == null ? Data.EMPTY : output; + } + + @Override + public Data getOutputData() { + return output; + } + + @Override + public String toString() { + return "Failure {mOutputData=" + output + "}"; + } + } + + public static final class Retry extends Result { + public Retry() { + } + + @Override + public Data getOutputData() { + return Data.EMPTY; + } + + @Override + public String toString() { + return "Retry"; + } + } + } +} diff --git a/maven/android-compat/src/main/java/androidx/work/NetworkType.java b/maven/android-compat/src/main/java/androidx/work/NetworkType.java new file mode 100644 index 00000000000..021816c12a2 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/work/NetworkType.java @@ -0,0 +1,27 @@ +/* + * 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 androidx.work; + +public enum NetworkType { + NOT_REQUIRED, CONNECTED, UNMETERED, NOT_ROAMING, METERED, TEMPORARILY_UNMETERED +} diff --git a/maven/android-compat/src/main/java/androidx/work/OneTimeWorkRequest.java b/maven/android-compat/src/main/java/androidx/work/OneTimeWorkRequest.java new file mode 100644 index 00000000000..31979348e33 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/work/OneTimeWorkRequest.java @@ -0,0 +1,68 @@ +/* + * 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 androidx.work; + +import java.util.ArrayList; +import java.util.List; + +public final class OneTimeWorkRequest extends WorkRequest { + + OneTimeWorkRequest(WorkSpec spec) { + super(spec); + } + + public static OneTimeWorkRequest from(Class workerClass) { + return new Builder(workerClass).build(); + } + + public static List from(List> workerClasses) { + List out = new ArrayList(); + for (int i = 0; i < workerClasses.size(); i++) { + out.add(from(workerClasses.get(i))); + } + return out; + } + + public static final class Builder extends WorkRequest.Builder { + + public Builder(Class workerClass) { + super(workerClass); + } + + /// Every merger here overwrites, like Android's default + /// `OverwritingInputMerger`. + public Builder setInputMerger(Class inputMerger) { + return this; + } + + @Override + Builder thisObject() { + return this; + } + + @Override + OneTimeWorkRequest buildInternal() { + return new OneTimeWorkRequest(spec.copy()); + } + } +} diff --git a/maven/android-compat/src/main/java/androidx/work/Operation.java b/maven/android-compat/src/main/java/androidx/work/Operation.java new file mode 100644 index 00000000000..21ac1119c2f --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/work/Operation.java @@ -0,0 +1,81 @@ +/* + * 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 androidx.work; + +import androidx.lifecycle.LiveData; + +import java.util.concurrent.Future; + +/// The outcome of an enqueue or cancel. Both are applied on the main thread, +/// so the state is already `SUCCESS` once the main thread has handled it. +public interface Operation { + + State.SUCCESS SUCCESS = new State.SUCCESS(); + State.IN_PROGRESS IN_PROGRESS = new State.IN_PROGRESS(); + + LiveData getState(); + + Future getResult(); + + abstract class State { + State() { + } + + public static final class SUCCESS extends State { + SUCCESS() { + } + + @Override + public String toString() { + return "SUCCESS"; + } + } + + public static final class IN_PROGRESS extends State { + IN_PROGRESS() { + } + + @Override + public String toString() { + return "IN_PROGRESS"; + } + } + + public static final class FAILURE extends State { + private final Throwable exception; + + public FAILURE(Throwable exception) { + this.exception = exception; + } + + public Throwable getThrowable() { + return exception; + } + + @Override + public String toString() { + return "FAILURE (" + exception + ")"; + } + } + } +} diff --git a/maven/android-compat/src/main/java/androidx/work/OutOfQuotaPolicy.java b/maven/android-compat/src/main/java/androidx/work/OutOfQuotaPolicy.java new file mode 100644 index 00000000000..58d03cbfb73 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/work/OutOfQuotaPolicy.java @@ -0,0 +1,27 @@ +/* + * 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 androidx.work; + +public enum OutOfQuotaPolicy { + RUN_AS_NON_EXPEDITED_WORK_REQUEST, DROP_WORK_REQUEST +} diff --git a/maven/android-compat/src/main/java/androidx/work/PeriodicWorkRequest.java b/maven/android-compat/src/main/java/androidx/work/PeriodicWorkRequest.java new file mode 100644 index 00000000000..821a3fdcbde --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/work/PeriodicWorkRequest.java @@ -0,0 +1,62 @@ +/* + * 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 androidx.work; + +import java.util.concurrent.TimeUnit; + +public final class PeriodicWorkRequest extends WorkRequest { + + public static final long MIN_PERIODIC_INTERVAL_MILLIS = 15 * 60 * 1000L; + public static final long MIN_PERIODIC_FLEX_MILLIS = 5 * 60 * 1000L; + + PeriodicWorkRequest(WorkSpec spec) { + super(spec); + } + + public static final class Builder extends WorkRequest.Builder { + + public Builder(Class workerClass, long repeatInterval, TimeUnit repeatIntervalTimeUnit) { + super(workerClass); + // Android raises a shorter interval to the minimum rather than refusing it. + spec.intervalMillis = Math.max(MIN_PERIODIC_INTERVAL_MILLIS, repeatIntervalTimeUnit.toMillis(repeatInterval)); + spec.flexMillis = spec.intervalMillis; + } + + public Builder(Class workerClass, long repeatInterval, TimeUnit repeatIntervalTimeUnit, + long flexInterval, TimeUnit flexIntervalTimeUnit) { + this(workerClass, repeatInterval, repeatIntervalTimeUnit); + long flex = flexIntervalTimeUnit.toMillis(flexInterval); + spec.flexMillis = Math.max(MIN_PERIODIC_FLEX_MILLIS, Math.min(spec.intervalMillis, flex)); + } + + @Override + Builder thisObject() { + return this; + } + + @Override + PeriodicWorkRequest buildInternal() { + return new PeriodicWorkRequest(spec.copy()); + } + } +} diff --git a/maven/android-compat/src/main/java/androidx/work/WorkContinuation.java b/maven/android-compat/src/main/java/androidx/work/WorkContinuation.java new file mode 100644 index 00000000000..67f866ed97a --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/work/WorkContinuation.java @@ -0,0 +1,165 @@ +/* + * 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 androidx.work; + +import androidx.lifecycle.LiveData; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.Future; + +/// A chain of one-time requests: each stage starts once every request of the +/// stage before it succeeded, and gets their outputs merged into its input. +public class WorkContinuation { + + final WorkManager manager; + /// Every request of the chain, each a copy owning its prerequisites. + final List specs; + /// The requests of the last stage, which a `then` waits for. + final List leaves; + final String uniqueName; + final ExistingWorkPolicy policy; + + WorkContinuation(WorkManager manager, List specs, List leaves, String uniqueName, + ExistingWorkPolicy policy) { + this.manager = manager; + this.specs = specs; + this.leaves = leaves; + this.uniqueName = uniqueName; + this.policy = policy; + } + + static WorkContinuation begin(WorkManager manager, List work, String uniqueName, + ExistingWorkPolicy policy) { + if (work == null || work.isEmpty()) { + throw new IllegalArgumentException("beginWith needs at least one OneTimeWorkRequest."); + } + List stage = copies(work); + return new WorkContinuation(manager, stage, stage, uniqueName, policy); + } + + private static List copies(List work) { + List out = new ArrayList(); + for (int i = 0; i < work.size(); i++) { + out.add(work.get(i).spec.copy()); + } + return out; + } + + public final WorkContinuation then(OneTimeWorkRequest work) { + return then(Arrays.asList(work)); + } + + public final WorkContinuation then(OneTimeWorkRequest... work) { + return then(Arrays.asList(work)); + } + + public WorkContinuation then(List work) { + if (work.isEmpty()) { + return this; + } + List stage = copies(work); + for (int i = 0; i < stage.size(); i++) { + for (int j = 0; j < leaves.size(); j++) { + stage.get(i).prerequisites.add(leaves.get(j).id); + } + } + List all = new ArrayList(specs); + all.addAll(stage); + return new WorkContinuation(manager, all, stage, uniqueName, policy); + } + + /// A continuation whose next stage waits for every chain in `continuations`. + public static WorkContinuation combine(List continuations) { + if (continuations.isEmpty()) { + throw new IllegalArgumentException("combine needs at least one WorkContinuation."); + } + List all = new ArrayList(); + List leaves = new ArrayList(); + WorkContinuation first = continuations.get(0); + for (int i = 0; i < continuations.size(); i++) { + WorkContinuation c = continuations.get(i); + for (int j = 0; j < c.specs.size(); j++) { + if (!all.contains(c.specs.get(j))) { + all.add(c.specs.get(j)); + } + } + leaves.addAll(c.leaves); + } + return new WorkContinuation(first.manager, all, leaves, first.uniqueName, first.policy); + } + + public Operation enqueue() { + // Enqueue a fresh copy, so enqueueing the same continuation twice + // does not share state between the two runs. + List fresh = new ArrayList(); + List freshLeaves = new ArrayList(); + for (int i = 0; i < specs.size(); i++) { + WorkSpec c = specs.get(i).copy(); + fresh.add(c); + if (leaves.contains(specs.get(i))) { + freshLeaves.add(c); + } + } + WorkEngine.get().enqueue(new WorkContinuation(manager, fresh, freshLeaves, uniqueName, policy)); + return WorkManager.completed(); + } + + public LiveData> getWorkInfosLiveData() { + return WorkEngine.get().liveInfos("chain:" + ids(), matcher()); + } + + public Future> getWorkInfos() { + return WorkEngine.get().infosLater(matcher()); + } + + private String ids() { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < specs.size(); i++) { + sb.append(specs.get(i).id).append(','); + } + return sb.toString(); + } + + private WorkEngine.Matcher matcher() { + final List ids = new ArrayList(); + for (int i = 0; i < specs.size(); i++) { + ids.add(specs.get(i).id); + } + return new IdsMatcher(ids); + } + + private static final class IdsMatcher implements WorkEngine.Matcher { + private final List ids; + + IdsMatcher(List ids) { + this.ids = ids; + } + + @Override + public boolean matches(WorkSpec s) { + return ids.contains(s.id); + } + } +} diff --git a/maven/android-compat/src/main/java/androidx/work/WorkEngine.java b/maven/android-compat/src/main/java/androidx/work/WorkEngine.java new file mode 100644 index 00000000000..b58aad5ef1c --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/work/WorkEngine.java @@ -0,0 +1,844 @@ +/* + * 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 androidx.work; + +import android.content.Context; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import com.codename1.androidcompat.runtime.CompatReport; +import com.codename1.androidcompat.runtime.MainThread; +import com.codename1.androidcompat.runtime.WorkerInstantiator; +import com.codename1.background.BackgroundWork; +import com.codename1.ui.Display; +import com.codename1.util.Callback; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.Callable; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; +import java.util.Timer; +import java.util.TimerTask; + +/// The scheduler behind [WorkManager]. +/// +/// Every piece of state is owned by the main thread: an enqueue, a cancel or +/// a finished worker is handed to it, so nothing here needs a lock. Workers +/// run on the configured executor. +/// +/// Work runs in this process while the application is running, as soon as +/// its delay has passed and its prerequisites succeeded. What has not +/// finished is saved by [WorkStore] and picked up again the next time the +/// application asks for its `WorkManager`. A request with a constraint only +/// the platform can judge -- charging, battery, idle, storage -- is handed to +/// Codename One's `BackgroundWork` where that is supported, and runs when the +/// platform says so (through [com.codename1.androidcompat.work.CompatBackgroundWorker]); +/// elsewhere those constraints are not checked. A network constraint is +/// treated as met: there is no portable way to wait for connectivity, and a +/// worker without it fails and asks to be retried, as it would on Android. +final class WorkEngine { + + static final String PLATFORM_PREFIX = "cn1compat.work:"; + static final String PLATFORM_KEY_ID = "id"; + + private static WorkEngine instance; + + private final Map specs = new LinkedHashMap(); + private final Map running = new HashMap(); + private final Map queries = new LinkedHashMap(); + private final Map>> platformWaiters = new HashMap>>(); + private final Progress progress = new Progress(); + private Context appContext; + private WorkerFactory factory; + private Executor executor; + private Timer timer; + private TimerTask wake; + private long wakeAt = Long.MAX_VALUE; + + private WorkEngine() { + } + + static WorkEngine get() { + if (instance == null) { + instance = new WorkEngine(); + } + return instance; + } + + /// Forgets everything in memory; for tests, which start from a clean store. + static void resetForTesting() { + if (instance != null && instance.wake != null) { + instance.wake.cancel(); + } + if (instance != null && instance.timer != null) { + instance.timer.cancel(); + } + instance = null; + } + + // ---------------------------------------------------------------- setup + + void initialize(Context context, Configuration config) { + appContext = context.getApplicationContext() == null ? context : context.getApplicationContext(); + if (config != null) { + factory = config.workerFactory; + executor = config.executor; + } + if (executor == null) { + // AndroidX's default pool is sized between two and four threads. + executor = Executors.newFixedThreadPool(4); + } + // Read now, on the caller's thread, and merged by the first engine + // operation on the main thread, whichever runs first: initialize may + // be called off the main thread, and an enqueue made on it meanwhile + // must neither overwrite the saved queue nor miss its unique work. + unmerged.set(WorkStore.load()); + onMain(new Runnable() { + @Override + public void run() { + changed(); + } + }); + } + + private final java.util.concurrent.atomic.AtomicReference> unmerged = + new java.util.concurrent.atomic.AtomicReference>(); + + /// Merges the work saved by an earlier run, once. Main thread. + void ensureLoaded() { + List saved = unmerged.getAndSet(null); + if (saved == null) { + return; + } + for (int i = 0; i < saved.size(); i++) { + WorkSpec s = saved.get(i); + if (specs.containsKey(s.id)) { + continue; + } + if (s.state == WorkInfo.State.RUNNING) { + // The process ended while it ran; Android runs it again. + s.state = WorkInfo.State.ENQUEUED; + } + specs.put(s.id, s); + } + } + + boolean isInitialized() { + return appContext != null; + } + + // ------------------------------------------------------------ requests + + void enqueue(final WorkContinuation c) { + onMain(new Runnable() { + @Override + public void run() { + insertContinuation(c); + } + }); + } + + private void insertContinuation(WorkContinuation c) { + List added = c.specs; + String name = c.uniqueName; + if (name != null) { + List existing = named(name); + boolean anyUnfinished = false; + for (int i = 0; i < existing.size(); i++) { + anyUnfinished |= !existing.get(i).state.isFinished(); + } + ExistingWorkPolicy policy = c.policy; + List leaves = leaves(existing); + if (policy == ExistingWorkPolicy.APPEND_OR_REPLACE) { + boolean broken = false; + for (int i = 0; i < leaves.size(); i++) { + WorkInfo.State st = specs.get(leaves.get(i)).state; + broken |= st == WorkInfo.State.FAILED || st == WorkInfo.State.CANCELLED; + } + policy = broken ? ExistingWorkPolicy.REPLACE : ExistingWorkPolicy.APPEND; + } + if (policy == ExistingWorkPolicy.KEEP && anyUnfinished) { + return; + } + if (policy == ExistingWorkPolicy.REPLACE) { + for (int i = 0; i < existing.size(); i++) { + cancel(existing.get(i)); + } + // The replaced work is gone from the name, finished or not. + for (int i = 0; i < existing.size(); i++) { + existing.get(i).uniqueName = null; + } + } else if (!anyUnfinished && policy != ExistingWorkPolicy.APPEND) { + // KEEP with nothing pending: the finished work leaves the name, + // so a later APPEND does not wait on it. + for (int i = 0; i < existing.size(); i++) { + existing.get(i).uniqueName = null; + } + } else if (policy == ExistingWorkPolicy.APPEND) { + // Finished leaves stay prerequisites: a failed or cancelled + // one fails or cancels what is appended, as on Android. + for (int i = 0; i < added.size(); i++) { + WorkSpec s = added.get(i); + if (s.prerequisites.isEmpty()) { + s.prerequisites.addAll(leaves); + } + } + } + for (int i = 0; i < added.size(); i++) { + added.get(i).uniqueName = name; + } + } + long now = System.currentTimeMillis(); + for (int i = 0; i < added.size(); i++) { + WorkSpec s = added.get(i); + s.state = s.prerequisites.isEmpty() ? WorkInfo.State.ENQUEUED : WorkInfo.State.BLOCKED; + s.nextRunAt = now + s.initialDelayMillis; + specs.put(s.id, s); + } + for (int i = 0; i < added.size(); i++) { + WorkSpec s = added.get(i); + if (s.state == WorkInfo.State.BLOCKED) { + resolveBlocked(s, now); + } else { + platformSchedule(s); + } + } + changed(); + } + + void enqueueUniquePeriodic(final String name, final ExistingPeriodicWorkPolicy policy, final WorkSpec spec) { + onMain(new Runnable() { + @Override + public void run() { + List existing = named(name); + WorkSpec live = null; + for (int i = 0; i < existing.size(); i++) { + if (!existing.get(i).state.isFinished()) { + live = existing.get(i); + } + } + if (live != null && policy == ExistingPeriodicWorkPolicy.KEEP) { + return; + } + if (live != null && policy == ExistingPeriodicWorkPolicy.UPDATE) { + // The request keeps its id, state and schedule; what it + // runs and how changes, as a new generation. + live.workerClassName = spec.workerClassName; + live.tags.clear(); + live.tags.addAll(spec.tags); + live.input = spec.input; + live.constraints = spec.constraints; + live.intervalMillis = spec.intervalMillis; + live.flexMillis = spec.flexMillis; + live.backoffPolicy = spec.backoffPolicy; + live.backoffDelayMillis = spec.backoffDelayMillis; + live.generation++; + platformSchedule(live); + changed(); + return; + } + for (int i = 0; i < existing.size(); i++) { + cancel(existing.get(i)); + existing.get(i).uniqueName = null; + } + List one = new ArrayList(); + one.add(spec); + WorkContinuation c = new WorkContinuation(null, one, one, name, ExistingWorkPolicy.KEEP); + insertContinuation(c); + } + }); + } + + // -------------------------------------------------------------- cancel + + void cancelWhere(final Matcher m) { + onMain(new Runnable() { + @Override + public void run() { + List all = new ArrayList(specs.values()); + for (int i = 0; i < all.size(); i++) { + if (m.matches(all.get(i))) { + cancel(all.get(i)); + } + } + changed(); + } + }); + } + + void prune() { + onMain(new Runnable() { + @Override + public void run() { + for (Iterator it = specs.values().iterator(); it.hasNext();) { + WorkSpec s = it.next(); + if (s.state.isFinished() && !hasUnfinishedDependent(s.id)) { + it.remove(); + } + } + changed(); + } + }); + } + + private void cancel(WorkSpec s) { + if (s.state.isFinished()) { + return; + } + ListenableWorker w = running.remove(s.id); + if (w != null) { + w.stop(); + } + s.state = WorkInfo.State.CANCELLED; + platformCancel(s); + settleWaiters(s.id, true); + cascade(s.id, WorkInfo.State.CANCELLED); + } + + private void cascade(String id, WorkInfo.State st) { + List all = new ArrayList(specs.values()); + for (int i = 0; i < all.size(); i++) { + WorkSpec d = all.get(i); + if (!d.state.isFinished() && d.prerequisites.contains(id)) { + d.state = st; + platformCancel(d); + cascade(d.id, st); + } + } + } + + // --------------------------------------------------------------- queries + + interface Matcher { + boolean matches(WorkSpec s); + } + + static Matcher byId(final String id) { + return new Matcher() { + @Override + public boolean matches(WorkSpec s) { + return s.id.equals(id); + } + }; + } + + static Matcher byTag(final String tag) { + return new Matcher() { + @Override + public boolean matches(WorkSpec s) { + return s.tags.contains(tag); + } + }; + } + + static Matcher byName(final String name) { + return new Matcher() { + @Override + public boolean matches(WorkSpec s) { + return name.equals(s.uniqueName); + } + }; + } + + static Matcher all() { + return new Matcher() { + @Override + public boolean matches(WorkSpec s) { + return true; + } + }; + } + + List infos(Matcher m) { + List out = new ArrayList(); + for (WorkSpec s : specs.values()) { + if (m.matches(s)) { + out.add(s.toInfo()); + } + } + return out; + } + + WorkInfo info(String id) { + WorkSpec s = specs.get(id); + return s == null ? null : s.toInfo(); + } + + Future> infosLater(final Matcher m) { + return later(new Callable>() { + @Override + public List call() { + return infos(m); + } + }); + } + + Future infoLater(final String id) { + return later(new Callable() { + @Override + public WorkInfo call() { + return info(id); + } + }); + } + + @SuppressWarnings("unchecked") + LiveData> liveInfos(String key, Matcher m) { + Query q = queries.get(key); + if (q == null) { + q = new Query(m, false); + queries.put(key, q); + refresh(q); + } + return (LiveData>) (LiveData) q.live; + } + + @SuppressWarnings("unchecked") + LiveData liveInfo(String id) { + String key = "id:" + id; + Query q = queries.get(key); + if (q == null) { + q = new Query(byId(id), true); + queries.put(key, q); + refresh(q); + } + return (LiveData) (LiveData) q.live; + } + + static final class Query { + final Matcher matcher; + final boolean single; + final MutableLiveData live = new MutableLiveData(); + Object last; + boolean published; + + Query(Matcher matcher, boolean single) { + this.matcher = matcher; + this.single = single; + } + } + + private void refresh(Query q) { + List now = infos(q.matcher); + Object v = q.single ? (now.isEmpty() ? null : now.get(0)) : now; + if (q.published && (q.last == null ? v == null : q.last.equals(v))) { + return; + } + q.published = true; + q.last = v; + q.live.setValue(v); + } + + // ----------------------------------------------------------- scheduling + + /// Everything after a change: persist, tell observers, run what is due. + private void changed() { + persist(); + for (Query q : queries.values()) { + refresh(q); + } + pump(); + } + + private void pump() { + long now = System.currentTimeMillis(); + long earliest = Long.MAX_VALUE; + List all = new ArrayList(specs.values()); + boolean ran = false; + for (int i = 0; i < all.size(); i++) { + WorkSpec s = all.get(i); + if (s.state != WorkInfo.State.ENQUEUED || running.containsKey(s.id) || deferToPlatform(s)) { + continue; + } + if (s.nextRunAt <= now) { + start(s, null); + ran = true; + } else { + earliest = Math.min(earliest, s.nextRunAt); + } + } + if (ran) { + persist(); + for (Query q : queries.values()) { + refresh(q); + } + } + wakeAt(earliest); + } + + private void wakeAt(long at) { + if (at == wakeAt) { + return; + } + if (wake != null) { + wake.cancel(); + wake = null; + } + wakeAt = at; + if (at == Long.MAX_VALUE) { + return; + } + if (timer == null) { + timer = new Timer(); + } + wake = new TimerTask() { + @Override + public void run() { + MainThread.post(new Runnable() { + @Override + public void run() { + wakeAt = Long.MAX_VALUE; + wake = null; + pump(); + } + }); + } + }; + timer.schedule(wake, Math.max(0, at - System.currentTimeMillis())); + } + + /// Starts `s`. Answers false when no worker could be made, in which + /// case the request has failed. + private boolean start(final WorkSpec s, Callback platform) { + if (platform != null) { + addWaiter(s.id, platform); + } + WorkerParameters params = new WorkerParameters(UUID.fromString(s.id), s.input, + new java.util.LinkedHashSet(s.tags), s.runAttemptCount, s.generation, executor, progress); + final ListenableWorker w = createWorker(s.workerClassName, params); + if (w == null || w.markUsed()) { + CompatReport.unsupported("WorkManager", s.workerClassName + + " (no public (Context, WorkerParameters) constructor, and no WorkerFactory made one)"); + finish(s, new ListenableWorker.Result.Failure()); + return false; + } + s.state = WorkInfo.State.RUNNING; + running.put(s.id, w); + final String id = s.id; + executor.execute(new Runnable() { + @Override + public void run() { + Object got = null; + try { + // Read as Object: the generic get() casts to Result, and a + // cast never fails on ParparVM, so the type is checked + // with instanceof below instead. + Future f = w.startWork(); + got = f == null ? null : f.get(); + } catch (Throwable t) { + com.codename1.io.Log.e(t); + } + ListenableWorker.Result r = got instanceof ListenableWorker.Result + ? (ListenableWorker.Result) got : null; + final ListenableWorker.Result result = r == null ? new ListenableWorker.Result.Failure() : r; + MainThread.post(new Runnable() { + @Override + public void run() { + completed(id, w, result); + } + }); + } + }); + return true; + } + + private ListenableWorker createWorker(String className, WorkerParameters params) { + if (factory != null) { + ListenableWorker w = factory.createWorker(appContext, className, params); + if (w != null) { + return w; + } + } + Object o = WorkerInstantiator.create(className, appContext, params); + return o instanceof ListenableWorker ? (ListenableWorker) o : null; + } + + private void completed(String id, ListenableWorker w, ListenableWorker.Result r) { + if (running.get(id) != w) { + // Stopped by a cancel or a replace; its result means nothing. + return; + } + running.remove(id); + WorkSpec s = specs.get(id); + if (s == null || s.state != WorkInfo.State.RUNNING) { + return; + } + finish(s, r); + changed(); + } + + private void finish(WorkSpec s, ListenableWorker.Result r) { + long now = System.currentTimeMillis(); + s.progress = Data.EMPTY; + if (r instanceof ListenableWorker.Result.Retry) { + s.runAttemptCount++; + s.state = WorkInfo.State.ENQUEUED; + s.nextRunAt = now + s.backoffMillis(); + settleWaiters(s.id, false); + return; + } + settleWaiters(s.id, true); + if (s.isPeriodic()) { + // A periodic request never finishes: success or failure, it waits + // for its next period. + s.runAttemptCount = 0; + s.state = WorkInfo.State.ENQUEUED; + s.nextRunAt = now + s.intervalMillis; + return; + } + platformCancel(s); + if (r instanceof ListenableWorker.Result.Success) { + s.state = WorkInfo.State.SUCCEEDED; + s.output = r.getOutputData(); + List all = new ArrayList(specs.values()); + for (int i = 0; i < all.size(); i++) { + WorkSpec d = all.get(i); + if (d.state == WorkInfo.State.BLOCKED && d.prerequisites.contains(s.id)) { + resolveBlocked(d, now); + } + } + } else { + s.state = WorkInfo.State.FAILED; + s.output = r.getOutputData(); + cascade(s.id, WorkInfo.State.FAILED); + } + } + + /// Unblocks `d` once every prerequisite succeeded, merging their outputs + /// over its own input as AndroidX's `OverwritingInputMerger` does; fails + /// or cancels it as soon as one did. + private void resolveBlocked(WorkSpec d, long now) { + Map merged = new HashMap(d.input.values); + for (int i = 0; i < d.prerequisites.size(); i++) { + WorkSpec p = specs.get(d.prerequisites.get(i)); + if (p == null) { + // Pruned, which only happens to finished work. + continue; + } + if (p.state == WorkInfo.State.FAILED || p.state == WorkInfo.State.CANCELLED) { + d.state = p.state; + cascade(d.id, p.state); + return; + } + if (p.state != WorkInfo.State.SUCCEEDED) { + return; + } + merged.putAll(p.output.values); + } + d.input = new Data(merged); + d.state = WorkInfo.State.ENQUEUED; + d.nextRunAt = now + d.initialDelayMillis; + platformSchedule(d); + } + + // ------------------------------------------------------------- platform + + private static boolean platformAvailable() { + return Display.isInitialized() && BackgroundWork.isSupported(); + } + + private static boolean deferToPlatform(WorkSpec s) { + return s.constraints.needsPlatformScheduler() && platformAvailable(); + } + + private void platformSchedule(WorkSpec s) { + if (!deferToPlatform(s) || s.state != WorkInfo.State.ENQUEUED) { + return; + } + Constraints c = s.constraints; + com.codename1.background.WorkRequest.Builder b = com.codename1.background.WorkRequest.builder( + PLATFORM_PREFIX + s.id, com.codename1.androidcompat.work.CompatBackgroundWorker.class) + .setRequiresNetwork(c.networkType != NetworkType.NOT_REQUIRED) + .setRequiresUnmeteredNetwork(c.networkType == NetworkType.UNMETERED) + .setRequiresCharging(c.requiresCharging) + .setRequiresIdle(c.requiresDeviceIdle) + .setRequiresBatteryNotLow(c.requiresBatteryNotLow) + .setInitialDelay(Math.max(0, s.nextRunAt - System.currentTimeMillis())) + .putInputData(PLATFORM_KEY_ID, s.id); + if (s.isPeriodic()) { + b.setPeriodic(s.intervalMillis); + } + BackgroundWork.schedule(b.build()); + } + + private static void platformCancel(WorkSpec s) { + if (s.constraints.needsPlatformScheduler() && platformAvailable()) { + BackgroundWork.cancel(PLATFORM_PREFIX + s.id); + } + } + + /// Runs `id` because the platform scheduler says its constraints hold. + /// `done` hears true once the attempt finished and false when it should + /// be retried. Work that is already running is not started twice. + void runForPlatform(final String id, final Callback done) { + onMain(new Runnable() { + @Override + public void run() { + WorkSpec s = specs.get(id); + if (s == null || s.state.isFinished()) { + done.onSucess(Boolean.TRUE); + return; + } + if (s.state == WorkInfo.State.RUNNING) { + addWaiter(id, done); + return; + } + if (s.state != WorkInfo.State.ENQUEUED) { + done.onSucess(Boolean.FALSE); + return; + } + start(s, done); + changed(); + } + }); + } + + private void addWaiter(String id, Callback cb) { + List> l = platformWaiters.get(id); + if (l == null) { + l = new ArrayList>(); + platformWaiters.put(id, l); + } + l.add(cb); + } + + private void settleWaiters(String id, boolean finished) { + List> l = platformWaiters.remove(id); + if (l != null) { + for (int i = 0; i < l.size(); i++) { + l.get(i).onSucess(Boolean.valueOf(finished)); + } + } + } + + // ---------------------------------------------------------------- store + + private void persist() { + List keep = new ArrayList(); + for (WorkSpec s : specs.values()) { + if (!s.state.isFinished() || hasUnfinishedDependent(s.id)) { + keep.add(s); + } + } + WorkStore.save(keep); + } + + private boolean hasUnfinishedDependent(String id) { + for (WorkSpec d : specs.values()) { + if (!d.state.isFinished() && d.prerequisites.contains(id)) { + return true; + } + } + return false; + } + + private List named(String name) { + List out = new ArrayList(); + for (WorkSpec s : specs.values()) { + if (name.equals(s.uniqueName)) { + out.add(s); + } + } + return out; + } + + /// The members of a unique chain nothing else in it waits for. + private static List leaves(List chain) { + List out = new ArrayList(); + for (int i = 0; i < chain.size(); i++) { + String id = chain.get(i).id; + boolean waitedFor = false; + for (int j = 0; j < chain.size() && !waitedFor; j++) { + waitedFor = chain.get(j).prerequisites.contains(id); + } + if (!waitedFor) { + out.add(id); + } + } + return out; + } + + // -------------------------------------------------------------- threads + + /// Runs `r` on the main thread, after merging the saved work: every + /// engine operation goes through here. + static void onMain(final Runnable r) { + Runnable run = new Runnable() { + @Override + public void run() { + WorkEngine e = instance; + if (e != null) { + e.ensureLoaded(); + } + r.run(); + } + }; + if (MainThread.isMainThread()) { + run.run(); + } else { + MainThread.post(run); + } + } + + static Future later(Callable c) { + FutureTask t = new FutureTask(c); + onMain(t); + return t; + } + + static Future done(final T value) { + FutureTask t = new FutureTask(new Callable() { + @Override + public T call() { + return value; + } + }); + t.run(); + return t; + } + + /// Where `setProgressAsync` reports to, from the worker's thread. + final class Progress { + void set(final UUID id, final Data data) { + onMain(new Runnable() { + @Override + public void run() { + WorkSpec s = specs.get(id.toString()); + if (s != null && s.state == WorkInfo.State.RUNNING) { + s.progress = data == null ? Data.EMPTY : data; + for (Query q : queries.values()) { + refresh(q); + } + } + } + }); + } + } +} diff --git a/maven/android-compat/src/main/java/androidx/work/WorkInfo.java b/maven/android-compat/src/main/java/androidx/work/WorkInfo.java new file mode 100644 index 00000000000..7d20b3844f6 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/work/WorkInfo.java @@ -0,0 +1,152 @@ +/* + * 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 androidx.work; + +import java.util.Set; +import java.util.UUID; + +/// A snapshot of one request's state. +public final class WorkInfo { + + public enum State { + ENQUEUED, RUNNING, SUCCEEDED, FAILED, BLOCKED, CANCELLED; + + public boolean isFinished() { + return this == SUCCEEDED || this == FAILED || this == CANCELLED; + } + } + + public static final int STOP_REASON_NOT_STOPPED = -256; + + private final UUID id; + private final State state; + private final Set tags; + private final Data outputData; + private final Data progress; + private final int runAttemptCount; + private final int generation; + private final Constraints constraints; + private final long nextScheduleTimeMillis; + private final long intervalMillis; + private final long flexMillis; + + WorkInfo(UUID id, State state, Set tags, Data outputData, Data progress, int runAttemptCount, + int generation, Constraints constraints, long nextScheduleTimeMillis, long intervalMillis, + long flexMillis) { + this.id = id; + this.state = state; + this.tags = tags; + this.outputData = outputData; + this.progress = progress; + this.runAttemptCount = runAttemptCount; + this.generation = generation; + this.constraints = constraints; + this.nextScheduleTimeMillis = nextScheduleTimeMillis; + this.intervalMillis = intervalMillis; + this.flexMillis = flexMillis; + } + + public UUID getId() { + return id; + } + + public State getState() { + return state; + } + + public Set getTags() { + return tags; + } + + public Data getOutputData() { + return outputData; + } + + public Data getProgress() { + return progress; + } + + public int getRunAttemptCount() { + return runAttemptCount; + } + + public int getGeneration() { + return generation; + } + + public Constraints getConstraints() { + return constraints; + } + + public long getNextScheduleTimeMillis() { + return state == State.ENQUEUED ? nextScheduleTimeMillis : Long.MAX_VALUE; + } + + /// The repeat interval of a periodic request, or null for one-time work. + public PeriodicityInfo getPeriodicityInfo() { + return intervalMillis > 0 ? new PeriodicityInfo(intervalMillis, flexMillis) : null; + } + + public static final class PeriodicityInfo { + private final long repeatIntervalMillis; + private final long flexIntervalMillis; + + public PeriodicityInfo(long repeatIntervalMillis, long flexIntervalMillis) { + this.repeatIntervalMillis = repeatIntervalMillis; + this.flexIntervalMillis = flexIntervalMillis; + } + + public long getRepeatIntervalMillis() { + return repeatIntervalMillis; + } + + public long getFlexIntervalMillis() { + return flexIntervalMillis; + } + } + + public int getStopReason() { + return STOP_REASON_NOT_STOPPED; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof WorkInfo)) { + return false; + } + WorkInfo w = (WorkInfo) o; + return id.equals(w.id) && state == w.state && runAttemptCount == w.runAttemptCount + && generation == w.generation && tags.equals(w.tags) && outputData.equals(w.outputData) + && progress.equals(w.progress); + } + + @Override + public int hashCode() { + return id.hashCode() * 31 + state.hashCode(); + } + + @Override + public String toString() { + return "WorkInfo{id='" + id + "', state=" + state + ", tags=" + tags + "}"; + } +} diff --git a/maven/android-compat/src/main/java/androidx/work/WorkManager.java b/maven/android-compat/src/main/java/androidx/work/WorkManager.java new file mode 100644 index 00000000000..ee44e0ae4bf --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/work/WorkManager.java @@ -0,0 +1,212 @@ +/* + * 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 androidx.work; + +import android.content.Context; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import com.codename1.androidcompat.runtime.AndroidRuntime; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.Future; + +/// AndroidX WorkManager over Codename One. See the engine notes in this +/// package for what runs where; in short, work runs in this process while +/// the application runs, is saved until it finishes, and work with a +/// charging, battery, idle or storage constraint is handed to Codename One's +/// `BackgroundWork` where the platform supports it. +/// +/// The work saved by an earlier run is resumed the first time the +/// application asks for its `WorkManager`, which AndroidX does at start-up +/// on its own. Code that only reads work it enqueued in an earlier run should +/// call [#getInstance(Context)] from `Application.onCreate`. +public abstract class WorkManager { + + private static WorkManager instance; + + WorkManager() { + } + + public static WorkManager getInstance(Context context) { + if (instance == null) { + Configuration config = null; + Context app = context.getApplicationContext() == null ? context : context.getApplicationContext(); + if (app instanceof Configuration.Provider) { + config = ((Configuration.Provider) app).getWorkManagerConfiguration(); + } + initialize(context, config == null ? new Configuration.Builder().build() : config); + } + return instance; + } + + @Deprecated + public static WorkManager getInstance() { + if (instance == null) { + AndroidRuntime rt = AndroidRuntime.getInstance(); + if (rt == null) { + throw new IllegalStateException("WorkManager is not initialized properly."); + } + return getInstance(rt.getApplication()); + } + return instance; + } + + public static void initialize(Context context, Configuration configuration) { + if (instance != null) { + throw new IllegalStateException("WorkManager is already initialized."); + } + instance = new Impl(); + WorkEngine.get().initialize(context, configuration); + } + + public static boolean isInitialized() { + return instance != null; + } + + /// Forgets the instance and the work in memory; for tests. + static void resetForTesting() { + instance = null; + WorkEngine.resetForTesting(); + } + + static Operation completed() { + return new Operation() { + @Override + public LiveData getState() { + return new MutableLiveData(Operation.SUCCESS); + } + + @Override + public Future getResult() { + return WorkEngine.done(Operation.SUCCESS); + } + }; + } + + // ------------------------------------------------------------- enqueue + + public final Operation enqueue(WorkRequest request) { + return enqueue(Arrays.asList(request)); + } + + public Operation enqueue(List requests) { + for (int i = 0; i < requests.size(); i++) { + List one = new ArrayList(); + one.add(requests.get(i).spec.copy()); + WorkEngine.get().enqueue(new WorkContinuation(this, one, one, null, null)); + } + return completed(); + } + + public final WorkContinuation beginWith(OneTimeWorkRequest request) { + return beginWith(Arrays.asList(request)); + } + + public WorkContinuation beginWith(List requests) { + return WorkContinuation.begin(this, requests, null, null); + } + + public final WorkContinuation beginUniqueWork(String uniqueWorkName, ExistingWorkPolicy policy, + OneTimeWorkRequest request) { + return beginUniqueWork(uniqueWorkName, policy, Arrays.asList(request)); + } + + public WorkContinuation beginUniqueWork(String uniqueWorkName, ExistingWorkPolicy policy, + List requests) { + return WorkContinuation.begin(this, requests, uniqueWorkName, policy); + } + + public Operation enqueueUniqueWork(String uniqueWorkName, ExistingWorkPolicy policy, OneTimeWorkRequest request) { + return enqueueUniqueWork(uniqueWorkName, policy, Arrays.asList(request)); + } + + public Operation enqueueUniqueWork(String uniqueWorkName, ExistingWorkPolicy policy, + List requests) { + return beginUniqueWork(uniqueWorkName, policy, requests).enqueue(); + } + + public Operation enqueueUniquePeriodicWork(String uniqueWorkName, ExistingPeriodicWorkPolicy policy, + PeriodicWorkRequest request) { + WorkEngine.get().enqueueUniquePeriodic(uniqueWorkName, policy, request.spec.copy()); + return completed(); + } + + // -------------------------------------------------------------- cancel + + public Operation cancelWorkById(UUID id) { + WorkEngine.get().cancelWhere(WorkEngine.byId(id.toString())); + return completed(); + } + + public Operation cancelAllWorkByTag(String tag) { + WorkEngine.get().cancelWhere(WorkEngine.byTag(tag)); + return completed(); + } + + public Operation cancelUniqueWork(String uniqueWorkName) { + WorkEngine.get().cancelWhere(WorkEngine.byName(uniqueWorkName)); + return completed(); + } + + public Operation cancelAllWork() { + WorkEngine.get().cancelWhere(WorkEngine.all()); + return completed(); + } + + public Operation pruneWork() { + WorkEngine.get().prune(); + return completed(); + } + + // -------------------------------------------------------------- query + + public Future getWorkInfoById(UUID id) { + return WorkEngine.get().infoLater(id.toString()); + } + + public LiveData getWorkInfoByIdLiveData(UUID id) { + return WorkEngine.get().liveInfo(id.toString()); + } + + public Future> getWorkInfosByTag(String tag) { + return WorkEngine.get().infosLater(WorkEngine.byTag(tag)); + } + + public LiveData> getWorkInfosByTagLiveData(String tag) { + return WorkEngine.get().liveInfos("tag:" + tag, WorkEngine.byTag(tag)); + } + + public Future> getWorkInfosForUniqueWork(String uniqueWorkName) { + return WorkEngine.get().infosLater(WorkEngine.byName(uniqueWorkName)); + } + + public LiveData> getWorkInfosForUniqueWorkLiveData(String uniqueWorkName) { + return WorkEngine.get().liveInfos("name:" + uniqueWorkName, WorkEngine.byName(uniqueWorkName)); + } + + static final class Impl extends WorkManager { + } +} diff --git a/maven/android-compat/src/main/java/androidx/work/WorkManagerBridge.java b/maven/android-compat/src/main/java/androidx/work/WorkManagerBridge.java new file mode 100644 index 00000000000..590f6a83ce9 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/work/WorkManagerBridge.java @@ -0,0 +1,49 @@ +/* + * 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 androidx.work; + +import com.codename1.androidcompat.runtime.AndroidRuntime; +import com.codename1.androidcompat.runtime.CompatReport; +import com.codename1.util.Callback; + +/// The entry point the platform scheduler's worker uses. Not AndroidX API. +public final class WorkManagerBridge { + + private WorkManagerBridge() { + } + + public static void run(String id, Callback done) { + if (!WorkManager.isInitialized()) { + AndroidRuntime rt = AndroidRuntime.getInstance(); + if (rt == null || rt.getApplication() == null) { + // A cold background launch before the application started: + // ask the platform to try again once it has. + CompatReport.unsupported("WorkManager", "background run before the application started"); + done.onSucess(Boolean.FALSE); + return; + } + WorkManager.getInstance(rt.getApplication()); + } + WorkEngine.get().runForPlatform(id, done); + } +} diff --git a/maven/android-compat/src/main/java/androidx/work/WorkRequest.java b/maven/android-compat/src/main/java/androidx/work/WorkRequest.java new file mode 100644 index 00000000000..1e0f0a11fe1 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/work/WorkRequest.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 androidx.work; + +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +public abstract class WorkRequest { + + public static final long DEFAULT_BACKOFF_DELAY_MILLIS = 30000L; + public static final long MAX_BACKOFF_MILLIS = 5 * 60 * 60 * 1000L; + public static final long MIN_BACKOFF_MILLIS = 10000L; + + final WorkSpec spec; + + WorkRequest(WorkSpec spec) { + this.spec = spec; + } + + public UUID getId() { + return UUID.fromString(spec.id); + } + + public String getStringId() { + return spec.id; + } + + public Set getTags() { + return java.util.Collections.unmodifiableSet(spec.tags); + } + + public abstract static class Builder, W extends WorkRequest> { + + WorkSpec spec = new WorkSpec(); + + Builder(Class workerClass) { + spec.id = UUID.randomUUID().toString(); + spec.workerClassName = workerClass.getName(); + // Android tags every request with its worker's class name. + spec.tags.add(workerClass.getName()); + } + + abstract B thisObject(); + + abstract W buildInternal(); + + public final B setId(UUID id) { + spec.id = id.toString(); + return thisObject(); + } + + public final B setBackoffCriteria(BackoffPolicy policy, long backoffDelay, TimeUnit timeUnit) { + spec.backoffPolicy = policy; + long ms = timeUnit.toMillis(backoffDelay); + spec.backoffDelayMillis = Math.max(MIN_BACKOFF_MILLIS, Math.min(MAX_BACKOFF_MILLIS, ms)); + return thisObject(); + } + + public final B setConstraints(Constraints constraints) { + spec.constraints = constraints; + return thisObject(); + } + + public final B setInputData(Data inputData) { + spec.input = inputData; + return thisObject(); + } + + public final B addTag(String tag) { + spec.tags.add(tag); + return thisObject(); + } + + public final B keepResultsForAtLeast(long duration, TimeUnit timeUnit) { + return thisObject(); + } + + public B setInitialDelay(long duration, TimeUnit timeUnit) { + spec.initialDelayMillis = timeUnit.toMillis(duration); + return thisObject(); + } + + /// Expedited work runs as soon as its constraints allow, which is + /// already what this runtime does with all work. + public B setExpedited(OutOfQuotaPolicy policy) { + spec.expedited = true; + return thisObject(); + } + + public final W build() { + W w = buildInternal(); + // A builder may build more than one request; each gets its own id. + WorkSpec next = spec.copy(); + next.id = UUID.randomUUID().toString(); + spec = next; + return w; + } + } +} diff --git a/maven/android-compat/src/main/java/androidx/work/WorkSpec.java b/maven/android-compat/src/main/java/androidx/work/WorkSpec.java new file mode 100644 index 00000000000..3a914e78dd2 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/work/WorkSpec.java @@ -0,0 +1,104 @@ +/* + * 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 androidx.work; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/// Everything this runtime knows about one request: what it was built with +/// and where it is now. Persisted by [WorkStore] while it is unfinished, so a +/// cold launch picks it up again. +final class WorkSpec { + + String id; + String workerClassName; + final Set tags = new LinkedHashSet(); + Data input = Data.EMPTY; + Constraints constraints = Constraints.NONE; + long initialDelayMillis; + long intervalMillis; + long flexMillis; + BackoffPolicy backoffPolicy = BackoffPolicy.EXPONENTIAL; + long backoffDelayMillis = WorkRequest.DEFAULT_BACKOFF_DELAY_MILLIS; + boolean expedited; + String uniqueName; + final List prerequisites = new ArrayList(); + + WorkInfo.State state = WorkInfo.State.ENQUEUED; + int runAttemptCount; + int generation; + long nextRunAt; + Data output = Data.EMPTY; + Data progress = Data.EMPTY; + + boolean isPeriodic() { + return intervalMillis > 0; + } + + WorkSpec copy() { + WorkSpec s = new WorkSpec(); + s.id = id; + s.workerClassName = workerClassName; + s.tags.addAll(tags); + s.input = input; + s.constraints = constraints; + s.initialDelayMillis = initialDelayMillis; + s.intervalMillis = intervalMillis; + s.flexMillis = flexMillis; + s.backoffPolicy = backoffPolicy; + s.backoffDelayMillis = backoffDelayMillis; + s.expedited = expedited; + s.uniqueName = uniqueName; + s.prerequisites.addAll(prerequisites); + s.state = state; + s.runAttemptCount = runAttemptCount; + s.generation = generation; + s.nextRunAt = nextRunAt; + s.output = output; + s.progress = progress; + return s; + } + + /// The delay before attempt `runAttemptCount + 1` after a retry, capped + /// at Android's five hours. + long backoffMillis() { + long d; + if (backoffPolicy == BackoffPolicy.LINEAR) { + d = backoffDelayMillis * runAttemptCount; + } else { + int shift = Math.max(0, Math.min(30, runAttemptCount - 1)); + d = backoffDelayMillis << shift; + if (d < 0) { + d = WorkRequest.MAX_BACKOFF_MILLIS; + } + } + return Math.min(WorkRequest.MAX_BACKOFF_MILLIS, d); + } + + WorkInfo toInfo() { + return new WorkInfo(java.util.UUID.fromString(id), state, new LinkedHashSet(tags), output, progress, + runAttemptCount, generation, constraints, nextRunAt, intervalMillis, flexMillis); + } +} diff --git a/maven/android-compat/src/main/java/androidx/work/WorkStore.java b/maven/android-compat/src/main/java/androidx/work/WorkStore.java new file mode 100644 index 00000000000..d85173d42bf --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/work/WorkStore.java @@ -0,0 +1,558 @@ +/* + * 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 androidx.work; + +import com.codename1.io.Storage; + +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +/// The persisted form of the work this application has not finished, kept in +/// the application's private `Storage` so it survives a restart, and the +/// binary encoding of [Data] that the size limit is measured against. +final class WorkStore { + + static final String FILE = "cn1compat-work.bin"; + static final String TEMP = "cn1compat-work.bin.new"; + private static final int VERSION = 1; + + private static final int T_NULL = 0; + private static final int T_BOOLEAN = 1; + private static final int T_BYTE = 2; + private static final int T_INT = 3; + private static final int T_LONG = 4; + private static final int T_FLOAT = 5; + private static final int T_DOUBLE = 6; + private static final int T_STRING = 7; + private static final int T_BOOLEAN_ARRAY = 8; + private static final int T_BYTE_ARRAY = 9; + private static final int T_INT_ARRAY = 10; + private static final int T_LONG_ARRAY = 11; + private static final int T_FLOAT_ARRAY = 12; + private static final int T_DOUBLE_ARRAY = 13; + private static final int T_STRING_ARRAY = 14; + + private WorkStore() { + } + + // ------------------------------------------------------------- values + + static boolean isSupportedValue(Object v) { + return typeOf(v) > T_NULL; + } + + private static int typeOf(Object v) { + if (v == null) { + return T_NULL; + } + if (v instanceof Boolean) { + return T_BOOLEAN; + } + if (v instanceof Byte) { + return T_BYTE; + } + if (v instanceof Integer) { + return T_INT; + } + if (v instanceof Long) { + return T_LONG; + } + if (v instanceof Float) { + return T_FLOAT; + } + if (v instanceof Double) { + return T_DOUBLE; + } + if (v instanceof String) { + return T_STRING; + } + if (v instanceof boolean[]) { + return T_BOOLEAN_ARRAY; + } + if (v instanceof byte[]) { + return T_BYTE_ARRAY; + } + if (v instanceof int[]) { + return T_INT_ARRAY; + } + if (v instanceof long[]) { + return T_LONG_ARRAY; + } + if (v instanceof float[]) { + return T_FLOAT_ARRAY; + } + if (v instanceof double[]) { + return T_DOUBLE_ARRAY; + } + if (v instanceof String[]) { + return T_STRING_ARRAY; + } + return -1; + } + + /// Arrays compare by content, as `Data.equals` does on Android. + static boolean valueEquals(Object a, Object b) { + if (a == b) { + return true; + } + if (a == null || b == null) { + return false; + } + int t = typeOf(a); + if (t != typeOf(b)) { + return false; + } + switch (t) { + case T_BOOLEAN_ARRAY: + return Arrays.equals((boolean[]) a, (boolean[]) b); + case T_BYTE_ARRAY: + return Arrays.equals((byte[]) a, (byte[]) b); + case T_INT_ARRAY: + return Arrays.equals((int[]) a, (int[]) b); + case T_LONG_ARRAY: + return Arrays.equals((long[]) a, (long[]) b); + case T_FLOAT_ARRAY: + return Arrays.equals((float[]) a, (float[]) b); + case T_DOUBLE_ARRAY: + return Arrays.equals((double[]) a, (double[]) b); + case T_STRING_ARRAY: + return Arrays.equals((Object[]) a, (Object[]) b); + default: + return a.equals(b); + } + } + + static int encodedSize(Data d) { + ByteArrayOutputStream bo = new ByteArrayOutputStream(); + try { + writeData(new DataOutputStream(bo), d); + } catch (IOException e) { + throw new IllegalStateException(e.toString()); + } + return bo.size(); + } + + static void writeData(DataOutputStream out, Data d) throws IOException { + out.writeInt(d.values.size()); + for (Map.Entry e : d.values.entrySet()) { + out.writeUTF(e.getKey()); + writeValue(out, e.getValue()); + } + } + + static Data readData(DataInputStream in) throws IOException { + int n = in.readInt(); + Map m = new HashMap(); + for (int i = 0; i < n; i++) { + String k = in.readUTF(); + m.put(k, readValue(in)); + } + return new Data(m); + } + + private static void writeValue(DataOutputStream out, Object v) throws IOException { + int t = typeOf(v); + out.writeByte(t); + switch (t) { + case T_NULL: + break; + case T_BOOLEAN: + out.writeBoolean(((Boolean) v).booleanValue()); + break; + case T_BYTE: + out.writeByte(((Byte) v).byteValue()); + break; + case T_INT: + out.writeInt(((Integer) v).intValue()); + break; + case T_LONG: + out.writeLong(((Long) v).longValue()); + break; + case T_FLOAT: + out.writeFloat(((Float) v).floatValue()); + break; + case T_DOUBLE: + out.writeDouble(((Double) v).doubleValue()); + break; + case T_STRING: + out.writeUTF((String) v); + break; + case T_BOOLEAN_ARRAY: { + boolean[] a = (boolean[]) v; + out.writeInt(a.length); + for (int i = 0; i < a.length; i++) { + out.writeBoolean(a[i]); + } + break; + } + case T_BYTE_ARRAY: { + byte[] a = (byte[]) v; + out.writeInt(a.length); + out.write(a); + break; + } + case T_INT_ARRAY: { + int[] a = (int[]) v; + out.writeInt(a.length); + for (int i = 0; i < a.length; i++) { + out.writeInt(a[i]); + } + break; + } + case T_LONG_ARRAY: { + long[] a = (long[]) v; + out.writeInt(a.length); + for (int i = 0; i < a.length; i++) { + out.writeLong(a[i]); + } + break; + } + case T_FLOAT_ARRAY: { + float[] a = (float[]) v; + out.writeInt(a.length); + for (int i = 0; i < a.length; i++) { + out.writeFloat(a[i]); + } + break; + } + case T_DOUBLE_ARRAY: { + double[] a = (double[]) v; + out.writeInt(a.length); + for (int i = 0; i < a.length; i++) { + out.writeDouble(a[i]); + } + break; + } + case T_STRING_ARRAY: { + String[] a = (String[]) v; + out.writeInt(a.length); + for (int i = 0; i < a.length; i++) { + out.writeBoolean(a[i] != null); + if (a[i] != null) { + out.writeUTF(a[i]); + } + } + break; + } + default: + throw new IOException("unsupported value " + v.getClass()); + } + } + + private static Object readValue(DataInputStream in) throws IOException { + int t = in.readByte(); + int n; + switch (t) { + case T_NULL: + return null; + case T_BOOLEAN: + return Boolean.valueOf(in.readBoolean()); + case T_BYTE: + return Byte.valueOf(in.readByte()); + case T_INT: + return Integer.valueOf(in.readInt()); + case T_LONG: + return Long.valueOf(in.readLong()); + case T_FLOAT: + return Float.valueOf(in.readFloat()); + case T_DOUBLE: + return Double.valueOf(in.readDouble()); + case T_STRING: + return in.readUTF(); + case T_BOOLEAN_ARRAY: { + n = in.readInt(); + boolean[] a = new boolean[n]; + for (int i = 0; i < n; i++) { + a[i] = in.readBoolean(); + } + return a; + } + case T_BYTE_ARRAY: { + n = in.readInt(); + byte[] a = new byte[n]; + in.readFully(a); + return a; + } + case T_INT_ARRAY: { + n = in.readInt(); + int[] a = new int[n]; + for (int i = 0; i < n; i++) { + a[i] = in.readInt(); + } + return a; + } + case T_LONG_ARRAY: { + n = in.readInt(); + long[] a = new long[n]; + for (int i = 0; i < n; i++) { + a[i] = in.readLong(); + } + return a; + } + case T_FLOAT_ARRAY: { + n = in.readInt(); + float[] a = new float[n]; + for (int i = 0; i < n; i++) { + a[i] = in.readFloat(); + } + return a; + } + case T_DOUBLE_ARRAY: { + n = in.readInt(); + double[] a = new double[n]; + for (int i = 0; i < n; i++) { + a[i] = in.readDouble(); + } + return a; + } + case T_STRING_ARRAY: { + n = in.readInt(); + String[] a = new String[n]; + for (int i = 0; i < n; i++) { + a[i] = in.readBoolean() ? in.readUTF() : null; + } + return a; + } + default: + throw new IOException("unknown value type " + t); + } + } + + // -------------------------------------------------------------- specs + + private static void writeOptional(DataOutputStream out, String s) throws IOException { + out.writeBoolean(s != null); + if (s != null) { + out.writeUTF(s); + } + } + + private static String readOptional(DataInputStream in) throws IOException { + return in.readBoolean() ? in.readUTF() : null; + } + + private static void writeSpec(DataOutputStream out, WorkSpec s) throws IOException { + out.writeUTF(s.id); + out.writeUTF(s.workerClassName); + out.writeInt(s.tags.size()); + for (Iterator it = s.tags.iterator(); it.hasNext();) { + out.writeUTF(it.next()); + } + writeData(out, s.input); + Constraints c = s.constraints; + out.writeUTF(c.networkType.name()); + out.writeBoolean(c.requiresCharging); + out.writeBoolean(c.requiresBatteryNotLow); + out.writeBoolean(c.requiresDeviceIdle); + out.writeBoolean(c.requiresStorageNotLow); + out.writeLong(s.initialDelayMillis); + out.writeLong(s.intervalMillis); + out.writeLong(s.flexMillis); + out.writeUTF(s.backoffPolicy.name()); + out.writeLong(s.backoffDelayMillis); + out.writeBoolean(s.expedited); + writeOptional(out, s.uniqueName); + out.writeInt(s.prerequisites.size()); + for (int i = 0; i < s.prerequisites.size(); i++) { + out.writeUTF(s.prerequisites.get(i)); + } + out.writeUTF(s.state.name()); + out.writeInt(s.runAttemptCount); + out.writeInt(s.generation); + out.writeLong(s.nextRunAt); + writeData(out, s.output); + } + + private static WorkSpec readSpec(DataInputStream in) throws IOException { + WorkSpec s = new WorkSpec(); + s.id = in.readUTF(); + s.workerClassName = in.readUTF(); + int n = in.readInt(); + for (int i = 0; i < n; i++) { + s.tags.add(in.readUTF()); + } + s.input = readData(in); + NetworkType net = NetworkType.valueOf(in.readUTF()); + boolean charging = in.readBoolean(); + boolean battery = in.readBoolean(); + boolean idle = in.readBoolean(); + boolean storage = in.readBoolean(); + s.constraints = new Constraints(net, charging, battery, idle, storage); + s.initialDelayMillis = in.readLong(); + s.intervalMillis = in.readLong(); + s.flexMillis = in.readLong(); + s.backoffPolicy = BackoffPolicy.valueOf(in.readUTF()); + s.backoffDelayMillis = in.readLong(); + s.expedited = in.readBoolean(); + s.uniqueName = readOptional(in); + n = in.readInt(); + for (int i = 0; i < n; i++) { + s.prerequisites.add(in.readUTF()); + } + s.state = WorkInfo.State.valueOf(in.readUTF()); + s.runAttemptCount = in.readInt(); + s.generation = in.readInt(); + s.nextRunAt = in.readLong(); + s.output = readData(in); + return s; + } + + static void save(List specs) { + Storage st = Storage.getInstance(); + if (specs.isEmpty()) { + if (st.exists(FILE)) { + st.deleteStorageFile(FILE); + } + if (st.exists(TEMP)) { + st.deleteStorageFile(TEMP); + } + return; + } + // Written whole to a second entry first, then made current, so a write + // cut short (the process killed, storage full) leaves the previous + // queue readable rather than a truncated file that loads as nothing. + OutputStream os = null; + boolean written = false; + try { + os = st.createOutputStream(TEMP); + DataOutputStream out = new DataOutputStream(os); + out.writeInt(VERSION); + out.writeInt(specs.size()); + for (int i = 0; i < specs.size(); i++) { + writeSpec(out, specs.get(i)); + } + out.flush(); + written = true; + } catch (IOException e) { + com.codename1.io.Log.e(e); + } finally { + close(os); + } + if (!written) { + return; + } + byte[] data = readAll(st, TEMP); + if (data == null) { + return; + } + OutputStream main = null; + try { + main = st.createOutputStream(FILE); + main.write(data); + main.flush(); + } catch (IOException e) { + com.codename1.io.Log.e(e); + return; + } finally { + close(main); + } + st.deleteStorageFile(TEMP); + } + + private static byte[] readAll(Storage st, String name) { + InputStream is = null; + try { + is = st.createInputStream(name); + if (is == null) { + return null; + } + java.io.ByteArrayOutputStream bo = new java.io.ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int n; + while ((n = is.read(buf)) > 0) { + bo.write(buf, 0, n); + } + return bo.toByteArray(); + } catch (IOException e) { + com.codename1.io.Log.e(e); + return null; + } finally { + close(is); + } + } + + /// The saved work, or none when nothing was saved or the file is from a + /// format this runtime does not read. + static List load() { + Storage st = Storage.getInstance(); + List main = loadFrom(st, FILE); + if (main == null && st.exists(TEMP)) { + // The copy into the main entry was cut short; the second entry + // was written whole before it began. + main = loadFrom(st, TEMP); + } + return main == null ? new ArrayList() : main; + } + + /// The specs in `name`, or null when it is missing or unreadable. + private static List loadFrom(Storage st, String name) { + List out = new ArrayList(); + if (!st.exists(name)) { + return null; + } + InputStream is = null; + try { + is = st.createInputStream(name); + DataInputStream in = new DataInputStream(is); + if (in.readInt() != VERSION) { + return out; + } + int n = in.readInt(); + for (int i = 0; i < n; i++) { + out.add(readSpec(in)); + } + } catch (IOException e) { + com.codename1.io.Log.e(e); + return null; + } catch (IllegalArgumentException e) { + // An enum constant this runtime no longer has. Nothing is kept: + // a partial queue would load dependents without the work they wait + // on, and the next save would make that permanent. + com.codename1.io.Log.e(e); + return new ArrayList(); + } finally { + close(is); + } + return out; + } + + private static void close(java.io.Closeable c) { + if (c != null) { + try { + c.close(); + } catch (IOException ignored) { + // nothing to recover + } + } + } +} diff --git a/maven/android-compat/src/main/java/androidx/work/Worker.java b/maven/android-compat/src/main/java/androidx/work/Worker.java new file mode 100644 index 00000000000..164b583e3ef --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/work/Worker.java @@ -0,0 +1,50 @@ +/* + * 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 androidx.work; + +import android.content.Context; + +import java.util.concurrent.Future; + +/// A worker whose [#doWork()] runs synchronously on a background thread. +public abstract class Worker extends ListenableWorker { + + public Worker(Context context, WorkerParameters workerParams) { + super(context, workerParams); + } + + public abstract Result doWork(); + + @Override + public final Future startWork() { + return WorkEngine.done(doWork()); + } + + public final void setProgress(Data data) { + setProgressAsync(data); + } + + public final void setForeground(ForegroundInfo info) { + setForegroundAsync(info); + } +} diff --git a/maven/android-compat/src/main/java/androidx/work/WorkerFactory.java b/maven/android-compat/src/main/java/androidx/work/WorkerFactory.java new file mode 100644 index 00000000000..6c672f46b67 --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/work/WorkerFactory.java @@ -0,0 +1,44 @@ +/* + * 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 androidx.work; + +import android.content.Context; + +/// Creates workers by class name. Return null to let the default creation +/// run, which calls the worker's `(Context, WorkerParameters)` constructor +/// through code the build generated -- there is no reflection on the device. +public abstract class WorkerFactory { + + public abstract ListenableWorker createWorker(Context appContext, String workerClassName, + WorkerParameters workerParameters); + + public static WorkerFactory getDefaultWorkerFactory() { + return new WorkerFactory() { + @Override + public ListenableWorker createWorker(Context appContext, String workerClassName, + WorkerParameters workerParameters) { + return null; + } + }; + } +} diff --git a/maven/android-compat/src/main/java/androidx/work/WorkerParameters.java b/maven/android-compat/src/main/java/androidx/work/WorkerParameters.java new file mode 100644 index 00000000000..822845815ae --- /dev/null +++ b/maven/android-compat/src/main/java/androidx/work/WorkerParameters.java @@ -0,0 +1,73 @@ +/* + * 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 androidx.work; + +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.Executor; + +public final class WorkerParameters { + + private final UUID id; + private final Data inputData; + private final Set tags; + private final int runAttemptCount; + private final int generation; + private final Executor backgroundExecutor; + final WorkEngine.Progress progress; + + WorkerParameters(UUID id, Data inputData, Set tags, int runAttemptCount, int generation, + Executor backgroundExecutor, WorkEngine.Progress progress) { + this.id = id; + this.inputData = inputData; + this.tags = tags; + this.runAttemptCount = runAttemptCount; + this.generation = generation; + this.backgroundExecutor = backgroundExecutor; + this.progress = progress; + } + + public UUID getId() { + return id; + } + + public Data getInputData() { + return inputData; + } + + public Set getTags() { + return tags; + } + + public int getRunAttemptCount() { + return runAttemptCount; + } + + public int getGeneration() { + return generation; + } + + public Executor getBackgroundExecutor() { + return backgroundExecutor; + } +} diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/AEADBadTagException.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/AEADBadTagException.java new file mode 100644 index 00000000000..9197309e4c2 --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/AEADBadTagException.java @@ -0,0 +1,40 @@ +/* + * 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.androidcompat.jdk; + +/// `javax.crypto.AEADBadTagException` for the Codename One runtime. +/// +/// The superclass is written under its JDK name on purpose: the remap step +/// rewrites it to this package's shim, so the hierarchy an application +/// catches against is the JDK's on the device too. +public class AEADBadTagException extends javax.crypto.BadPaddingException { + private static final long serialVersionUID = 1L; + + public AEADBadTagException() { + super(); + } + + public AEADBadTagException(String message) { + super(message); + } +} diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/AlgorithmParameterSpec.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/AlgorithmParameterSpec.java new file mode 100644 index 00000000000..299737e5890 --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/AlgorithmParameterSpec.java @@ -0,0 +1,27 @@ +/* + * 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.androidcompat.jdk; + +/// `java.security.spec.AlgorithmParameterSpec` for the Codename One runtime. +public interface AlgorithmParameterSpec { +} diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/AndroidKeyStoreEntries.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/AndroidKeyStoreEntries.java new file mode 100644 index 00000000000..f92b9aaf1fb --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/AndroidKeyStoreEntries.java @@ -0,0 +1,250 @@ +/* + * 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.androidcompat.jdk; + +import com.codename1.io.Storage; +import com.codename1.security.SecureStorage; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; + +/// Where the `AndroidKeyStore` keeps its keys. +/// +/// The key material goes into the quiet (non-prompting) half of +/// [SecureStorage] -- `SecureStorage.set(account, value)`, which is the iOS +/// keychain (`kSecAttrAccessibleAfterFirstUnlock`) and, on Android, AES-GCM +/// under a key of the platform's own `AndroidKeyStore`. Neither prompts, which +/// matters: an Android Keystore key made without +/// `setUserAuthenticationRequired(true)` -- the license or token case -- never +/// asks for a fingerprint, so an application that suddenly did would be broken, +/// not hardened. The biometric half of `SecureStorage` is never used. +/// +/// The alias, algorithm, modes and creation time are not secret and stay in +/// the application's private [Storage], one entry per alias: `SecureStorage` +/// cannot list its accounts, and `KeyStore.aliases()` must. +/// +/// #### Where the material is not protected +/// +/// A port with no quiet secure store (the base `SecureStorage`, whose `set` +/// answers false) gets the material written into that same [Storage] entry, in +/// the clear. That is the sandbox's protection and no more: a rooted device or +/// a backup of the application's data can read it. The entry records which of +/// the two it used, so a key is read back from where it was written. +/// +/// A key whose material is in `SecureStorage` and has gone missing (the user +/// cleared the keychain, the platform revoked the wrapping key) reads as +/// absent, as a key invalidated by the platform does on Android: the +/// application generates a new one. +final class AndroidKeyStoreEntries { + private static final String PREFIX = "cn1-androidks-"; + private static final int VERSION = 2; + private static final char[] HEX = "0123456789abcdef".toCharArray(); + + private AndroidKeyStoreEntries() { + } + + static String entryName(String alias) { + byte[] b; + try { + b = alias.getBytes("UTF-8"); + } catch (java.io.UnsupportedEncodingException e) { + throw new IllegalStateException("UTF-8 missing"); + } + StringBuilder sb = new StringBuilder(PREFIX.length() + b.length * 2); + sb.append(PREFIX); + for (int i = 0; i < b.length; i++) { + sb.append(HEX[(b[i] >> 4) & 0xf]).append(HEX[b[i] & 0xf]); + } + return sb.toString(); + } + + private static String aliasOf(String entry) { + if (!entry.startsWith(PREFIX)) { + return null; + } + int n = entry.length() - PREFIX.length(); + if (n % 2 != 0) { + return null; + } + byte[] b = new byte[n / 2]; + for (int i = 0; i < b.length; i++) { + int hi = Character.digit(entry.charAt(PREFIX.length() + i * 2), 16); + int lo = Character.digit(entry.charAt(PREFIX.length() + i * 2 + 1), 16); + if (hi < 0 || lo < 0) { + return null; + } + b[i] = (byte) ((hi << 4) | lo); + } + try { + return new String(b, "UTF-8"); + } catch (java.io.UnsupportedEncodingException e) { + return null; + } + } + + static boolean contains(String alias) { + return Storage.getInstance().exists(entryName(alias)); + } + + static List aliases() { + List out = new ArrayList(); + String[] entries = Storage.getInstance().listEntries(); + if (entries == null) { + return out; + } + for (int i = 0; i < entries.length; i++) { + String a = entries[i] == null ? null : aliasOf(entries[i]); + if (a != null) { + out.add(a); + } + } + return out; + } + + static void delete(String alias) { + String name = entryName(alias); + SecureStorage.getInstance().remove(name); + Storage.getInstance().deleteStorageFile(name); + } + + static void write(AndroidKeyStoreSecretKey key) throws IOException { + byte[] m = key.material(); + boolean secure = SecureStorage.getInstance().set(entryName(key.alias()), hex(m)); + OutputStream os = Storage.getInstance().createOutputStream(entryName(key.alias())); + if (os == null) { + throw new IOException("Storage unavailable"); + } + DataOutputStream out = new DataOutputStream(os); + try { + out.writeInt(VERSION); + out.writeUTF(key.getAlgorithm()); + out.writeInt(key.purposes()); + writeStrings(out, key.blockModes()); + writeStrings(out, key.paddings()); + out.writeBoolean(key.randomizedEncryptionRequired()); + out.writeLong(key.created()); + out.writeBoolean(secure); + if (secure) { + out.writeInt(0); + } else { + out.writeInt(m.length); + out.write(m); + } + } finally { + out.close(); + } + } + + /// The stored key, or null when nothing is stored under `alias`. + static AndroidKeyStoreSecretKey read(String alias) throws IOException { + String name = entryName(alias); + if (!Storage.getInstance().exists(name)) { + return null; + } + InputStream is = Storage.getInstance().createInputStream(name); + if (is == null) { + return null; + } + DataInputStream in = new DataInputStream(is); + try { + int version = in.readInt(); + if (version != VERSION) { + throw new IOException("Unknown key entry version " + version); + } + String algorithm = in.readUTF(); + int purposes = in.readInt(); + String[] modes = readStrings(in); + String[] paddings = readStrings(in); + boolean randomized = in.readBoolean(); + long created = in.readLong(); + boolean secure = in.readBoolean(); + int len = in.readInt(); + byte[] m; + if (secure) { + m = unhex(SecureStorage.getInstance().get(name)); + if (m == null) { + return null; + } + } else { + if (len <= 0 || len > 64) { + throw new IOException("Corrupt key entry"); + } + m = new byte[len]; + in.readFully(m); + } + return new AndroidKeyStoreSecretKey(alias, algorithm, purposes, modes, paddings, + randomized, created, m); + } finally { + in.close(); + } + } + + private static void writeStrings(DataOutputStream out, String[] s) throws IOException { + out.writeInt(s.length); + for (int i = 0; i < s.length; i++) { + out.writeUTF(s[i]); + } + } + + private static String[] readStrings(DataInputStream in) throws IOException { + int n = in.readInt(); + if (n < 0 || n > 32) { + throw new IOException("Corrupt key entry"); + } + String[] s = new String[n]; + for (int i = 0; i < n; i++) { + s[i] = in.readUTF(); + } + return s; + } + + private static String hex(byte[] b) { + StringBuilder sb = new StringBuilder(b.length * 2); + for (int i = 0; i < b.length; i++) { + sb.append(HEX[(b[i] >> 4) & 0xf]).append(HEX[b[i] & 0xf]); + } + return sb.toString(); + } + + /// The bytes `s` spells, or null when it is missing or not a key. + private static byte[] unhex(String s) { + if (s == null || s.length() == 0 || s.length() % 2 != 0 || s.length() > 128) { + return null; + } + byte[] b = new byte[s.length() / 2]; + for (int i = 0; i < b.length; i++) { + int hi = Character.digit(s.charAt(i * 2), 16); + int lo = Character.digit(s.charAt(i * 2 + 1), 16); + if (hi < 0 || lo < 0) { + return null; + } + b[i] = (byte) ((hi << 4) | lo); + } + return b; + } +} diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/AndroidKeyStoreSecretKey.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/AndroidKeyStoreSecretKey.java new file mode 100644 index 00000000000..8e50c493b7d --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/AndroidKeyStoreSecretKey.java @@ -0,0 +1,138 @@ +/* + * 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.androidcompat.jdk; + +/// A secret key that lives in the `AndroidKeyStore`. Like the platform's, it +/// does not export its bytes: [#getEncoded()] and [#getFormat()] answer null, +/// and only [Cipher] reads the material, through the package-private +/// [#material()]. +/// +/// It implements `javax.crypto.SecretKey` so the `(SecretKey) ks.getKey(..)` +/// cast application code writes always holds -- see the note on +/// ParparVM's unchecked `CHECKCAST` in the repository's guidance. +final class AndroidKeyStoreSecretKey implements javax.crypto.SecretKey { + private static final long serialVersionUID = 1L; + + private final String alias; + private final String algorithm; + private final int purposes; + private final String[] blockModes; + private final String[] paddings; + private final boolean randomizedEncryptionRequired; + private final long created; + private final byte[] material; + + AndroidKeyStoreSecretKey(String alias, String algorithm, int purposes, String[] blockModes, + String[] paddings, boolean randomizedEncryptionRequired, long created, + byte[] material) { + this.alias = alias; + this.algorithm = algorithm; + this.purposes = purposes; + this.blockModes = blockModes; + this.paddings = paddings; + this.randomizedEncryptionRequired = randomizedEncryptionRequired; + this.created = created; + this.material = material; + } + + @Override + public String getAlgorithm() { + return algorithm; + } + + /// Null, as on Android: the key material never leaves the key store. + @Override + public String getFormat() { + return null; + } + + /// Null, as on Android: the key material never leaves the key store. + @Override + @SuppressWarnings("PMD.ReturnEmptyCollectionRatherThanNull") + public byte[] getEncoded() { + return null; + } + + String alias() { + return alias; + } + + int purposes() { + return purposes; + } + + boolean randomizedEncryptionRequired() { + return randomizedEncryptionRequired; + } + + long created() { + return created; + } + + String[] blockModes() { + return copy(blockModes); + } + + String[] paddings() { + return copy(paddings); + } + + /// True when the key was generated without a block-mode restriction or + /// with `mode` among its allowed ones. + boolean allowsBlockMode(String mode) { + return allows(blockModes, mode); + } + + boolean allowsPadding(String padding) { + if (allows(paddings, padding)) { + return true; + } + // Android spells PKCS#7 for AES; the JDK name for the same padding is + // PKCS5, and Android's own provider accepts both. + return "PKCS5Padding".equalsIgnoreCase(padding) && allows(paddings, "PKCS7Padding"); + } + + private static boolean allows(String[] set, String value) { + if (set.length == 0) { + return true; + } + for (int i = 0; i < set.length; i++) { + if (set[i].equalsIgnoreCase(value)) { + return true; + } + } + return false; + } + + byte[] material() { + byte[] out = new byte[material.length]; + System.arraycopy(material, 0, out, 0, material.length); + return out; + } + + private static String[] copy(String[] a) { + String[] out = new String[a.length]; + System.arraycopy(a, 0, out, 0, a.length); + return out; + } +} diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/BadPaddingException.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/BadPaddingException.java new file mode 100644 index 00000000000..77156b507c1 --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/BadPaddingException.java @@ -0,0 +1,40 @@ +/* + * 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.androidcompat.jdk; + +/// `javax.crypto.BadPaddingException` for the Codename One runtime. +/// +/// The superclass is written under its JDK name on purpose: the remap step +/// rewrites it to this package's shim, so the hierarchy an application +/// catches against is the JDK's on the device too. +public class BadPaddingException extends java.security.GeneralSecurityException { + private static final long serialVersionUID = 1L; + + public BadPaddingException() { + super(); + } + + public BadPaddingException(String message) { + super(message); + } +} diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/CertificateException.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/CertificateException.java new file mode 100644 index 00000000000..343ecc9da0e --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/CertificateException.java @@ -0,0 +1,48 @@ +/* + * 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.androidcompat.jdk; + +/// `java.security.cert.CertificateException` for the Codename One runtime. +/// +/// The superclass is written under its JDK name on purpose: the remap step +/// rewrites it to this package's shim, so the hierarchy an application +/// catches against is the JDK's on the device too. +public class CertificateException extends java.security.GeneralSecurityException { + private static final long serialVersionUID = 1L; + + public CertificateException() { + super(); + } + + public CertificateException(String message) { + super(message); + } + + public CertificateException(String message, Throwable cause) { + super(message, cause); + } + + public CertificateException(Throwable cause) { + super(cause); + } +} diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/Cipher.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/Cipher.java new file mode 100644 index 00000000000..3c402130357 --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/Cipher.java @@ -0,0 +1,367 @@ +/* + * 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.androidcompat.jdk; + +import java.io.ByteArrayOutputStream; + +/// `javax.crypto.Cipher` for the Codename One runtime: AES in GCM, CBC and +/// ECB over `com.codename1.security.Cipher`. +/// +/// The input is buffered and processed in [#doFinal()]; `update` answers an +/// empty array, which the JDK allows. GCM takes only the 128-bit tag the core +/// produces, and a tag or padding that does not verify is reported as the +/// JDK reports it (`AEADBadTagException`, `BadPaddingException`). +public class Cipher { + public static final int ENCRYPT_MODE = 1; + public static final int DECRYPT_MODE = 2; + public static final int WRAP_MODE = 3; + public static final int UNWRAP_MODE = 4; + + private static final int BLOCK = 16; + private static final int GCM_TAG_BITS = 128; + private static final int GCM_IV = 12; + + private final String transformation; + private final String mode; + private final String padding; + private final String coreTransformation; + + private int opmode; + private byte[] key; + private byte[] iv; + private final ByteArrayOutputStream input = new ByteArrayOutputStream(); + private final ByteArrayOutputStream aad = new ByteArrayOutputStream(); + + protected Cipher(String transformation, String mode, String padding, String coreTransformation) { + this.transformation = transformation; + this.mode = mode; + this.padding = padding; + this.coreTransformation = coreTransformation; + } + + public static final Cipher getInstance(String transformation) + throws java.security.NoSuchAlgorithmException, javax.crypto.NoSuchPaddingException { + if (transformation == null || transformation.length() == 0) { + throw new java.security.NoSuchAlgorithmException("Null or empty transformation"); + } + String alg = transformation; + String m = "ECB"; + String p = "PKCS5Padding"; + int s1 = transformation.indexOf('/'); + if (s1 >= 0) { + int s2 = transformation.indexOf('/', s1 + 1); + if (s2 < 0 || transformation.indexOf('/', s2 + 1) >= 0) { + throw new java.security.NoSuchAlgorithmException("Invalid transformation: " + transformation); + } + alg = transformation.substring(0, s1).trim(); + m = transformation.substring(s1 + 1, s2).trim(); + p = transformation.substring(s2 + 1).trim(); + } + if (!"AES".equalsIgnoreCase(alg)) { + throw new java.security.NoSuchAlgorithmException("Cannot find any provider supporting " + transformation); + } + boolean noPad = "NoPadding".equalsIgnoreCase(p); + boolean pkcs = "PKCS5Padding".equalsIgnoreCase(p) || "PKCS7Padding".equalsIgnoreCase(p); + String core; + if ("GCM".equalsIgnoreCase(m)) { + if (!noPad) { + throw new javax.crypto.NoSuchPaddingException("GCM takes NoPadding, not " + p); + } + m = "GCM"; + core = com.codename1.security.Cipher.AES_GCM; + } else if ("CBC".equalsIgnoreCase(m)) { + if (!noPad && !pkcs) { + throw new javax.crypto.NoSuchPaddingException("Unsupported padding " + p); + } + m = "CBC"; + core = noPad ? com.codename1.security.Cipher.AES_CBC : com.codename1.security.Cipher.AES_CBC_PKCS5; + } else if ("ECB".equalsIgnoreCase(m)) { + if (!pkcs) { + throw new javax.crypto.NoSuchPaddingException("Unsupported padding " + p); + } + m = "ECB"; + core = com.codename1.security.Cipher.AES_ECB_PKCS5; + } else { + throw new java.security.NoSuchAlgorithmException("Cannot find any provider supporting " + transformation); + } + return new Cipher(transformation, m, noPad ? "NoPadding" : p, core); + } + + public static final Cipher getInstance(String transformation, String provider) + throws java.security.NoSuchAlgorithmException, java.security.NoSuchProviderException, + javax.crypto.NoSuchPaddingException { + if (provider == null || provider.length() == 0) { + throw new IllegalArgumentException("Missing provider"); + } + return getInstance(transformation); + } + + public final String getAlgorithm() { + return transformation; + } + + public final int getBlockSize() { + return BLOCK; + } + + public final int getOutputSize(int inputLen) { + int total = input.size() + Math.max(inputLen, 0); + if ("GCM".equals(mode)) { + return opmode == DECRYPT_MODE ? Math.max(total - GCM_TAG_BITS / 8, 0) : total + GCM_TAG_BITS / 8; + } + return opmode == DECRYPT_MODE ? total : (total / BLOCK + 1) * BLOCK; + } + + public final byte[] getIV() { + if (iv == null) { + return null; + } + byte[] out = new byte[iv.length]; + System.arraycopy(iv, 0, out, 0, iv.length); + return out; + } + + public final void init(int opmode, java.security.Key key) throws java.security.InvalidKeyException { + try { + init(opmode, key, (java.security.spec.AlgorithmParameterSpec) null, null); + } catch (java.security.InvalidAlgorithmParameterException e) { + // The JDK reports a cipher that needs parameters it was not given + // as an InvalidKeyException from this overload. + throw new java.security.InvalidKeyException(e.getMessage()); + } + } + + public final void init(int opmode, java.security.Key key, java.security.SecureRandom random) + throws java.security.InvalidKeyException { + init(opmode, key); + } + + public final void init(int opmode, java.security.Key key, java.security.spec.AlgorithmParameterSpec params) + throws java.security.InvalidKeyException, java.security.InvalidAlgorithmParameterException { + init(opmode, key, params, null); + } + + public final void init(int opmode, java.security.Key key, java.security.spec.AlgorithmParameterSpec params, + java.security.SecureRandom random) + throws java.security.InvalidKeyException, java.security.InvalidAlgorithmParameterException { + if (opmode != ENCRYPT_MODE && opmode != DECRYPT_MODE) { + throw new IllegalArgumentException("Unsupported opmode " + opmode); + } + byte[] material = material(opmode, key); + byte[] newIv = null; + if (params instanceof javax.crypto.spec.GCMParameterSpec) { + if (!"GCM".equals(mode)) { + throw new java.security.InvalidAlgorithmParameterException("GCMParameterSpec for " + mode); + } + javax.crypto.spec.GCMParameterSpec g = (javax.crypto.spec.GCMParameterSpec) params; + if (g.getTLen() != GCM_TAG_BITS) { + throw new java.security.InvalidAlgorithmParameterException( + "Unsupported GCM tag length: " + g.getTLen()); + } + newIv = g.getIV(); + } else if (params instanceof javax.crypto.spec.IvParameterSpec) { + if ("ECB".equals(mode)) { + throw new java.security.InvalidAlgorithmParameterException("ECB takes no IV"); + } + newIv = ((javax.crypto.spec.IvParameterSpec) params).getIV(); + if ("CBC".equals(mode) && newIv.length != BLOCK) { + throw new java.security.InvalidAlgorithmParameterException("Wrong IV length: must be 16 bytes long"); + } + } else if (params != null) { + throw new java.security.InvalidAlgorithmParameterException("Unsupported parameters"); + } + if (newIv != null && newIv.length == 0) { + throw new java.security.InvalidAlgorithmParameterException("Empty IV"); + } + if (newIv != null && opmode == ENCRYPT_MODE && key instanceof AndroidKeyStoreSecretKey + && ((AndroidKeyStoreSecretKey) key).randomizedEncryptionRequired()) { + // Android refuses a caller IV for a key generated with randomized + // encryption (the default), so code written for it never passes one. + throw new java.security.InvalidAlgorithmParameterException("Caller-provided IV not permitted"); + } + if (newIv == null && !"ECB".equals(mode)) { + if (opmode == DECRYPT_MODE) { + throw new java.security.InvalidAlgorithmParameterException( + "IV required when decrypting. Use IvParameterSpec or AlgorithmParameters to provide it."); + } + newIv = com.codename1.security.SecureRandom.bytes("GCM".equals(mode) ? GCM_IV : BLOCK); + } + if (opmode == ENCRYPT_MODE && "GCM".equals(mode) && lastGcmKey != null + && java.util.Arrays.equals(lastGcmKey, material) && java.util.Arrays.equals(lastGcmIv, newIv)) { + // Re-initialising with the key and IV of the last encryption is + // the same nonce reuse by another route; Conscrypt refuses it too. + throw new java.security.InvalidAlgorithmParameterException( + "Cannot reuse iv for GCM encryption"); + } + this.opmode = opmode; + this.key = material; + this.iv = newIv; + gcmSpent = false; + if (opmode == ENCRYPT_MODE && "GCM".equals(mode)) { + // Recorded at init, as Conscrypt does, so two inits with one IV + // are refused even without a doFinal between them. + lastGcmKey = material; + lastGcmIv = newIv; + } + input.reset(); + aad.reset(); + } + + private byte[] material(int opmode, java.security.Key key) throws java.security.InvalidKeyException { + if (key == null) { + throw new java.security.InvalidKeyException("No key given"); + } + if (!"AES".equalsIgnoreCase(key.getAlgorithm())) { + throw new java.security.InvalidKeyException("Wrong algorithm: AES required"); + } + byte[] m; + if (key instanceof AndroidKeyStoreSecretKey) { + AndroidKeyStoreSecretKey k = (AndroidKeyStoreSecretKey) key; + int needed = opmode == ENCRYPT_MODE ? 1 : 2; + if ((k.purposes() & needed) == 0) { + throw new java.security.InvalidKeyException("Incompatible purpose"); + } + if (!k.allowsBlockMode(mode)) { + throw new java.security.InvalidKeyException("Incompatible block mode: " + mode); + } + if (!k.allowsPadding(padding)) { + throw new java.security.InvalidKeyException("Incompatible padding mode: " + padding); + } + m = k.material(); + } else { + m = key.getEncoded(); + } + if (m == null || (m.length != 16 && m.length != 24 && m.length != 32)) { + throw new java.security.InvalidKeyException("Invalid AES key length"); + } + return m; + } + + private void checkInit() { + if (key == null) { + throw new IllegalStateException("Cipher not initialized"); + } + if (gcmSpent) { + // A second GCM encryption under the same key and IV leaks the + // XOR of the plaintexts and lets an attacker forge tags; Android + // refuses it the same way. + throw new IllegalStateException("Cipher must be reinitialized after a GCM encryption"); + } + } + + /// Set by a GCM encryption's doFinal; cleared by init. + private boolean gcmSpent; + + /// The key and IV of this cipher's last GCM encryption, which init will + /// not accept again. + private byte[] lastGcmKey; + private byte[] lastGcmIv; + + public final void updateAAD(byte[] src) { + if (src == null) { + throw new IllegalArgumentException("src buffer is null"); + } + updateAAD(src, 0, src.length); + } + + public final void updateAAD(byte[] src, int offset, int len) { + checkInit(); + if (!"GCM".equals(mode)) { + throw new IllegalStateException("AAD is supported only in GCM mode"); + } + if (input.size() > 0) { + throw new IllegalStateException("AAD must be supplied before encryption/decryption starts"); + } + aad.write(src, offset, len); + } + + public final byte[] update(byte[] in) { + if (in == null) { + throw new IllegalArgumentException("Null input buffer"); + } + return update(in, 0, in.length); + } + + public final byte[] update(byte[] in, int offset, int len) { + checkInit(); + if (in == null || offset < 0 || len < 0 || offset + len > in.length) { + throw new IllegalArgumentException("Bad arguments"); + } + input.write(in, offset, len); + return new byte[0]; + } + + public final byte[] doFinal(byte[] in) + throws javax.crypto.IllegalBlockSizeException, javax.crypto.BadPaddingException { + if (in == null) { + throw new IllegalArgumentException("Null input buffer"); + } + return doFinal(in, 0, in.length); + } + + public final byte[] doFinal(byte[] in, int offset, int len) + throws javax.crypto.IllegalBlockSizeException, javax.crypto.BadPaddingException { + update(in, offset, len); + return doFinal(); + } + + public final byte[] doFinal() + throws javax.crypto.IllegalBlockSizeException, javax.crypto.BadPaddingException { + checkInit(); + byte[] data = input.toByteArray(); + byte[] ad = aad.size() == 0 ? null : aad.toByteArray(); + input.reset(); + aad.reset(); + boolean gcm = "GCM".equals(mode); + if (!gcm && "NoPadding".equals(padding) && data.length % BLOCK != 0) { + throw new javax.crypto.IllegalBlockSizeException("Input length not multiple of 16 bytes"); + } + if (!gcm && opmode == DECRYPT_MODE && data.length % BLOCK != 0) { + throw new javax.crypto.IllegalBlockSizeException( + "Input length must be multiple of 16 when decrypting with padded cipher"); + } + com.codename1.security.SecretKey k = new com.codename1.security.SecretKey("AES", key); + if (opmode == ENCRYPT_MODE) { + try { + byte[] out = com.codename1.security.Cipher.aesEncrypt(coreTransformation, k, iv, ad, data); + if (gcm) { + gcmSpent = true; + } + return out; + } catch (com.codename1.security.CryptoException e) { + throw new javax.crypto.IllegalBlockSizeException(e.getMessage()); + } + } + if (gcm && data.length < GCM_TAG_BITS / 8) { + throw new javax.crypto.AEADBadTagException("Input data too short to contain an expected tag length of 16bytes"); + } + try { + return com.codename1.security.Cipher.aesDecrypt(coreTransformation, k, iv, ad, data); + } catch (com.codename1.security.CryptoException e) { + if (gcm) { + throw new javax.crypto.AEADBadTagException("Tag mismatch"); + } + throw new javax.crypto.BadPaddingException("Given final block not properly padded"); + } + } +} diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/GCMParameterSpec.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/GCMParameterSpec.java new file mode 100644 index 00000000000..b0cb18671a1 --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/GCMParameterSpec.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.androidcompat.jdk; + +/// `javax.crypto.spec.GCMParameterSpec` for the Codename One runtime. +public class GCMParameterSpec implements java.security.spec.AlgorithmParameterSpec { + private final int tLen; + private final byte[] iv; + + public GCMParameterSpec(int tLen, byte[] src) { + this(tLen, src, 0, src == null ? 0 : src.length); + } + + public GCMParameterSpec(int tLen, byte[] src, int offset, int len) { + if (tLen < 0) { + throw new IllegalArgumentException("Length argument is negative"); + } + if (src == null) { + throw new IllegalArgumentException("src array is null"); + } + if (offset < 0 || len < 0 || offset + len > src.length) { + throw new IllegalArgumentException("Invalid offset/length combination"); + } + this.tLen = tLen; + this.iv = new byte[len]; + System.arraycopy(src, offset, this.iv, 0, len); + } + + public int getTLen() { + return tLen; + } + + public byte[] getIV() { + byte[] out = new byte[iv.length]; + System.arraycopy(iv, 0, out, 0, iv.length); + return out; + } +} diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/IllegalBlockSizeException.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/IllegalBlockSizeException.java new file mode 100644 index 00000000000..08d6cf7f3a3 --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/IllegalBlockSizeException.java @@ -0,0 +1,40 @@ +/* + * 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.androidcompat.jdk; + +/// `javax.crypto.IllegalBlockSizeException` for the Codename One runtime. +/// +/// The superclass is written under its JDK name on purpose: the remap step +/// rewrites it to this package's shim, so the hierarchy an application +/// catches against is the JDK's on the device too. +public class IllegalBlockSizeException extends java.security.GeneralSecurityException { + private static final long serialVersionUID = 1L; + + public IllegalBlockSizeException() { + super(); + } + + public IllegalBlockSizeException(String message) { + super(message); + } +} diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/InvalidAlgorithmParameterException.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/InvalidAlgorithmParameterException.java new file mode 100644 index 00000000000..d034cfe4998 --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/InvalidAlgorithmParameterException.java @@ -0,0 +1,48 @@ +/* + * 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.androidcompat.jdk; + +/// `java.security.InvalidAlgorithmParameterException` for the Codename One runtime. +/// +/// The superclass is written under its JDK name on purpose: the remap step +/// rewrites it to this package's shim, so the hierarchy an application +/// catches against is the JDK's on the device too. +public class InvalidAlgorithmParameterException extends java.security.GeneralSecurityException { + private static final long serialVersionUID = 1L; + + public InvalidAlgorithmParameterException() { + super(); + } + + public InvalidAlgorithmParameterException(String message) { + super(message); + } + + public InvalidAlgorithmParameterException(String message, Throwable cause) { + super(message, cause); + } + + public InvalidAlgorithmParameterException(Throwable cause) { + super(cause); + } +} diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/InvalidKeyException.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/InvalidKeyException.java new file mode 100644 index 00000000000..a7813acc7fe --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/InvalidKeyException.java @@ -0,0 +1,48 @@ +/* + * 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.androidcompat.jdk; + +/// `java.security.InvalidKeyException` for the Codename One runtime. +/// +/// The superclass is written under its JDK name on purpose: the remap step +/// rewrites it to this package's shim, so the hierarchy an application +/// catches against is the JDK's on the device too. +public class InvalidKeyException extends java.security.KeyException { + private static final long serialVersionUID = 1L; + + public InvalidKeyException() { + super(); + } + + public InvalidKeyException(String message) { + super(message); + } + + public InvalidKeyException(String message, Throwable cause) { + super(message, cause); + } + + public InvalidKeyException(Throwable cause) { + super(cause); + } +} diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/IvParameterSpec.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/IvParameterSpec.java new file mode 100644 index 00000000000..c51f1d8fc83 --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/IvParameterSpec.java @@ -0,0 +1,49 @@ +/* + * 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.androidcompat.jdk; + +/// `javax.crypto.spec.IvParameterSpec` for the Codename One runtime. +public class IvParameterSpec implements java.security.spec.AlgorithmParameterSpec { + private final byte[] iv; + + public IvParameterSpec(byte[] iv) { + this(iv, 0, iv == null ? 0 : iv.length); + } + + public IvParameterSpec(byte[] iv, int offset, int len) { + if (iv == null) { + throw new IllegalArgumentException("IV missing"); + } + if (offset < 0 || len < 0 || offset + len > iv.length) { + throw new IllegalArgumentException("Invalid offset/length"); + } + this.iv = new byte[len]; + System.arraycopy(iv, offset, this.iv, 0, len); + } + + public byte[] getIV() { + byte[] out = new byte[iv.length]; + System.arraycopy(iv, 0, out, 0, iv.length); + return out; + } +} diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/Key.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/Key.java new file mode 100644 index 00000000000..2a09bafa53b --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/Key.java @@ -0,0 +1,32 @@ +/* + * 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.androidcompat.jdk; + +/// `java.security.Key` for the Codename One runtime. +public interface Key { + String getAlgorithm(); + + String getFormat(); + + byte[] getEncoded(); +} diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/KeyException.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/KeyException.java new file mode 100644 index 00000000000..9c76e9b8602 --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/KeyException.java @@ -0,0 +1,48 @@ +/* + * 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.androidcompat.jdk; + +/// `java.security.KeyException` for the Codename One runtime. +/// +/// The superclass is written under its JDK name on purpose: the remap step +/// rewrites it to this package's shim, so the hierarchy an application +/// catches against is the JDK's on the device too. +public class KeyException extends java.security.GeneralSecurityException { + private static final long serialVersionUID = 1L; + + public KeyException() { + super(); + } + + public KeyException(String message) { + super(message); + } + + public KeyException(String message, Throwable cause) { + super(message, cause); + } + + public KeyException(Throwable cause) { + super(cause); + } +} diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/KeyGenerator.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/KeyGenerator.java new file mode 100644 index 00000000000..e16ff13f221 --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/KeyGenerator.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.androidcompat.jdk; + +import android.security.keystore.KeyGenParameterSpec; + +import java.io.IOException; + +/// `javax.crypto.KeyGenerator` for the Codename One runtime: AES and HMAC +/// keys from `com.codename1.security.SecureRandom`. +/// +/// With the `AndroidKeyStore` provider the generator must be initialised with +/// a [KeyGenParameterSpec], and [#generateKey()] stores the key under the +/// spec's alias (see [AndroidKeyStoreEntries]) and answers a key whose bytes +/// cannot be exported, as on Android. Without it the key is a plain +/// [SecretKeySpec]. +public class KeyGenerator { + private static final String ANDROID_KEY_STORE = "AndroidKeyStore"; + + private final String algorithm; + private final boolean keyStore; + private int keySizeBits; + private KeyGenParameterSpec spec; + + protected KeyGenerator(String algorithm, boolean keyStore) { + this.algorithm = algorithm; + this.keyStore = keyStore; + this.keySizeBits = defaultSize(algorithm); + } + + private static String canonical(String algorithm) { + if (algorithm == null) { + return null; + } + if ("AES".equalsIgnoreCase(algorithm)) { + return "AES"; + } + if ("HmacSHA256".equalsIgnoreCase(algorithm)) { + return "HmacSHA256"; + } + if ("HmacSHA512".equalsIgnoreCase(algorithm)) { + return "HmacSHA512"; + } + return null; + } + + private static int defaultSize(String algorithm) { + if ("HmacSHA256".equals(algorithm)) { + return 256; + } + if ("HmacSHA512".equals(algorithm)) { + return 512; + } + return 128; + } + + public static KeyGenerator getInstance(String algorithm) + throws java.security.NoSuchAlgorithmException { + String c = canonical(algorithm); + if (c == null) { + throw new java.security.NoSuchAlgorithmException(algorithm + " KeyGenerator not available"); + } + return new KeyGenerator(c, false); + } + + public static KeyGenerator getInstance(String algorithm, String provider) + throws java.security.NoSuchAlgorithmException, java.security.NoSuchProviderException { + if (provider == null || provider.length() == 0) { + throw new IllegalArgumentException("missing provider"); + } + if (!ANDROID_KEY_STORE.equals(provider)) { + throw new java.security.NoSuchProviderException("no such provider: " + provider); + } + String c = canonical(algorithm); + if (c == null) { + throw new java.security.NoSuchAlgorithmException(algorithm + " KeyGenerator not available"); + } + return new KeyGenerator(c, true); + } + + public final String getAlgorithm() { + return algorithm; + } + + public final void init(java.security.SecureRandom random) { + // Randomness always comes from com.codename1.security.SecureRandom. + } + + public final void init(int keysize) { + init(keysize, null); + } + + public final void init(int keysize, java.security.SecureRandom random) { + if ("AES".equals(algorithm) && keysize != 128 && keysize != 192 && keysize != 256) { + throw new IllegalArgumentException("Unsupported AES key size: " + keysize); + } + if (keysize <= 0 || keysize % 8 != 0 || keysize > 512) { + throw new IllegalArgumentException("Unsupported key size: " + keysize); + } + keySizeBits = keysize; + } + + public final void init(java.security.spec.AlgorithmParameterSpec params) + throws java.security.InvalidAlgorithmParameterException { + init(params, null); + } + + public final void init(java.security.spec.AlgorithmParameterSpec params, + java.security.SecureRandom random) + throws java.security.InvalidAlgorithmParameterException { + if (!(params instanceof KeyGenParameterSpec)) { + throw new java.security.InvalidAlgorithmParameterException( + "Unsupported params: KeyGenParameterSpec required"); + } + KeyGenParameterSpec s = (KeyGenParameterSpec) params; + int size = s.getKeySize() < 0 ? defaultSize(algorithm) : s.getKeySize(); + try { + init(size, random); + } catch (IllegalArgumentException e) { + throw new java.security.InvalidAlgorithmParameterException(e.getMessage()); + } + if (s.getPurposes() == 0) { + throw new java.security.InvalidAlgorithmParameterException("No purposes specified"); + } + spec = s; + } + + public final javax.crypto.SecretKey generateKey() { + byte[] material = com.codename1.security.SecureRandom.bytes(keySizeBits / 8); + if (!keyStore) { + return new SecretKeySpec(material, algorithm); + } + if (spec == null) { + throw new IllegalStateException("Not initialized"); + } + AndroidKeyStoreSecretKey k = new AndroidKeyStoreSecretKey(spec.getKeystoreAlias(), algorithm, + spec.getPurposes(), spec.getBlockModes(), spec.getEncryptionPaddings(), + spec.isRandomizedEncryptionRequired(), System.currentTimeMillis(), material); + try { + AndroidKeyStoreEntries.write(k); + } catch (IOException e) { + // Android's provider reports a failed key store write as a + // ProviderException, an unchecked exception. + throw new IllegalStateException("Failed to store key " + spec.getKeystoreAlias() + ": " + e.getMessage()); + } + return k; + } +} diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/KeySpec.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/KeySpec.java new file mode 100644 index 00000000000..75cc20c7134 --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/KeySpec.java @@ -0,0 +1,27 @@ +/* + * 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.androidcompat.jdk; + +/// `java.security.spec.KeySpec` for the Codename One runtime. +public interface KeySpec { +} diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/KeyStore.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/KeyStore.java new file mode 100644 index 00000000000..f902ec881af --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/KeyStore.java @@ -0,0 +1,207 @@ +/* + * 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.androidcompat.jdk; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Date; +import java.util.Enumeration; +import java.util.List; +import java.util.Vector; + +/// `java.security.KeyStore` for the Codename One runtime. Only the +/// `AndroidKeyStore` type exists, and it holds secret keys generated through +/// `KeyGenerator.getInstance(alg, "AndroidKeyStore")`; where they are kept, +/// and why, is described on [AndroidKeyStoreEntries]. +/// +/// JDK types in signatures (`java.security.Key`, the checked exceptions) are +/// written under their JDK names: the remap step rewrites them to this +/// package's shims, as it does the application's own references. +public class KeyStore { + private static final String ANDROID_KEY_STORE = "AndroidKeyStore"; + + private boolean loaded; + + protected KeyStore() { + } + + public static KeyStore getInstance(String type) throws java.security.KeyStoreException { + if (!ANDROID_KEY_STORE.equals(type)) { + throw new java.security.KeyStoreException(type + " not found"); + } + return new KeyStore(); + } + + public static KeyStore getInstance(String type, String provider) + throws java.security.KeyStoreException, java.security.NoSuchProviderException { + if (provider == null || provider.length() == 0) { + throw new IllegalArgumentException("missing provider"); + } + if (!ANDROID_KEY_STORE.equals(provider)) { + throw new java.security.NoSuchProviderException("no such provider: " + provider); + } + return getInstance(type); + } + + public static String getDefaultType() { + return ANDROID_KEY_STORE; + } + + public final String getType() { + return ANDROID_KEY_STORE; + } + + public final void load(LoadStoreParameter param) + throws IOException, java.security.NoSuchAlgorithmException, + java.security.cert.CertificateException { + if (param != null) { + throw new IllegalArgumentException("AndroidKeyStore takes no load parameter"); + } + loaded = true; + } + + public final void load(InputStream stream, char[] password) + throws IOException, java.security.NoSuchAlgorithmException, + java.security.cert.CertificateException { + if (stream != null) { + throw new IllegalArgumentException("AndroidKeyStore cannot be loaded from a stream"); + } + loaded = true; + } + + private void checkLoaded() throws java.security.KeyStoreException { + if (!loaded) { + throw new java.security.KeyStoreException("Uninitialized keystore"); + } + } + + public final boolean containsAlias(String alias) throws java.security.KeyStoreException { + checkLoaded(); + return alias != null && AndroidKeyStoreEntries.contains(alias); + } + + public final Enumeration aliases() throws java.security.KeyStoreException { + checkLoaded(); + List all = AndroidKeyStoreEntries.aliases(); + Vector v = new Vector(all.size()); + for (int i = 0; i < all.size(); i++) { + v.addElement(all.get(i)); + } + return v.elements(); + } + + public final int size() throws java.security.KeyStoreException { + checkLoaded(); + return AndroidKeyStoreEntries.aliases().size(); + } + + public final boolean isKeyEntry(String alias) throws java.security.KeyStoreException { + return containsAlias(alias); + } + + public final boolean isCertificateEntry(String alias) throws java.security.KeyStoreException { + checkLoaded(); + return false; + } + + public final void deleteEntry(String alias) throws java.security.KeyStoreException { + checkLoaded(); + if (alias != null) { + AndroidKeyStoreEntries.delete(alias); + } + } + + /// The key stored under `alias`, or null when there is none. The answer + /// is always a `javax.crypto.SecretKey`, so the application's cast to one + /// holds. The password is ignored, as on Android. + public final java.security.Key getKey(String alias, char[] password) + throws java.security.KeyStoreException, java.security.NoSuchAlgorithmException, + java.security.UnrecoverableKeyException { + checkLoaded(); + if (alias == null) { + return null; + } + try { + return AndroidKeyStoreEntries.read(alias); + } catch (IOException e) { + java.security.UnrecoverableKeyException u = + new java.security.UnrecoverableKeyException("Failed to read key " + alias); + u.initCause(e); + throw u; + } + } + + public final Entry getEntry(String alias, ProtectionParameter protParam) + throws java.security.NoSuchAlgorithmException, java.security.UnrecoverableEntryException, + java.security.KeyStoreException { + java.security.Key k = getKey(alias, null); + if (k instanceof javax.crypto.SecretKey) { + return new SecretKeyEntry((javax.crypto.SecretKey) k); + } + return null; + } + + public final boolean entryInstanceOf(String alias, Class entryClass) + throws java.security.KeyStoreException { + return containsAlias(alias) && entryClass == SecretKeyEntry.class; + } + + public final Date getCreationDate(String alias) throws java.security.KeyStoreException { + checkLoaded(); + try { + AndroidKeyStoreSecretKey k = alias == null ? null : AndroidKeyStoreEntries.read(alias); + return k == null ? null : new Date(k.created()); + } catch (IOException e) { + return null; + } + } + + /// `KeyStore.LoadStoreParameter`. + public interface LoadStoreParameter { + ProtectionParameter getProtectionParameter(); + } + + /// `KeyStore.ProtectionParameter`. + public interface ProtectionParameter { + } + + /// `KeyStore.Entry`. + public interface Entry { + } + + /// `KeyStore.SecretKeyEntry`. + public static final class SecretKeyEntry implements Entry { + private final javax.crypto.SecretKey key; + + public SecretKeyEntry(javax.crypto.SecretKey secretKey) { + if (secretKey == null) { + throw new NullPointerException("invalid null input"); + } + this.key = secretKey; + } + + public javax.crypto.SecretKey getSecretKey() { + return key; + } + } +} diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/KeyStoreException.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/KeyStoreException.java new file mode 100644 index 00000000000..bfac031388e --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/KeyStoreException.java @@ -0,0 +1,48 @@ +/* + * 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.androidcompat.jdk; + +/// `java.security.KeyStoreException` for the Codename One runtime. +/// +/// The superclass is written under its JDK name on purpose: the remap step +/// rewrites it to this package's shim, so the hierarchy an application +/// catches against is the JDK's on the device too. +public class KeyStoreException extends java.security.GeneralSecurityException { + private static final long serialVersionUID = 1L; + + public KeyStoreException() { + super(); + } + + public KeyStoreException(String message) { + super(message); + } + + public KeyStoreException(String message, Throwable cause) { + super(message, cause); + } + + public KeyStoreException(Throwable cause) { + super(cause); + } +} diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/NoSuchPaddingException.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/NoSuchPaddingException.java new file mode 100644 index 00000000000..1df8c602688 --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/NoSuchPaddingException.java @@ -0,0 +1,40 @@ +/* + * 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.androidcompat.jdk; + +/// `javax.crypto.NoSuchPaddingException` for the Codename One runtime. +/// +/// The superclass is written under its JDK name on purpose: the remap step +/// rewrites it to this package's shim, so the hierarchy an application +/// catches against is the JDK's on the device too. +public class NoSuchPaddingException extends java.security.GeneralSecurityException { + private static final long serialVersionUID = 1L; + + public NoSuchPaddingException() { + super(); + } + + public NoSuchPaddingException(String message) { + super(message); + } +} diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/NoSuchProviderException.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/NoSuchProviderException.java new file mode 100644 index 00000000000..e95cb039b87 --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/NoSuchProviderException.java @@ -0,0 +1,40 @@ +/* + * 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.androidcompat.jdk; + +/// `java.security.NoSuchProviderException` for the Codename One runtime. +/// +/// The superclass is written under its JDK name on purpose: the remap step +/// rewrites it to this package's shim, so the hierarchy an application +/// catches against is the JDK's on the device too. +public class NoSuchProviderException extends java.security.GeneralSecurityException { + private static final long serialVersionUID = 1L; + + public NoSuchProviderException() { + super(); + } + + public NoSuchProviderException(String message) { + super(message); + } +} diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/SecretKey.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/SecretKey.java new file mode 100644 index 00000000000..6496e9717a7 --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/SecretKey.java @@ -0,0 +1,30 @@ +/* + * 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.androidcompat.jdk; + +/// `javax.crypto.SecretKey` for the Codename One runtime. +/// +/// Written against the JDK's `java.security.Key`, which the remap step +/// rewrites to [Key]. +public interface SecretKey extends java.security.Key { +} diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/SecretKeySpec.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/SecretKeySpec.java new file mode 100644 index 00000000000..fb45c869472 --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/SecretKeySpec.java @@ -0,0 +1,97 @@ +/* + * 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.androidcompat.jdk; + +/// `javax.crypto.spec.SecretKeySpec` for the Codename One runtime: raw key +/// bytes under an algorithm name, exportable like the JDK's. +public class SecretKeySpec implements java.security.spec.KeySpec, javax.crypto.SecretKey { + private static final long serialVersionUID = 1L; + + private final byte[] key; + private final String algorithm; + + public SecretKeySpec(byte[] key, String algorithm) { + this(key, 0, key == null ? 0 : key.length, algorithm); + } + + public SecretKeySpec(byte[] key, int offset, int len, String algorithm) { + if (key == null || algorithm == null) { + throw new IllegalArgumentException("Missing argument"); + } + if (len == 0) { + throw new IllegalArgumentException("Empty key"); + } + if (offset < 0 || len < 0 || offset + len > key.length) { + throw new IllegalArgumentException("Invalid offset/length combination"); + } + this.key = new byte[len]; + System.arraycopy(key, offset, this.key, 0, len); + this.algorithm = algorithm; + } + + @Override + public String getAlgorithm() { + return algorithm; + } + + @Override + public String getFormat() { + return "RAW"; + } + + @Override + public byte[] getEncoded() { + byte[] out = new byte[key.length]; + System.arraycopy(key, 0, out, 0, key.length); + return out; + } + + @Override + public int hashCode() { + int h = 0; + for (int i = 1; i < key.length; i++) { + h += key[i] * i; + } + return h ^ algorithm.hashCode(); + } + + /// Constant-time over the key bytes, like the JDK's. + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof SecretKeySpec)) { + return false; + } + SecretKeySpec other = (SecretKeySpec) o; + if (!algorithm.equalsIgnoreCase(other.algorithm) || key.length != other.key.length) { + return false; + } + int diff = 0; + for (int i = 0; i < key.length; i++) { + diff |= key[i] ^ other.key[i]; + } + return diff == 0; + } +} diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/UnrecoverableEntryException.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/UnrecoverableEntryException.java new file mode 100644 index 00000000000..5d80bf86a54 --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/UnrecoverableEntryException.java @@ -0,0 +1,40 @@ +/* + * 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.androidcompat.jdk; + +/// `java.security.UnrecoverableEntryException` for the Codename One runtime. +/// +/// The superclass is written under its JDK name on purpose: the remap step +/// rewrites it to this package's shim, so the hierarchy an application +/// catches against is the JDK's on the device too. +public class UnrecoverableEntryException extends java.security.GeneralSecurityException { + private static final long serialVersionUID = 1L; + + public UnrecoverableEntryException() { + super(); + } + + public UnrecoverableEntryException(String message) { + super(message); + } +} diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/UnrecoverableKeyException.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/UnrecoverableKeyException.java new file mode 100644 index 00000000000..8fbfb3a3ad6 --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/jdk/UnrecoverableKeyException.java @@ -0,0 +1,40 @@ +/* + * 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.androidcompat.jdk; + +/// `java.security.UnrecoverableKeyException` for the Codename One runtime. +/// +/// The superclass is written under its JDK name on purpose: the remap step +/// rewrites it to this package's shim, so the hierarchy an application +/// catches against is the JDK's on the device too. +public class UnrecoverableKeyException extends java.security.UnrecoverableEntryException { + private static final long serialVersionUID = 1L; + + public UnrecoverableKeyException() { + super(); + } + + public UnrecoverableKeyException(String message) { + super(message); + } +} diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/AndroidApp.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/AndroidApp.java index b81a756f4da..a33d75a4b85 100644 --- a/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/AndroidApp.java +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/AndroidApp.java @@ -79,6 +79,8 @@ public static final class ActivityInfo { private String versionName = "1.0"; private int versionCode = 1; private final List activities = new ArrayList(); + private final List> serviceTypes = new ArrayList>(); + private final List serviceNames = new ArrayList(); private Map tagIndex; protected AndroidApp(String table, String packageName) { @@ -112,6 +114,23 @@ protected final void activity(Class type, String className, int theme, int la activities.add(a); } + /// A `` the manifest declares. + protected final void service(Class type, String className) { + serviceTypes.add(type); + serviceNames.add(className); + } + + /// The declared service class named `className`, or null. + public Class serviceClass(String className) { + int i = serviceNames.indexOf(className); + return i < 0 ? null : serviceTypes.get(i); + } + + /// Whether the manifest declares `type` as a service. + public boolean isService(Class type) { + return serviceTypes.contains(type); + } + protected final void configChanges(Class type, int mask) { ActivityInfo a = activityInfo(type); if (a != null) { @@ -289,6 +308,12 @@ public int viewIndex(String tag) { public abstract Application createApplication(); + /// `new` for a declared service class, or null. Not abstract, so an + /// application factory generated before services existed still links. + public android.app.Service createService(Class type) { + return null; + } + /// `new` for the fragment class `className` (a binary name, as /// `Class.getName()` spells it), or null when the application has no such /// public class with a public no-argument constructor. The build diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/ContextImpl.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/ContextImpl.java index 0057ca0721f..a6ffc03c42c 100644 --- a/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/ContextImpl.java +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/ContextImpl.java @@ -104,6 +104,15 @@ public Object getSystemService(String name) { if (INPUT_METHOD_SERVICE.equals(name)) { return new InputMethodManager(); } + if (LOCATION_SERVICE.equals(name)) { + return android.location.LocationManager.getInstance(); + } + if (SENSOR_SERVICE.equals(name)) { + return android.hardware.SensorManager.getInstance(); + } + if (NOTIFICATION_SERVICE.equals(name)) { + return android.app.NotificationManager.getInstance(); + } CompatReport.unsupported("system service", name); return null; } diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/RoomFactory.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/RoomFactory.java new file mode 100644 index 00000000000..858ee195d88 --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/RoomFactory.java @@ -0,0 +1,41 @@ +/* + * 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.androidcompat.runtime; + +/// Creates the generated implementation of a Room `@Database` class by name. +/// +/// This copy is a placeholder: the Android remap step reads every +/// `@Database` class of the application, writes its `_Impl`, and replaces +/// this class with a name comparison and `new` for each, so +/// `Room.databaseBuilder` needs neither reflection nor `Class.forName`. +public final class RoomFactory { + + private RoomFactory() { + } + + /// The implementation of `databaseClassName`, or null when the build + /// generated none. + public static Object create(String databaseClassName) { + return null; + } +} diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/RoomSupport.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/RoomSupport.java new file mode 100644 index 00000000000..ad44687fd2e --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/RoomSupport.java @@ -0,0 +1,243 @@ +/* + * 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.androidcompat.runtime; + +import android.database.Cursor; +import androidx.room.RoomDatabase; +import androidx.sqlite.db.SimpleSQLiteQuery; +import androidx.sqlite.db.SupportSQLiteStatement; + +import java.util.Collection; +import java.util.Iterator; +import java.util.List; + +/// What the DAO implementations the Room build step generates call. Not an +/// application API. +public final class RoomSupport { + + private RoomSupport() { + } + + public static Cursor query(RoomDatabase db, String sql, List args) { + return db.query(sql, args.toArray()); + } + + /// Runs an INSERT and answers the row id, or -1 when a conflict + /// strategy skipped the row. + public static long insert(RoomDatabase db, String sql, Object[] args) { + SupportSQLiteStatement st = db.compileStatement(sql); + try { + SimpleSQLiteQuery.bind(st, args); + return st.executeInsert(); + } finally { + st.close(); + } + } + + /// Runs an UPDATE or DELETE and answers how many rows it changed. + public static int updateDelete(RoomDatabase db, String sql, Object[] args) { + SupportSQLiteStatement st = db.compileStatement(sql); + try { + SimpleSQLiteQuery.bind(st, args); + return st.executeUpdateDelete(); + } finally { + st.close(); + } + } + + public static int updateDelete(RoomDatabase db, String sql, List args) { + return updateDelete(db, sql, args.toArray()); + } + + public static void execute(RoomDatabase db, String sql, List args) { + SupportSQLiteStatement st = db.compileStatement(sql); + try { + SimpleSQLiteQuery.bind(st, args.toArray()); + st.execute(); + } finally { + st.close(); + } + } + + /// The column's index, looking past the backticks a result column may + /// keep, or -1. + public static int columnIndex(Cursor c, String name) { + int i = c.getColumnIndex(name); + if (i >= 0) { + return i; + } + i = c.getColumnIndex("`" + name + "`"); + if (i >= 0) { + return i; + } + // A view or a join can report a column as table.column. + String[] names = c.getColumnNames(); + String suffix = "." + name; + for (int k = 0; k < names.length; k++) { + String n = names[k]; + if (n.endsWith(suffix) || n.endsWith(suffix + "`")) { + return k; + } + } + return -1; + } + + public static int columnIndexOrThrow(Cursor c, String name) { + int i = columnIndex(c, name); + if (i < 0) { + throw new IllegalArgumentException("column '" + name + "' does not exist. Available columns: " + + java.util.Arrays.asList(c.getColumnNames())); + } + return i; + } + + /// Appends one placeholder per element of an `IN (:list)` parameter + /// and records the values. + public static void appendIn(StringBuilder sql, List args, Collection values) { + int n = 0; + if (values != null) { + Iterator it = values.iterator(); + while (it.hasNext()) { + sql.append(n++ == 0 ? "?" : ",?"); + args.add(it.next()); + } + } + } + + public static void appendIn(StringBuilder sql, List args, Object[] values) { + if (values != null) { + for (int i = 0; i < values.length; i++) { + sql.append(i == 0 ? "?" : ",?"); + args.add(values[i]); + } + } + } + + public static void appendIn(StringBuilder sql, List args, long[] values) { + if (values != null) { + for (int i = 0; i < values.length; i++) { + sql.append(i == 0 ? "?" : ",?"); + args.add(Long.valueOf(values[i])); + } + } + } + + public static void appendIn(StringBuilder sql, List args, int[] values) { + if (values != null) { + for (int i = 0; i < values.length; i++) { + sql.append(i == 0 ? "?" : ",?"); + args.add(Integer.valueOf(values[i])); + } + } + } + + public static void appendIn(StringBuilder sql, List args, short[] values) { + if (values != null) { + for (int i = 0; i < values.length; i++) { + sql.append(i == 0 ? "?" : ",?"); + args.add(Short.valueOf(values[i])); + } + } + } + + public static void appendIn(StringBuilder sql, List args, double[] values) { + if (values != null) { + for (int i = 0; i < values.length; i++) { + sql.append(i == 0 ? "?" : ",?"); + args.add(Double.valueOf(values[i])); + } + } + } + + public static void appendIn(StringBuilder sql, List args, float[] values) { + if (values != null) { + for (int i = 0; i < values.length; i++) { + sql.append(i == 0 ? "?" : ",?"); + args.add(Float.valueOf(values[i])); + } + } + } + + /// An enum constant by name, found without `Enum.valueOf` (which looks + /// the class up reflectively). + public static > E enumByName(E[] values, String name) { + if (name == null) { + return null; + } + for (E e : values) { + if (e.name().equals(name)) { + return e; + } + } + throw new IllegalArgumentException("Can't convert value to enum, unknown value: " + name); + } + + public static String enumName(Enum e) { + return e == null ? null : e.name(); + } + + /// Whether an insert failed on a primary key or unique constraint, the + /// case in which an upsert updates instead. + public static boolean isUniqueViolation(RuntimeException e) { + String m = e.getMessage(); + if (m == null) { + return false; + } + return containsIgnoreCase(m, "unique") || containsIgnoreCase(m, "primary key") + || m.indexOf("2067") >= 0 || m.indexOf("1555") >= 0; + } + + private static boolean containsIgnoreCase(String s, String part) { + int n = part.length(); + for (int i = 0; i + n <= s.length(); i++) { + if (s.regionMatches(true, i, part, 0, n)) { + return true; + } + } + return false; + } + + public static Object[] concat(Object[] a, Object[] b) { + Object[] out = new Object[a.length + b.length]; + System.arraycopy(a, 0, out, 0, a.length); + System.arraycopy(b, 0, out, a.length, b.length); + return out; + } + + /// The boolean a column holds, Room's way: nonzero is true. + public static boolean toBoolean(Cursor c, int i) { + return c.getInt(i) != 0; + } + + public static Object bindBoolean(boolean b) { + return Integer.valueOf(b ? 1 : 0); + } + + public static Object bindChar(Character c) { + return c == null ? null : Integer.valueOf(c.charValue()); + } + + public static Object bindBoolean(Boolean b) { + return b == null ? null : Integer.valueOf(b.booleanValue() ? 1 : 0); + } +} diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/WorkerInstantiator.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/WorkerInstantiator.java new file mode 100644 index 00000000000..5abb164e8b2 --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/runtime/WorkerInstantiator.java @@ -0,0 +1,39 @@ +/* + * 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.androidcompat.runtime; + +/// Creates a WorkManager worker by class name without reflection. +/// +/// This body is a placeholder: the build's remap step replaces the class with +/// one that `new`s every public, concrete `ListenableWorker` subclass of the +/// application that has a `(Context, WorkerParameters)` constructor. +public final class WorkerInstantiator { + + private WorkerInstantiator() { + } + + /// The worker `className` names, or null. + public static Object create(String className, Object context, Object params) { + return null; + } +} diff --git a/maven/android-compat/src/main/java/com/codename1/androidcompat/work/CompatBackgroundWorker.java b/maven/android-compat/src/main/java/com/codename1/androidcompat/work/CompatBackgroundWorker.java new file mode 100644 index 00000000000..c83d500d6bf --- /dev/null +++ b/maven/android-compat/src/main/java/com/codename1/androidcompat/work/CompatBackgroundWorker.java @@ -0,0 +1,49 @@ +/* + * 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.androidcompat.work; + +import com.codename1.background.BackgroundWorker; +import com.codename1.util.Callback; + +import java.util.Map; + +/// The Codename One `BackgroundWorker` that runs a WorkManager request the +/// platform scheduler released, once its constraints hold. It only names the +/// request; the request itself, and whether it still needs running, comes +/// from WorkManager's saved state, so a request the application already ran +/// in the foreground is not run again. +public class CompatBackgroundWorker implements BackgroundWorker { + + public CompatBackgroundWorker() { + } + + @Override + public void performWork(String workId, Map inputData, long deadline, Callback onComplete) { + String id = inputData == null ? null : inputData.get("id"); + if (id == null) { + onComplete.onSucess(Boolean.TRUE); + return; + } + androidx.work.WorkManagerBridge.run(id, onComplete); + } +} diff --git a/maven/android-compat/src/test/java/android/app/ServiceLifecycleTest.java b/maven/android-compat/src/test/java/android/app/ServiceLifecycleTest.java new file mode 100644 index 00000000000..a2933f10b66 --- /dev/null +++ b/maven/android-compat/src/test/java/android/app/ServiceLifecycleTest.java @@ -0,0 +1,192 @@ +/* + * 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 android.app; + +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.Binder; +import android.os.IBinder; +import com.codename1.androidcompat.testing.AndroidTestSupport; +import com.codename1.compat.testing.MainThreadRule; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class ServiceLifecycleTest { + + @Rule + public final MainThreadRule mainThread = new MainThreadRule(); + + private Context context; + + @Before + public void setUp() { + context = AndroidTestSupport.context(); + AndroidTestSupport.TestService.calls.clear(); + } + + @Test + public void startAndStopRunTheLifecycleOnTheMainThread() { + Intent i = new Intent(context, AndroidTestSupport.TestService.class).putExtra("n", 1); + assertNotNull(context.startService(i)); + MainThreadRule.drain(); + context.startService(new Intent(context, AndroidTestSupport.TestService.class).putExtra("n", 2)); + MainThreadRule.drain(); + assertEquals("[create, start:1:1, start:2:2]", AndroidTestSupport.TestService.calls.toString()); + assertTrue(ActiveServices.isRunning(AndroidTestSupport.TestService.class)); + assertTrue(context.stopService(new Intent(context, AndroidTestSupport.TestService.class))); + MainThreadRule.drain(); + assertFalse(ActiveServices.isRunning(AndroidTestSupport.TestService.class)); + assertEquals("destroy", AndroidTestSupport.TestService.calls.get(3)); + } + + @Test + public void boundServiceStaysUntilTheLastUnbind() { + final IBinder[] got = new IBinder[1]; + ServiceConnection conn = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + got[0] = service; + } + + @Override + public void onServiceDisconnected(ComponentName name) { + } + }; + assertTrue(context.bindService(new Intent(context, AndroidTestSupport.TestService.class), conn, + Context.BIND_AUTO_CREATE)); + MainThreadRule.drain(); + assertNotNull(got[0]); + assertSame(AndroidTestSupport.TestService.last, + ((AndroidTestSupport.TestService.LocalBinder) got[0]).service()); + context.unbindService(conn); + MainThreadRule.drain(); + assertFalse(ActiveServices.isRunning(AndroidTestSupport.TestService.class)); + assertEquals("[create, bind, unbind, destroy]", AndroidTestSupport.TestService.calls.toString()); + } + + /// A stop in the same frame as its start stops the service; it used to + /// be judged before the start ran, answer false and leave it running. + @Test + public void startThenStopInOneFrame() { + context.startService(new Intent(context, AndroidTestSupport.TestService.class).putExtra("n", 1)); + assertTrue(context.stopService(new Intent(context, AndroidTestSupport.TestService.class))); + MainThreadRule.drain(); + assertFalse(ActiveServices.isRunning(AndroidTestSupport.TestService.class)); + assertEquals("[create, start:1:1, destroy]", AndroidTestSupport.TestService.calls.toString()); + assertFalse("nothing is left to stop", + context.stopService(new Intent(context, AndroidTestSupport.TestService.class))); + } + + /// An unbind in the same frame as its bind is accepted and nothing + /// connects; it used to throw "Service not registered". + @Test + public void bindThenUnbindInOneFrame() { + final boolean[] connected = new boolean[1]; + ServiceConnection conn = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + connected[0] = true; + } + + @Override + public void onServiceDisconnected(ComponentName name) { + } + }; + assertTrue(context.bindService(new Intent(context, AndroidTestSupport.TestService.class), conn, + Context.BIND_AUTO_CREATE)); + context.unbindService(conn); + MainThreadRule.drain(); + assertFalse(connected[0]); + assertFalse(ActiveServices.isRunning(AndroidTestSupport.TestService.class)); + } + + /// stopSelf(startId) compares the id when the stop runs, in order with + /// the starts, so a start delivered first keeps the service running. + @Test + public void stopSelfForAnOlderStartKeepsANewerOne() { + context.startService(new Intent(context, AndroidTestSupport.TestService.class).putExtra("n", 1)); + MainThreadRule.drain(); + context.startService(new Intent(context, AndroidTestSupport.TestService.class).putExtra("n", 2)); + AndroidTestSupport.TestService.last.stopSelf(1); + MainThreadRule.drain(); + assertTrue(ActiveServices.isRunning(AndroidTestSupport.TestService.class)); + assertTrue(context.stopService(new Intent(context, AndroidTestSupport.TestService.class))); + MainThreadRule.drain(); + assertFalse(ActiveServices.isRunning(AndroidTestSupport.TestService.class)); + } + + /// A start that arrives after a stopSelf has stopped the service starts + /// it again, and a later stopService still stops it. + @Test + public void aStartAfterStopSelfCanStillBeStopped() { + context.startService(new Intent(context, AndroidTestSupport.TestService.class).putExtra("n", 1)); + MainThreadRule.drain(); + AndroidTestSupport.TestService.last.stopSelf(1); + context.startService(new Intent(context, AndroidTestSupport.TestService.class).putExtra("n", 2)); + MainThreadRule.drain(); + assertTrue(ActiveServices.isRunning(AndroidTestSupport.TestService.class)); + assertTrue(context.stopService(new Intent(context, AndroidTestSupport.TestService.class))); + MainThreadRule.drain(); + assertFalse(ActiveServices.isRunning(AndroidTestSupport.TestService.class)); + } + + @Test + public void anUndeclaredServiceDoesNotStart() { + assertNull(context.startService(new Intent(context, Undeclared.class))); + } + + @Test(expected = IllegalArgumentException.class) + public void implicitServiceIntentsAreRefused() { + context.startService(new Intent("some.ACTION")); + } + + @Test + public void notificationsBuildAndPost() { + NotificationManager nm = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + assertSame(NotificationManager.getInstance(), nm); + nm.createNotificationChannel(new NotificationChannel("c", "Channel", NotificationManager.IMPORTANCE_LOW)); + Notification n = new Notification.Builder(context, "c").setContentTitle("T").setContentText("B") + .setProgress(10, 3, false).build(); + assertEquals("c", n.getChannelId()); + nm.notify(5, n); + nm.cancel(5); + assertNotNull(nm.getNotificationChannel("c")); + } + + public static final class Undeclared extends Service { + @Override + public IBinder onBind(Intent intent) { + return new Binder(); + } + } +} diff --git a/maven/android-compat/src/test/java/android/database/sqlite/JdbcContexts.java b/maven/android-compat/src/test/java/android/database/sqlite/JdbcContexts.java new file mode 100644 index 00000000000..a190bd92731 --- /dev/null +++ b/maven/android-compat/src/test/java/android/database/sqlite/JdbcContexts.java @@ -0,0 +1,69 @@ +/* + * 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 android.database.sqlite; + +import android.content.Context; +import android.content.ContextWrapper; +import android.database.DatabaseErrorHandler; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; + +/// A context whose databases are JDBC SQLite files in a temporary +/// directory, so a database outlives the helper that opened it, for tests +/// outside this package (Room's). +public final class JdbcContexts { + + private JdbcContexts() { + } + + public static Context create() { + final File dir; + try { + dir = Files.createTempDirectory("room-test").toFile(); + } catch (IOException e) { + throw new IllegalStateException(e); + } + dir.deleteOnExit(); + return new ContextWrapper(null) { + @Override + public SQLiteDatabase openOrCreateDatabase(String name, int mode, SQLiteDatabase.CursorFactory factory, + DatabaseErrorHandler errorHandler) { + File f = new File(dir, name); + f.deleteOnExit(); + try { + return SQLiteDatabase.wrap(new JdbcDatabase("jdbc:sqlite:" + f.getAbsolutePath()), name, + factory, errorHandler); + } catch (IOException e) { + throw new SQLiteException(e.getMessage()); + } + } + + @Override + public boolean deleteDatabase(String name) { + return new File(dir, name).delete(); + } + }; + } +} diff --git a/maven/android-compat/src/test/java/android/database/sqlite/JdbcDatabase.java b/maven/android-compat/src/test/java/android/database/sqlite/JdbcDatabase.java index 6150a15d06b..f55ee7cffb0 100644 --- a/maven/android-compat/src/test/java/android/database/sqlite/JdbcDatabase.java +++ b/maven/android-compat/src/test/java/android/database/sqlite/JdbcDatabase.java @@ -42,8 +42,12 @@ class JdbcDatabase extends Database { private final Connection conn; JdbcDatabase() throws IOException { + this("jdbc:sqlite::memory:"); + } + + JdbcDatabase(String url) throws IOException { try { - conn = DriverManager.getConnection("jdbc:sqlite::memory:"); + conn = DriverManager.getConnection(url); } catch (java.sql.SQLException e) { throw new IOException(e.getMessage()); } diff --git a/maven/android-compat/src/test/java/android/database/sqlite/SQLiteDatabaseTest.java b/maven/android-compat/src/test/java/android/database/sqlite/SQLiteDatabaseTest.java index 7ece2c8fefb..bab60727c82 100644 --- a/maven/android-compat/src/test/java/android/database/sqlite/SQLiteDatabaseTest.java +++ b/maven/android-compat/src/test/java/android/database/sqlite/SQLiteDatabaseTest.java @@ -389,6 +389,7 @@ public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { /// rolled back with ours. @Test public void anotherThreadDoesNotNestIntoThisThreadsTransaction() throws Exception { + SQLiteDatabase.BUSY_TIMEOUT_MS.set(50); db.beginTransaction(); final boolean[] otherSawTransaction = new boolean[1]; final Throwable[] otherFailure = new Throwable[1]; @@ -444,6 +445,7 @@ public void run() { /// thread's insert, and its query saw rows that were never committed. @Test public void anotherThreadsStatementDoesNotJoinThisThreadsTransaction() throws Exception { + SQLiteDatabase.BUSY_TIMEOUT_MS.set(50); db.beginTransaction(); db.insert("notes", null, note("uncommitted", 1)); final Throwable[] failures = new Throwable[2]; @@ -479,6 +481,7 @@ public void run() { /// call, so the outcome does not depend on timing. @Test public void anotherThreadsStatementIsRefusedWhileTheEngineTransactionBeginsAndCommits() throws Exception { + SQLiteDatabase.BUSY_TIMEOUT_MS.set(50); final SQLiteDatabase[] shared = new SQLiteDatabase[1]; final List outcomes = new ArrayList(); final boolean[] armed = new boolean[1]; @@ -514,6 +517,48 @@ public void commitTransaction() throws java.io.IOException { shared[0].close(); } + @After + public void restoreBusyTimeout() { + SQLiteDatabase.BUSY_TIMEOUT_MS.set(10000); + } + + /// A second thread waits for this thread's transaction rather than + /// failing, as Android's connection pool makes it: two Room executor + /// threads writing at once is ordinary. + @Test + public void anotherThreadWaitsForThisThreadsTransactionToEnd() throws Exception { + db.beginTransaction(); + db.insert("notes", null, note("mine", 1)); + final Throwable[] failure = new Throwable[1]; + final boolean[] done = new boolean[1]; + Thread other = new Thread(new Runnable() { + @Override + public void run() { + try { + db.beginTransaction(); + try { + db.insert("notes", null, note("theirs", 2)); + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + done[0] = true; + } catch (Throwable t) { + failure[0] = t; + } + } + }); + other.start(); + other.join(200); + assertTrue("still waiting while ours is open", other.isAlive()); + db.setTransactionSuccessful(); + db.endTransaction(); + other.join(5000); + assertNull(String.valueOf(failure[0]), failure[0]); + assertTrue(done[0]); + assertEquals(2, DatabaseUtils.queryNumEntries(db, "notes")); + } + /// Inserts `value` from a new thread and waits for it: "refused" for /// the locked-database refusal, "ran" when the insert went through. private static String insertFromAnotherThread(final SQLiteDatabase d, final String value) { diff --git a/maven/android-compat/src/test/java/android/hardware/SensorManagerTest.java b/maven/android-compat/src/test/java/android/hardware/SensorManagerTest.java new file mode 100644 index 00000000000..30b67093178 --- /dev/null +++ b/maven/android-compat/src/test/java/android/hardware/SensorManagerTest.java @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package android.hardware; + +import com.codename1.androidcompat.testing.AndroidTestSupport; +import com.codename1.compat.testing.MainThreadRule; +import com.codename1.sensors.MotionSensorManager; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +public class SensorManagerTest { + + @Rule + public final MainThreadRule mainThread = new MainThreadRule(); + + static final class FakeSensors extends MotionSensorManager { + boolean magnetometer = true; + + @Override + protected boolean isNativeSensorSupported(int type) { + return type != TYPE_MAGNETOMETER || magnetometer; + } + + @Override + protected void startNativeSensor(int type) { + } + + @Override + protected void stopNativeSensor(int type) { + } + + @Override + protected boolean readNativeSensor(int type, float[] out) { + return false; + } + } + + private FakeSensors fake; + + @Before + public void setUp() { + AndroidTestSupport.context(); + fake = new FakeSensors(); + SensorManager.override = fake; + } + + @After + public void tearDown() { + SensorManager.override = null; + } + + @Test + public void accelerometerEventsCarryTheReading() { + SensorManager sm = SensorManager.getInstance(); + Sensor acc = sm.getDefaultSensor(Sensor.TYPE_ACCELEROMETER); + assertNotNull(acc); + final List seen = new ArrayList(); + SensorEventListener l = new SensorEventListener() { + @Override + public void onSensorChanged(SensorEvent event) { + seen.add(event.values.clone()); + } + + @Override + public void onAccuracyChanged(Sensor sensor, int accuracy) { + } + }; + assertTrue(sm.registerListener(l, acc, SensorManager.SENSOR_DELAY_FASTEST)); + sm.feed(MotionSensorManager.TYPE_ACCELEROMETER, 1f, 2f, 9.8f); + MainThreadRule.drain(); + sm.unregisterListener(l); + sm.feed(MotionSensorManager.TYPE_ACCELEROMETER, 5f, 5f, 5f); + MainThreadRule.drain(); + assertEquals(1, seen.size()); + assertEquals(9.8f, seen.get(0)[2], 1e-6f); + } + + @Test + public void aCompassNeedsAMagnetometer() { + fake.magnetometer = false; + assertNull(SensorManager.getInstance().getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)); + } + + @Test + public void rotationMatrixAndOrientationOfAFlatDeviceFacingNorth() { + float[] r = new float[9]; + float[] gravity = {0f, 0f, 9.81f}; + float[] magnetic = {0f, 22f, -40f}; + assertTrue(SensorManager.getRotationMatrix(r, null, gravity, magnetic)); + float[] o = SensorManager.getOrientation(r, new float[3]); + assertEquals(0f, o[0], 1e-4f); + assertEquals(0f, o[1], 1e-4f); + assertEquals(0f, o[2], 1e-4f); + assertEquals(1f, r[0], 1e-5f); + assertEquals(1f, r[4], 1e-5f); + assertEquals(1f, r[8], 1e-5f); + } +} diff --git a/maven/android-compat/src/test/java/android/location/LocationManagerTest.java b/maven/android-compat/src/test/java/android/location/LocationManagerTest.java new file mode 100644 index 00000000000..98e24a6413e --- /dev/null +++ b/maven/android-compat/src/test/java/android/location/LocationManagerTest.java @@ -0,0 +1,197 @@ +/* + * 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 android.location; + +import android.content.Context; +import com.codename1.androidcompat.testing.AndroidTestSupport; +import com.codename1.compat.testing.MainThreadRule; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +public class LocationManagerTest { + + @Rule + public final MainThreadRule mainThread = new MainThreadRule(); + + /// A Codename One location manager whose fixes the test supplies. + static final class FakeLocations extends com.codename1.location.LocationManager { + com.codename1.location.Location last; + int binds; + int clears; + + @Override + public com.codename1.location.Location getCurrentLocation() { + return last; + } + + @Override + public com.codename1.location.Location getLastKnownLocation() { + return last; + } + + @Override + protected void bindListener() { + binds++; + } + + @Override + protected void clearListener() { + clears++; + } + + void fix(double lat, double lon) { + com.codename1.location.Location l = new com.codename1.location.Location(); + l.setLatitude(lat); + l.setLongitude(lon); + l.setAccuracy(5); + l.setTimeStamp(System.currentTimeMillis()); + last = l; + if (getListener() != null) { + getListener().locationUpdated(l); + } + } + } + + /// One for the whole class: the shim is a per-application singleton + /// that binds its platform manager once, as the real one never changes. + private static final FakeLocations fake = new FakeLocations(); + + @Before + public void setUp() { + AndroidTestSupport.context(); + LocationManager.override = fake; + } + + @After + public void tearDown() { + LocationManager.override = null; + } + + @Test + public void systemServiceIsTheShim() { + Context c = AndroidTestSupport.context(); + assertSame(LocationManager.getInstance(), c.getSystemService(Context.LOCATION_SERVICE)); + } + + @Test + public void updatesFanOutAndHonourMinDistance() { + LocationManager lm = LocationManager.getInstance(); + final List all = new ArrayList(); + final List far = new ArrayList(); + LocationListener a = new LocationListener() { + @Override + public void onLocationChanged(Location location) { + all.add(location); + } + }; + LocationListener b = new LocationListener() { + @Override + public void onLocationChanged(Location location) { + far.add(location); + } + }; + lm.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0, a); + lm.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 1000, b); + MainThreadRule.drain(); + assertTrue(fake.binds > 0); + fake.fix(32.0, 34.0); + fake.fix(32.0001, 34.0); + fake.fix(33.0, 34.0); + MainThreadRule.drain(); + assertEquals(3, all.size()); + assertEquals(2, far.size()); + assertEquals(LocationManager.GPS_PROVIDER, all.get(0).getProvider()); + assertEquals(32.0, all.get(0).getLatitude(), 1e-9); + lm.removeUpdates(a); + lm.removeUpdates(b); + MainThreadRule.drain(); + fake.fix(34.0, 34.0); + MainThreadRule.drain(); + assertEquals(3, all.size()); + } + + /// A listener removed by another listener's callback hears nothing more, + /// not even the fix being delivered when it was removed. + @Test + public void aListenerRemovedDuringDeliveryHearsNothing() { + final LocationManager lm = LocationManager.getInstance(); + final List heardByB = new ArrayList(); + final LocationListener b = new LocationListener() { + @Override + public void onLocationChanged(Location location) { + heardByB.add(location); + } + }; + LocationListener a = new LocationListener() { + @Override + public void onLocationChanged(Location location) { + lm.removeUpdates(b); + } + }; + lm.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0, a); + lm.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0, b); + MainThreadRule.drain(); + fake.fix(32.0, 34.0); + MainThreadRule.drain(); + assertEquals(0, heardByB.size()); + lm.removeUpdates(a); + MainThreadRule.drain(); + } + + @Test(expected = IllegalArgumentException.class) + public void theExecutorOverloadRefusesAnUnknownProvider() { + LocationManager.getInstance().requestLocationUpdates("nope", 0, 0, new java.util.concurrent.Executor() { + @Override + public void execute(Runnable command) { + command.run(); + } + }, new LocationListener() { + @Override + public void onLocationChanged(Location location) { + } + }); + } + + @Test + public void lastKnownLocationAndDistance() { + fake.fix(0, 0); + Location l = LocationManager.getInstance().getLastKnownLocation(LocationManager.GPS_PROVIDER); + assertNotNull(l); + assertTrue(l.hasAccuracy()); + float[] out = new float[1]; + Location.distanceBetween(0, 0, 0, 1, out); + // One degree of longitude at the equator: within 0.2% of the answer + // Android computes on the WGS84 ellipsoid. + assertEquals(111319.5f, out[0], 200f); + } +} diff --git a/maven/android-compat/src/test/java/android/security/keystore/AndroidKeyStoreTest.java b/maven/android-compat/src/test/java/android/security/keystore/AndroidKeyStoreTest.java new file mode 100644 index 00000000000..bcd97fbd04d --- /dev/null +++ b/maven/android-compat/src/test/java/android/security/keystore/AndroidKeyStoreTest.java @@ -0,0 +1,349 @@ +/* + * 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 android.security.keystore; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Enumeration; +import java.util.List; + +import javax.crypto.AEADBadTagException; +import javax.crypto.BadPaddingException; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.IvParameterSpec; + +import com.codename1.androidcompat.jdk.Cipher; +import com.codename1.androidcompat.jdk.KeyGenerator; +import com.codename1.androidcompat.jdk.KeyStore; +import com.codename1.androidcompat.jdk.SecretKeySpec; +import com.codename1.compat.testing.HeadlessImplementation; +import com.codename1.compat.testing.MainThreadRule; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +/// The license/token flow written for the platform's `AndroidKeyStore`, run +/// against the shims the remap step points it at. The JDK names in these +/// signatures (`GCMParameterSpec`, `SecretKey`, the exceptions) are what the +/// shims are compiled against; on a device they are remapped too. +public class AndroidKeyStoreTest { + + @Rule + public final MainThreadRule mainThread = new MainThreadRule(); + + private static final byte[] DATA; + + static { + try { + DATA = "license:4242-ACME".getBytes("UTF-8"); + } catch (java.io.UnsupportedEncodingException e) { + throw new IllegalStateException(e); + } + } + + @Before + public void clean() { + HeadlessImplementation.install(); + HeadlessImplementation.STORAGE.clear(); + HeadlessImplementation.SECURE.clear(); + HeadlessImplementation.SECURE_STORAGE_ON.set(false); + com.codename1.io.Storage.getInstance().clearCache(); + } + + private static KeyStore loaded() throws Exception { + KeyStore ks = KeyStore.getInstance("AndroidKeyStore"); + ks.load(null); + return ks; + } + + private static void generate(String alias, String mode, String padding) throws Exception { + KeyGenerator kg = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore"); + kg.init(new KeyGenParameterSpec.Builder(alias, + KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(mode).setEncryptionPaddings(padding).build()); + SecretKey k = kg.generateKey(); + assertNull("keystore keys do not export", k.getEncoded()); + } + + @Test + public void gcmRoundTripsThroughAStoredKey() throws Exception { + KeyStore ks = loaded(); + assertFalse(ks.containsAlias("lic")); + generate("lic", KeyProperties.BLOCK_MODE_GCM, KeyProperties.ENCRYPTION_PADDING_NONE); + assertTrue(ks.containsAlias("lic")); + + SecretKey k = (SecretKey) ks.getKey("lic", null); + Cipher c = Cipher.getInstance("AES/GCM/NoPadding"); + c.init(Cipher.ENCRYPT_MODE, k); + byte[] iv = c.getIV(); + assertEquals(12, iv.length); + byte[] ct = c.doFinal(DATA); + assertEquals(DATA.length + 16, ct.length); + + Cipher d = Cipher.getInstance("AES/GCM/NoPadding"); + d.init(Cipher.DECRYPT_MODE, k, new GCMParameterSpec(128, iv)); + assertArrayEquals(DATA, d.doFinal(ct)); + } + + @Test + public void cbcPkcs7RoundTrips() throws Exception { + generate("cbc", KeyProperties.BLOCK_MODE_CBC, KeyProperties.ENCRYPTION_PADDING_PKCS7); + SecretKey k = (SecretKey) loaded().getKey("cbc", null); + Cipher c = Cipher.getInstance("AES/CBC/PKCS7Padding"); + c.init(Cipher.ENCRYPT_MODE, k); + byte[] iv = c.getIV(); + assertEquals(16, iv.length); + c.update(DATA, 0, 4); + byte[] ct = c.doFinal(DATA, 4, DATA.length - 4); + assertEquals(0, ct.length % 16); + + Cipher d = Cipher.getInstance("AES/CBC/PKCS7Padding"); + d.init(Cipher.DECRYPT_MODE, k, new IvParameterSpec(iv)); + assertArrayEquals(DATA, d.doFinal(ct)); + } + + @Test + public void aliasesPersistAcrossInstancesAndDelete() throws Exception { + generate("one", KeyProperties.BLOCK_MODE_GCM, KeyProperties.ENCRYPTION_PADDING_NONE); + generate("two/with space", KeyProperties.BLOCK_MODE_GCM, KeyProperties.ENCRYPTION_PADDING_NONE); + SecretKey first = (SecretKey) loaded().getKey("one", null); + Cipher c = Cipher.getInstance("AES/GCM/NoPadding"); + c.init(Cipher.ENCRYPT_MODE, first); + byte[] iv = c.getIV(); + byte[] ct = c.doFinal(DATA); + + com.codename1.io.Storage.getInstance().clearCache(); + KeyStore fresh = loaded(); + List names = new ArrayList(); + for (Enumeration e = fresh.aliases(); e.hasMoreElements();) { + names.add(e.nextElement()); + } + Collections.sort(names); + assertEquals("[one, two/with space]", names.toString()); + assertEquals(2, fresh.size()); + KeyStore.Entry entry = fresh.getEntry("one", null); + assertTrue(entry instanceof KeyStore.SecretKeyEntry); + + Cipher d = Cipher.getInstance("AES/GCM/NoPadding"); + d.init(Cipher.DECRYPT_MODE, fresh.getKey("one", null), new GCMParameterSpec(128, iv)); + assertArrayEquals(DATA, d.doFinal(ct)); + + fresh.deleteEntry("one"); + assertFalse(loaded().containsAlias("one")); + assertNull(loaded().getKey("one", null)); + assertTrue(loaded().containsAlias("two/with space")); + } + + @Test + public void wrongIvOrTamperedTagFailsWithAeadBadTag() throws Exception { + generate("lic", KeyProperties.BLOCK_MODE_GCM, KeyProperties.ENCRYPTION_PADDING_NONE); + SecretKey k = (SecretKey) loaded().getKey("lic", null); + Cipher c = Cipher.getInstance("AES/GCM/NoPadding"); + c.init(Cipher.ENCRYPT_MODE, k); + byte[] iv = c.getIV(); + byte[] ct = c.doFinal(DATA); + + byte[] wrongIv = iv.clone(); + wrongIv[0] ^= 1; + Cipher d = Cipher.getInstance("AES/GCM/NoPadding"); + d.init(Cipher.DECRYPT_MODE, k, new GCMParameterSpec(128, wrongIv)); + try { + d.doFinal(ct); + fail("wrong IV decrypted"); + } catch (AEADBadTagException expected) { + // expected + } + + byte[] tampered = ct.clone(); + tampered[tampered.length - 1] ^= 1; + d.init(Cipher.DECRYPT_MODE, k, new GCMParameterSpec(128, iv)); + try { + d.doFinal(tampered); + fail("tampered tag decrypted"); + } catch (AEADBadTagException expected) { + // expected + } + } + + @Test + public void keystoreKeysEnforceModeAndRandomizedIv() throws Exception { + generate("gcm", KeyProperties.BLOCK_MODE_GCM, KeyProperties.ENCRYPTION_PADDING_NONE); + SecretKey k = (SecretKey) loaded().getKey("gcm", null); + try { + Cipher.getInstance("AES/CBC/PKCS7Padding").init(Cipher.ENCRYPT_MODE, k); + fail("CBC on a GCM-only key"); + } catch (java.security.InvalidKeyException expected) { + // expected + } + try { + Cipher.getInstance("AES/GCM/NoPadding").init(Cipher.ENCRYPT_MODE, k, + new GCMParameterSpec(128, randomIv(12))); + fail("caller IV accepted"); + } catch (java.security.InvalidAlgorithmParameterException expected) { + // expected + } + } + + @Test + public void plainSecretKeySpecWorksAndBadPaddingIsReported() throws Exception { + SecretKeySpec key = new SecretKeySpec(new byte[16], "AES"); + byte[] iv = randomIv(16); + Cipher c = Cipher.getInstance("AES/CBC/PKCS5Padding"); + c.init(Cipher.ENCRYPT_MODE, key, new IvParameterSpec(iv)); + byte[] ct = c.doFinal(DATA); + Cipher d = Cipher.getInstance("AES/CBC/PKCS5Padding"); + d.init(Cipher.DECRYPT_MODE, new SecretKeySpec(new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, + "AES"), new IvParameterSpec(iv)); + try { + byte[] out = d.doFinal(ct); + // A wrong key can, rarely, still unpad; it never yields the plaintext. + assertFalse(java.util.Arrays.equals(DATA, out)); + } catch (BadPaddingException expected) { + // expected + } + SecretKey generated = KeyGenerator.getInstance("AES").generateKey(); + assertEquals(16, generated.getEncoded().length); + assertEquals("RAW", generated.getFormat()); + } + + @Test + public void materialGoesToSecureStorageWhenThePortHasOne() throws Exception { + HeadlessImplementation.SECURE_STORAGE_ON.set(true); + generate("lic", KeyProperties.BLOCK_MODE_GCM, KeyProperties.ENCRYPTION_PADDING_NONE); + assertEquals(1, HeadlessImplementation.SECURE.size()); + String material = HeadlessImplementation.SECURE.values().iterator().next(); + assertTrue("an AES key, hex", material.length() == 32 || material.length() == 64); + byte[] raw = new byte[material.length() / 2]; + for (int i = 0; i < raw.length; i++) { + raw[i] = (byte) Integer.parseInt(material.substring(i * 2, i * 2 + 2), 16); + } + for (byte[] entry : HeadlessImplementation.STORAGE.values()) { + assertFalse("the key material must not be in plain storage", indexOf(entry, raw) >= 0); + } + + com.codename1.io.Storage.getInstance().clearCache(); + SecretKey k = (SecretKey) loaded().getKey("lic", null); + Cipher c = Cipher.getInstance("AES/GCM/NoPadding"); + c.init(Cipher.ENCRYPT_MODE, k); + byte[] iv = c.getIV(); + byte[] ct = c.doFinal(DATA); + Cipher d = Cipher.getInstance("AES/GCM/NoPadding"); + d.init(Cipher.DECRYPT_MODE, loaded().getKey("lic", null), new GCMParameterSpec(128, iv)); + assertArrayEquals(DATA, d.doFinal(ct)); + + // The platform dropping the secret reads as an invalidated key. + HeadlessImplementation.SECURE.clear(); + assertNull(loaded().getKey("lic", null)); + + generate("gone", KeyProperties.BLOCK_MODE_GCM, KeyProperties.ENCRYPTION_PADDING_NONE); + loaded().deleteEntry("gone"); + assertTrue("delete removes the secret too", HeadlessImplementation.SECURE.isEmpty()); + } + + @Test + public void withoutSecureStorageTheMaterialStaysInPrivateStorage() throws Exception { + generate("lic", KeyProperties.BLOCK_MODE_GCM, KeyProperties.ENCRYPTION_PADDING_NONE); + assertTrue(HeadlessImplementation.SECURE.isEmpty()); + com.codename1.io.Storage.getInstance().clearCache(); + assertTrue(loaded().getKey("lic", null) instanceof SecretKey); + } + + private static int indexOf(byte[] hay, byte[] needle) { + for (int i = 0; i + needle.length <= hay.length; i++) { + int j = 0; + while (j < needle.length && hay[i + j] == needle[j]) { + j++; + } + if (j == needle.length) { + return i; + } + } + return -1; + } + + /// A second encryption under the same GCM key and IV is refused until + /// the cipher is initialized again, which picks a fresh IV. + @Test + public void gcmCannotEncryptTwiceWithOneIv() throws Exception { + generate("lic", KeyProperties.BLOCK_MODE_GCM, KeyProperties.ENCRYPTION_PADDING_NONE); + SecretKey k = (SecretKey) loaded().getKey("lic", null); + Cipher c = Cipher.getInstance("AES/GCM/NoPadding"); + c.init(Cipher.ENCRYPT_MODE, k); + byte[] iv = c.getIV(); + c.doFinal(DATA); + try { + c.doFinal(DATA); + fail("a second GCM encryption reused the IV"); + } catch (IllegalStateException expected) { + // as on Android + } + c.init(Cipher.ENCRYPT_MODE, k); + assertFalse(java.util.Arrays.equals(iv, c.getIV())); + assertEquals(DATA.length + 16, c.doFinal(DATA).length); + } + + /// Initialising again with the last encryption's key and IV is refused, + /// the other route to GCM nonce reuse. + @Test + public void gcmRefusesTheLastKeyAndIvAgain() throws Exception { + SecretKeySpec key = new SecretKeySpec(new byte[16], "AES"); + byte[] iv = randomIv(12); + Cipher c = Cipher.getInstance("AES/GCM/NoPadding"); + c.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv)); + c.doFinal(DATA); + try { + c.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, iv)); + fail("the same key and IV were accepted for a second encryption"); + } catch (java.security.InvalidAlgorithmParameterException expected) { + // as Conscrypt + } + // Decrypting with them is fine. + c.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(128, iv)); + byte[] other = randomIv(12); + Cipher twice = Cipher.getInstance("AES/GCM/NoPadding"); + twice.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(128, other)); + try { + twice.init(Cipher.ENCRYPT_MODE, key, + new GCMParameterSpec(128, other)); + fail("two encrypt inits with one IV were accepted"); + } catch (java.security.InvalidAlgorithmParameterException expected) { + // recorded at init + } + } + + /// A fresh IV per test, as real code must use; a constant one is what + /// code scanning rightly flags. + private static byte[] randomIv(int length) { + byte[] iv = new byte[length]; + new java.security.SecureRandom().nextBytes(iv); + return iv; + } +} diff --git a/maven/android-compat/src/test/java/androidx/room/RoomRuntimeTest.java b/maven/android-compat/src/test/java/androidx/room/RoomRuntimeTest.java new file mode 100644 index 00000000000..030016ee421 --- /dev/null +++ b/maven/android-compat/src/test/java/androidx/room/RoomRuntimeTest.java @@ -0,0 +1,314 @@ +/* + * 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 androidx.room; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.JdbcContexts; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.Observer; +import androidx.room.migration.Migration; +import androidx.sqlite.db.SupportSQLiteDatabase; +import com.codename1.androidcompat.runtime.RoomSupport; +import com.codename1.compat.testing.MainThreadRule; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.Callable; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +/// The Room runtime with a hand-written stand-in for what the build +/// generates: opening, migrations, the identity check, transactions and +/// live queries. +public class RoomRuntimeTest { + + @Rule + public final MainThreadRule mainThread = new MainThreadRule(); + + private Context ctx; + + @Before + public void installLog() { + com.codename1.io.Log.install(new com.codename1.io.Log() { + @Override + protected void print(String text, int level) { + } + }); + ctx = JdbcContexts.create(); + } + + /// What `@Database(version = n)` over one `note` table would generate. + static class NotesDb extends RoomDatabase { + final int version; + final String extraColumn; + + NotesDb(int version, String extraColumn) { + this.version = version; + this.extraColumn = extraColumn; + } + + @Override + protected int roomSchemaVersion() { + return version; + } + + @Override + protected String[] roomCreateStatements() { + return new String[] {"CREATE TABLE IF NOT EXISTS `note` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL," + + " `title` TEXT" + (extraColumn == null ? "" : ", `" + extraColumn + "` INTEGER") + ")"}; + } + + @Override + protected String[] roomTableNames() { + return new String[] {"note"}; + } + + @Override + protected String[][] roomTableColumns() { + return extraColumn == null ? new String[][] {{"note", "id", "title"}} + : new String[][] {{"note", "id", "title", extraColumn}}; + } + + long insert(String title) { + long id = RoomSupport.insert(this, "INSERT INTO `note` (`title`) VALUES (?)", new Object[] {title}); + getInvalidationTracker().notifyTablesChanged("note"); + return id; + } + + List titles() { + Cursor c = RoomSupport.query(this, "SELECT title FROM note ORDER BY id", new ArrayList()); + try { + List out = new ArrayList(); + while (c.moveToNext()) { + out.add(c.getString(0)); + } + return out; + } finally { + c.close(); + } + } + } + + private NotesDb open(int version, String extra, RoomDatabase.Builder b) { + return b.allowMainThreadQueries().buildWith(new NotesDb(version, extra)); + } + + private RoomDatabase.Builder builder() { + return Room.databaseBuilder(ctx, NotesDb.class, "notes.db"); + } + + @Test + public void createsTablesOnFirstOpenAndReadsBack() { + NotesDb db = open(1, null, builder()); + assertEquals(1L, db.insert("a")); + db.insert("b"); + assertEquals(Arrays.asList("a", "b"), db.titles()); + db.close(); + NotesDb again = open(1, null, builder()); + assertEquals(Arrays.asList("a", "b"), again.titles()); + } + + @Test + public void buildWithoutGeneratedImplementationFails() { + try { + builder().build(); + fail(); + } catch (RuntimeException expected) { + assertTrue(expected.getMessage(), expected.getMessage().contains("NotesDb_Impl does not exist")); + } + } + + @Test + public void runsMigrationsOnVersionChange() { + open(1, null, builder()).insert("kept"); + final List ran = new ArrayList(); + Migration m12 = new Migration(1, 2) { + @Override + public void migrate(SupportSQLiteDatabase database) { + ran.add("1-2"); + database.execSQL("ALTER TABLE note ADD COLUMN `stars` INTEGER"); + } + }; + NotesDb v2 = open(2, "stars", builder().addMigrations(m12)); + // Opening is lazy, as in Room: the first access migrates. + assertTrue(ran.isEmpty()); + assertEquals(Arrays.asList("kept"), v2.titles()); + assertEquals(Arrays.asList("1-2"), ran); + assertEquals(2, v2.getOpenHelper().getWritableDatabase().getVersion()); + } + + /// A migration that forgets a column the entity now has is refused, and + /// the database is not stamped with the new version. + @Test + public void migrationThatMissesAColumnIsRefused() { + open(1, null, builder()).insert("kept"); + Migration forgetful = new Migration(1, 2) { + @Override + public void migrate(SupportSQLiteDatabase database) { + } + }; + try { + open(2, "stars", builder().addMigrations(forgetful)).titles(); + fail("a migration that left out `stars` was accepted"); + } catch (IllegalStateException expected) { + assertTrue(expected.getMessage(), expected.getMessage().startsWith("Migration didn't properly handle: note")); + assertTrue(expected.getMessage(), expected.getMessage().contains("stars")); + } + assertEquals(Arrays.asList("kept"), open(1, null, builder()).titles()); + } + + @Test + public void missingMigrationThrowsUnlessDestructive() { + open(1, null, builder()).insert("x"); + try { + open(2, "stars", builder()).titles(); + fail(); + } catch (IllegalStateException expected) { + assertTrue(expected.getMessage(), expected.getMessage().startsWith("A migration from 1 to 2 was required")); + } + NotesDb wiped = open(2, "stars", builder().fallbackToDestructiveMigration()); + assertEquals(new ArrayList(), wiped.titles()); + } + + @Test + public void schemaChangeWithoutVersionBumpIsRefused() { + open(1, null, builder()).insert("x"); + try { + open(1, "stars", builder()).titles(); + fail(); + } catch (IllegalStateException expected) { + assertTrue(expected.getMessage(), expected.getMessage().startsWith("Room cannot verify the data integrity")); + } + } + + @Test + public void migrationPathChoosesTheLongestStep() { + RoomDatabase.MigrationContainer c = new RoomDatabase.MigrationContainer(); + Migration m12 = noop(1, 2); + Migration m23 = noop(2, 3); + Migration m13 = noop(1, 3); + Migration m34 = noop(3, 4); + c.addMigrations(m12, m23, m13, m34); + assertEquals(Arrays.asList(m13, m34), c.findMigrationPath(1, 4)); + assertEquals(Arrays.asList(m23), c.findMigrationPath(2, 3)); + assertNull(c.findMigrationPath(4, 1)); + } + + private static Migration noop(int a, int b) { + return new Migration(a, b) { + @Override + public void migrate(SupportSQLiteDatabase database) { + } + }; + } + + @Test + public void transactionRollsBackAndDefersInvalidation() { + final NotesDb db = open(1, null, builder()); + final List seen = new ArrayList(); + db.getInvalidationTracker().addObserver(new InvalidationTracker.Observer("NOTE") { + @Override + public void onInvalidated(java.util.Set tables) { + seen.add(tables.toString()); + } + }); + try { + db.runInTransaction(new Runnable() { + @Override + public void run() { + db.insert("gone"); + assertTrue(seen.isEmpty()); + throw new IllegalArgumentException("boom"); + } + }); + fail(); + } catch (IllegalArgumentException expected) { + assertEquals("boom", expected.getMessage()); + } + assertEquals(new ArrayList(), db.titles()); + assertEquals(Arrays.asList("[note]"), seen); + Integer n = db.runInTransaction(new Callable() { + @Override + public Integer call() { + db.insert("kept"); + return Integer.valueOf(db.titles().size()); + } + }); + assertEquals(1, n.intValue()); + assertEquals(2, seen.size()); + } + + @Test + public void liveQueryRefreshesOnWrite() { + // Queries run on the query executor and post their value, as in + // AndroidX; a direct executor and a drain make each step observable. + final NotesDb db = open(1, null, builder().setQueryExecutor(new java.util.concurrent.Executor() { + @Override + public void execute(Runnable command) { + command.run(); + } + })); + LiveData> live = db.getInvalidationTracker().createLiveData(new String[] {"note"}, + false, new Callable>() { + @Override + public List call() { + return db.titles(); + } + }); + final List> values = new ArrayList>(); + Observer> obs = new Observer>() { + @Override + public void onChanged(List v) { + values.add(v); + } + }; + live.observeForever(obs); + MainThreadRule.drain(); + db.insert("one"); + MainThreadRule.drain(); + db.insert("two"); + MainThreadRule.drain(); + live.removeObserver(obs); + db.insert("three"); + MainThreadRule.drain(); + assertEquals(3, values.size()); + assertEquals(Arrays.asList("one", "two"), values.get(2)); + } + + @Test + public void clearAllTablesEmptiesEveryEntity() { + NotesDb db = open(1, null, builder()); + db.insert("a"); + db.clearAllTables(); + assertEquals(new ArrayList(), db.titles()); + } +} diff --git a/maven/android-compat/src/test/java/androidx/work/WorkManagerTest.java b/maven/android-compat/src/test/java/androidx/work/WorkManagerTest.java new file mode 100644 index 00000000000..9aca3a8ab14 --- /dev/null +++ b/maven/android-compat/src/test/java/androidx/work/WorkManagerTest.java @@ -0,0 +1,296 @@ +/* + * 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 androidx.work; + +import android.content.Context; +import com.codename1.androidcompat.testing.AndroidTestSupport; +import com.codename1.compat.testing.MainThreadRule; +import com.codename1.io.Storage; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +public class WorkManagerTest { + + @Rule + public final MainThreadRule mainThread = new MainThreadRule(); + + /// What each worker saw, in the order they ran. + static final List RAN = Collections.synchronizedList(new ArrayList()); + + public static final class Echo extends Worker { + public Echo(Context c, WorkerParameters p) { + super(c, p); + } + + @Override + public Result doWork() { + String in = getInputData().getString("in"); + RAN.add("echo:" + in); + if ("fail".equals(in)) { + return Result.failure(); + } + if ("retry".equals(in)) { + return Result.retry(); + } + return Result.success(new Data.Builder().putString("in", in + "+").putInt("n", 7).build()); + } + } + + private Context context; + + @Before + public void setUp() { + context = AndroidTestSupport.context(); + WorkManager.resetForTesting(); + Storage.getInstance().deleteStorageFile(WorkStore.FILE); + Storage.getInstance().deleteStorageFile(WorkStore.TEMP); + RAN.clear(); + } + + @After + public void tearDown() { + WorkManager.resetForTesting(); + Storage.getInstance().deleteStorageFile(WorkStore.FILE); + Storage.getInstance().deleteStorageFile(WorkStore.TEMP); + } + + private WorkManager manager() { + WorkerFactory f = new WorkerFactory() { + @Override + public ListenableWorker createWorker(Context c, String name, WorkerParameters p) { + return Echo.class.getName().equals(name) ? new Echo(c, p) : null; + } + }; + Executor inline = new Executor() { + @Override + public void execute(Runnable r) { + r.run(); + } + }; + WorkManager.initialize(context, new Configuration.Builder().setWorkerFactory(f).setExecutor(inline).build()); + return WorkManager.getInstance(context); + } + + private static OneTimeWorkRequest echo(String in) { + return new OneTimeWorkRequest.Builder(Echo.class).setInputData(new Data.Builder().putString("in", in).build()) + .addTag("t").build(); + } + + private static WorkInfo.State state(WorkManager wm, OneTimeWorkRequest r) throws Exception { + MainThreadRule.drain(); + MainThreadRule.drain(); + return wm.getWorkInfoById(r.getId()).get().getState(); + } + + @Test + public void oneTimeWorkRunsAndReportsItsOutput() throws Exception { + WorkManager wm = manager(); + OneTimeWorkRequest r = echo("a"); + wm.enqueue(r); + assertEquals(WorkInfo.State.SUCCEEDED, state(wm, r)); + WorkInfo info = wm.getWorkInfoById(r.getId()).get(); + assertEquals("a+", info.getOutputData().getString("in")); + assertEquals(7, info.getOutputData().getInt("n", 0)); + assertTrue(info.getTags().contains(Echo.class.getName())); + assertEquals(1, wm.getWorkInfosByTag("t").get().size()); + assertEquals(WorkInfo.State.SUCCEEDED, wm.getWorkInfoByIdLiveData(r.getId()).getValue().getState()); + } + + @Test + public void aChainMergesOutputsAndAFailureCascades() throws Exception { + WorkManager wm = manager(); + OneTimeWorkRequest a = echo("a"); + OneTimeWorkRequest b = new OneTimeWorkRequest.Builder(Echo.class).build(); + wm.beginWith(a).then(b).enqueue(); + assertEquals(WorkInfo.State.SUCCEEDED, state(wm, b)); + // b had no input of its own: it ran with a's output. + assertEquals("a+", RAN.get(1).substring("echo:".length())); + + OneTimeWorkRequest f = echo("fail"); + OneTimeWorkRequest after = echo("never"); + wm.beginWith(f).then(after).enqueue(); + assertEquals(WorkInfo.State.FAILED, state(wm, f)); + assertEquals(WorkInfo.State.FAILED, state(wm, after)); + assertTrue(!RAN.contains("echo:never")); + } + + @Test + public void retryBacksOffAndCountsTheAttempt() throws Exception { + WorkManager wm = manager(); + OneTimeWorkRequest r = new OneTimeWorkRequest.Builder(Echo.class) + .setInputData(new Data.Builder().putString("in", "retry").build()) + .setBackoffCriteria(BackoffPolicy.LINEAR, 1, TimeUnit.HOURS).build(); + long before = System.currentTimeMillis(); + wm.enqueue(r); + assertEquals(WorkInfo.State.ENQUEUED, state(wm, r)); + WorkInfo info = wm.getWorkInfoById(r.getId()).get(); + assertEquals(1, info.getRunAttemptCount()); + assertTrue(info.getNextScheduleTimeMillis() >= before + TimeUnit.HOURS.toMillis(1)); + wm.cancelWorkById(r.getId()); + assertEquals(WorkInfo.State.CANCELLED, state(wm, r)); + } + + @Test + public void uniqueWorkPolicies() throws Exception { + WorkManager wm = manager(); + OneTimeWorkRequest delayed = new OneTimeWorkRequest.Builder(Echo.class) + .setInitialDelay(1, TimeUnit.HOURS).build(); + wm.enqueueUniqueWork("u", ExistingWorkPolicy.KEEP, delayed); + OneTimeWorkRequest kept = echo("kept"); + wm.enqueueUniqueWork("u", ExistingWorkPolicy.KEEP, kept); + MainThreadRule.drain(); + assertEquals(null, wm.getWorkInfoById(kept.getId()).get()); + + OneTimeWorkRequest appended = echo("appended"); + wm.enqueueUniqueWork("u", ExistingWorkPolicy.APPEND, appended); + assertEquals(WorkInfo.State.BLOCKED, state(wm, appended)); + + OneTimeWorkRequest replacement = echo("replacement"); + wm.enqueueUniqueWork("u", ExistingWorkPolicy.REPLACE, replacement); + assertEquals(WorkInfo.State.SUCCEEDED, state(wm, replacement)); + assertEquals(WorkInfo.State.CANCELLED, state(wm, delayed)); + assertEquals(WorkInfo.State.CANCELLED, state(wm, appended)); + assertEquals(1, wm.getWorkInfosForUniqueWork("u").get().size()); + } + + @Test + public void unfinishedWorkSurvivesARestart() throws Exception { + WorkManager wm = manager(); + OneTimeWorkRequest later = new OneTimeWorkRequest.Builder(Echo.class).addTag("persist") + .setInputData(new Data.Builder().putString("in", "x").putIntArray("arr", new int[] {1, 2}).build()) + .setInitialDelay(1, TimeUnit.HOURS).build(); + wm.enqueue(later); + assertEquals(WorkInfo.State.ENQUEUED, state(wm, later)); + + WorkManager.resetForTesting(); + WorkManager again = manager(); + List infos = again.getWorkInfosByTag("persist").get(); + assertEquals(1, infos.size()); + assertEquals(later.getId(), infos.get(0).getId()); + assertEquals(WorkInfo.State.ENQUEUED, infos.get(0).getState()); + assertTrue(RAN.isEmpty()); + } + + /// A save cut short while the main entry was being replaced leaves it + /// truncated; the queue is read from the second entry, which was + /// written whole first, rather than lost. + @Test + public void aTruncatedSaveFallsBackToTheCompleteCopy() throws Exception { + WorkManager wm = manager(); + OneTimeWorkRequest later = new OneTimeWorkRequest.Builder(Echo.class).addTag("persist") + .setInitialDelay(1, TimeUnit.HOURS).build(); + wm.enqueue(later); + assertEquals(WorkInfo.State.ENQUEUED, state(wm, later)); + WorkManager.resetForTesting(); + + Storage st = Storage.getInstance(); + byte[] whole; + java.io.InputStream in = st.createInputStream(WorkStore.FILE); + java.io.ByteArrayOutputStream bo = new java.io.ByteArrayOutputStream(); + byte[] buf = new byte[512]; + int n; + while ((n = in.read(buf)) > 0) { + bo.write(buf, 0, n); + } + in.close(); + whole = bo.toByteArray(); + java.io.OutputStream o = st.createOutputStream(WorkStore.TEMP); + o.write(whole); + o.close(); + o = st.createOutputStream(WorkStore.FILE); + o.write(whole, 0, whole.length / 2); + o.close(); + st.clearCache(); + + List infos = manager().getWorkInfosByTag("persist").get(); + assertEquals(1, infos.size()); + assertEquals(later.getId(), infos.get(0).getId()); + } + + /// Initialized off the main thread, the saved queue is merged before the + /// first enqueue made on the main thread, which used to save over it. + @Test + public void anEnqueueBeforeTheLoadDoesNotOverwriteTheSavedQueue() throws Exception { + WorkManager wm = manager(); + OneTimeWorkRequest first = new OneTimeWorkRequest.Builder(Echo.class).addTag("persist") + .setInitialDelay(1, TimeUnit.HOURS).build(); + wm.enqueue(first); + assertEquals(WorkInfo.State.ENQUEUED, state(wm, first)); + WorkManager.resetForTesting(); + + final WorkManager[] off = new WorkManager[1]; + Thread t = new Thread(new Runnable() { + @Override + public void run() { + off[0] = manager(); + } + }); + t.start(); + t.join(); + OneTimeWorkRequest second = new OneTimeWorkRequest.Builder(Echo.class).addTag("persist") + .setInitialDelay(1, TimeUnit.HOURS).build(); + off[0].enqueue(second); + List infos = off[0].getWorkInfosByTag("persist").get(); + assertEquals(2, infos.size()); + } + + @Test + public void aShortDelayRunsWithoutAnotherTrigger() throws Exception { + WorkManager wm = manager(); + OneTimeWorkRequest r = new OneTimeWorkRequest.Builder(Echo.class) + .setInitialDelay(30, TimeUnit.MILLISECONDS).build(); + wm.enqueue(r); + long until = System.currentTimeMillis() + 5000; + while (state(wm, r) != WorkInfo.State.SUCCEEDED && System.currentTimeMillis() < until) { + Thread.sleep(10); + } + assertEquals(WorkInfo.State.SUCCEEDED, state(wm, r)); + } + + @Test + public void dataRoundTripsAndEnforcesItsLimit() throws Exception { + Data d = new Data.Builder().putBoolean("b", true).putLong("l", 5L).putDouble("d", 1.5) + .putStringArray("s", new String[] {"x", null}).putByteArray("y", new byte[] {1, 2}).build(); + java.io.ByteArrayOutputStream bo = new java.io.ByteArrayOutputStream(); + WorkStore.writeData(new java.io.DataOutputStream(bo), d); + Data back = WorkStore.readData(new java.io.DataInputStream(new java.io.ByteArrayInputStream(bo.toByteArray()))); + assertEquals(d, back); + try { + new Data.Builder().putByteArray("big", new byte[Data.MAX_DATA_BYTES + 1]).build(); + throw new AssertionError("oversized data accepted"); + } catch (IllegalStateException expected) { + // as on Android + } + } +} diff --git a/maven/android-compat/src/test/java/com/codename1/androidcompat/testing/AndroidTestSupport.java b/maven/android-compat/src/test/java/com/codename1/androidcompat/testing/AndroidTestSupport.java index 1461e0484bc..44752d561f4 100644 --- a/maven/android-compat/src/test/java/com/codename1/androidcompat/testing/AndroidTestSupport.java +++ b/maven/android-compat/src/test/java/com/codename1/androidcompat/testing/AndroidTestSupport.java @@ -320,6 +320,47 @@ private boolean stopScenario() { @Override protected void onDestroy() { super.onDestroy(); event("destroy"); } } + /// A service declared by the test application, recording its lifecycle. + public static final class TestService extends android.app.Service { + public static final java.util.List calls = new java.util.ArrayList(); + public static TestService last; + + public final class LocalBinder extends android.os.Binder { + public TestService service() { + return TestService.this; + } + } + + @Override + public void onCreate() { + last = this; + calls.add("create"); + } + + @Override + public int onStartCommand(android.content.Intent intent, int flags, int startId) { + calls.add("start:" + intent.getIntExtra("n", 0) + ":" + startId); + return START_NOT_STICKY; + } + + @Override + public android.os.IBinder onBind(android.content.Intent intent) { + calls.add("bind"); + return new LocalBinder(); + } + + @Override + public boolean onUnbind(android.content.Intent intent) { + calls.add("unbind"); + return false; + } + + @Override + public void onDestroy() { + calls.add("destroy"); + } + } + private static final class TestApp extends AndroidApp { TestApp() { super(null, "com.codename1.androidcompat.test"); @@ -350,6 +391,12 @@ private static final class TestApp extends AndroidApp { parentActivity(CompatChildActivity.class, TestActivity.class.getName()); activity(GalleryActivity.class, GalleryActivity.class.getName(), android.R.style.Theme_Material_Light_DarkActionBar, 0, "Gallery", null, null, false); + service(TestService.class, TestService.class.getName()); + } + + @Override + public android.app.Service createService(Class type) { + return type == TestService.class ? new TestService() : null; } @Override diff --git a/maven/android-res-compiler/src/main/java/com/codename1/android/rescompiler/JavaWriter.java b/maven/android-res-compiler/src/main/java/com/codename1/android/rescompiler/JavaWriter.java index 5e624fe4def..abb37591697 100644 --- a/maven/android-res-compiler/src/main/java/com/codename1/android/rescompiler/JavaWriter.java +++ b/maven/android-res-compiler/src/main/java/com/codename1/android/rescompiler/JavaWriter.java @@ -123,8 +123,8 @@ File writeAppImpl(File outDir, String pkg, String cls, String table, ManifestInf sb.append("// Generated by the Codename One Android resource compiler. Do not edit.\n"); sb.append("package ").append(pkg).append(";\n\n"); sb.append("/// The application-specific half of the Android compatibility runtime:\n"); - sb.append("/// `new` for every view class the layouts name and every activity the\n"); - sb.append("/// manifest declares, so nothing is instantiated by reflection.\n"); + sb.append("/// `new` for every view class the layouts name and every activity and\n"); + sb.append("/// service the manifest declares, so nothing is instantiated by reflection.\n"); sb.append("public final class ").append(cls).append(" extends ").append(RUNTIME).append(".AndroidApp {\n\n"); List tags = new ArrayList(tagToClass.keySet()); sb.append(" private static final String[] VIEW_TAGS = {"); @@ -167,6 +167,9 @@ File writeAppImpl(File outDir, String pkg, String cls, String table, ManifestInf writeIntentFilter(sb, a.className, f); } } + for (String svc : m.services) { + sb.append(" service(").append(svc).append(".class, ").append(quote(svc)).append(");\n"); + } } sb.append(" }\n\n"); @@ -190,6 +193,15 @@ File writeAppImpl(File outDir, String pkg, String cls, String table, ManifestInf } sb.append(" return null;\n }\n\n"); + sb.append(" @Override\n public android.app.Service createService(Class type) {\n"); + if (m != null) { + for (String svc : m.services) { + sb.append(" if (type == ").append(svc).append(".class) {\n"); + sb.append(" return new ").append(svc).append("();\n }\n"); + } + } + sb.append(" return null;\n }\n\n"); + sb.append(" @Override\n public android.app.Application createApplication() {\n"); String appCls = m == null || m.applicationClass == null ? "android.app.Application" : m.applicationClass; sb.append(" return new ").append(appCls).append("();\n }\n}\n"); diff --git a/maven/android-res-compiler/src/main/java/com/codename1/android/rescompiler/ManifestInfo.java b/maven/android-res-compiler/src/main/java/com/codename1/android/rescompiler/ManifestInfo.java index 611c5f11ed5..4cbcf7dd553 100644 --- a/maven/android-res-compiler/src/main/java/com/codename1/android/rescompiler/ManifestInfo.java +++ b/maven/android-res-compiler/src/main/java/com/codename1/android/rescompiler/ManifestInfo.java @@ -105,6 +105,8 @@ public static final class IntentFilter { public Value appLabel; public Value appIcon; public final List activities = new ArrayList(); + /// The fully qualified classes of the enabled `` elements. + public final List services = new ArrayList(); public final List permissions = new ArrayList(); /// The `ActivityInfo.CONFIG_*` bit of each `android:configChanges` flag diff --git a/maven/android-res-compiler/src/main/java/com/codename1/android/rescompiler/ResourceCompiler.java b/maven/android-res-compiler/src/main/java/com/codename1/android/rescompiler/ResourceCompiler.java index d132ca8e8f2..84e74c60738 100644 --- a/maven/android-res-compiler/src/main/java/com/codename1/android/rescompiler/ResourceCompiler.java +++ b/maven/android-res-compiler/src/main/java/com/codename1/android/rescompiler/ResourceCompiler.java @@ -855,6 +855,16 @@ private ManifestInfo parseManifest(File f) throws IOException { continue; } m.activities.add(parseActivity(a, m.packageName, file)); + } else if (a.tag.equals("service") + && !"false".equals(a.attr(RawNode.NS_KEY_ANDROID, "enabled"))) { + // A service is only ever started by its class here: the + // generated factory news it, and its intent filters + // (other applications binding to it) have no meaning in + // a single-application runtime. + String name = a.attr(RawNode.NS_KEY_ANDROID, "name"); + if (name != null) { + m.services.add(ManifestInfo.resolveClass(m.packageName, name)); + } } } } diff --git a/maven/build-engine/pom.xml b/maven/build-engine/pom.xml index 233bb8ccf0c..77025a84f47 100644 --- a/maven/build-engine/pom.xml +++ b/maven/build-engine/pom.xml @@ -126,6 +126,20 @@ codenameone-android-res-compiler ${project.version} + + + ${project.groupId} + codenameone-android-compat + ${project.version} + test + + + * + * + + + diff --git a/maven/build-engine/src/main/java/com/codename1/maven/AndroidRemapper.java b/maven/build-engine/src/main/java/com/codename1/maven/AndroidRemapper.java index 149fea81f78..b82d0a85d45 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/AndroidRemapper.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/AndroidRemapper.java @@ -79,6 +79,8 @@ public final class AndroidRemapper { /// placeholder, the view model base it looks for, and the constructor /// argument types it passes. public static final String VIEW_MODEL_FACTORY = RUNTIME_TARGET + "ViewModelFactory"; + /// The Room database factory the runtime ships as a placeholder. + public static final String ROOM_FACTORY = RUNTIME_TARGET + "RoomFactory"; static final String VIEW_MODEL = TARGET + "androidx/lifecycle/ViewModel"; static final String APPLICATION = TARGET + "android/app/Application"; static final String SAVED_STATE_HANDLE = TARGET + "androidx/lifecycle/SavedStateHandle"; @@ -91,6 +93,14 @@ public final class AndroidRemapper { "(L" + APPLICATION + ";L" + SAVED_STATE_HANDLE + ";)V", "(L" + SAVED_STATE_HANDLE + ";)V", "(L" + APPLICATION + ";)V", "()V"}; + /// The generated replacement for the runtime's `WorkerInstantiator` + /// placeholder, the worker bases it looks for, and the one constructor + /// WorkManager's default factory calls. + public static final String WORKER_INSTANTIATOR = RUNTIME_TARGET + "WorkerInstantiator"; + static final String[] WORKER_BASES = {TARGET + "androidx/work/ListenableWorker", TARGET + "androidx/work/Worker"}; + static final String WORKER_CTOR = "(L" + TARGET + "android/content/Context;L" + TARGET + + "androidx/work/WorkerParameters;)V"; + /// JDK classes Android code uses that the Codename One runtime lacks, /// redirected to the compatibility runtime's implementations. static final String[][] ANDROID_JDK_SHIMS = { @@ -108,6 +118,35 @@ public final class AndroidRemapper { {"java/io/FilterInputStream", "com/codename1/androidcompat/jdk/FilterInputStream"}, {"java/io/FilterOutputStream", "com/codename1/androidcompat/jdk/FilterOutputStream"}, {"java/io/PrintWriter", "com/codename1/androidcompat/jdk/PrintWriter"}, + // The JCA surface Android Keystore code uses. GeneralSecurityException, + // NoSuchAlgorithmException and SecureRandom are in the Codename One + // class library itself and are deliberately absent here. + {"java/security/Key", "com/codename1/androidcompat/jdk/Key"}, + {"java/security/KeyStore", "com/codename1/androidcompat/jdk/KeyStore"}, + {"java/security/KeyStore$LoadStoreParameter", "com/codename1/androidcompat/jdk/KeyStore$LoadStoreParameter"}, + {"java/security/KeyStore$ProtectionParameter", "com/codename1/androidcompat/jdk/KeyStore$ProtectionParameter"}, + {"java/security/KeyStore$Entry", "com/codename1/androidcompat/jdk/KeyStore$Entry"}, + {"java/security/KeyStore$SecretKeyEntry", "com/codename1/androidcompat/jdk/KeyStore$SecretKeyEntry"}, + {"java/security/KeyException", "com/codename1/androidcompat/jdk/KeyException"}, + {"java/security/KeyStoreException", "com/codename1/androidcompat/jdk/KeyStoreException"}, + {"java/security/InvalidKeyException", "com/codename1/androidcompat/jdk/InvalidKeyException"}, + {"java/security/InvalidAlgorithmParameterException", "com/codename1/androidcompat/jdk/InvalidAlgorithmParameterException"}, + {"java/security/NoSuchProviderException", "com/codename1/androidcompat/jdk/NoSuchProviderException"}, + {"java/security/UnrecoverableEntryException", "com/codename1/androidcompat/jdk/UnrecoverableEntryException"}, + {"java/security/UnrecoverableKeyException", "com/codename1/androidcompat/jdk/UnrecoverableKeyException"}, + {"java/security/cert/CertificateException", "com/codename1/androidcompat/jdk/CertificateException"}, + {"java/security/spec/AlgorithmParameterSpec", "com/codename1/androidcompat/jdk/AlgorithmParameterSpec"}, + {"java/security/spec/KeySpec", "com/codename1/androidcompat/jdk/KeySpec"}, + {"javax/crypto/Cipher", "com/codename1/androidcompat/jdk/Cipher"}, + {"javax/crypto/KeyGenerator", "com/codename1/androidcompat/jdk/KeyGenerator"}, + {"javax/crypto/SecretKey", "com/codename1/androidcompat/jdk/SecretKey"}, + {"javax/crypto/NoSuchPaddingException", "com/codename1/androidcompat/jdk/NoSuchPaddingException"}, + {"javax/crypto/BadPaddingException", "com/codename1/androidcompat/jdk/BadPaddingException"}, + {"javax/crypto/AEADBadTagException", "com/codename1/androidcompat/jdk/AEADBadTagException"}, + {"javax/crypto/IllegalBlockSizeException", "com/codename1/androidcompat/jdk/IllegalBlockSizeException"}, + {"javax/crypto/spec/GCMParameterSpec", "com/codename1/androidcompat/jdk/GCMParameterSpec"}, + {"javax/crypto/spec/IvParameterSpec", "com/codename1/androidcompat/jdk/IvParameterSpec"}, + {"javax/crypto/spec/SecretKeySpec", "com/codename1/androidcompat/jdk/SecretKeySpec"}, // Every Codename One stream, reader and writer is AutoCloseable, and // close() is the interface's only method, so code holding a Closeable // runs unchanged against it. @@ -187,6 +226,11 @@ List applicationClasses() { public void run() throws BuildException { try { + if (shipRuntime) { + // Room's implementations are generated as source naming the + // real androidx types, so before anything is relocated. + new RoomGenerator(classesDir, handlerDirs, compatJar, log).generate(); + } List appClasses = new ArrayList(); applicationClasses = appClasses; relocator.remapDirectory(classesDir, appClasses, log); @@ -198,9 +242,17 @@ public void run() throws BuildException { int handlers = writeOnClickDispatch(appClasses); int fragments = writeFragmentFactory(); int viewModels = writeViewModelFactory(); + int databases = writeRoomFactory(); + if (databases > 0) { + log.info(databases + " Room database(s) buildable by Room.databaseBuilder"); + } if (viewModels > 0) { log.info(viewModels + " view model class(es) creatable by ViewModelProvider"); } + int workers = writeWorkerInstantiator(); + if (workers > 0) { + log.info(workers + " worker class(es) creatable by WorkManager"); + } int jsMethods = writeJsInterfaceDispatch(appClasses); if (jsMethods > 0) { log.info(jsMethods + " @JavascriptInterface method(s) callable from web views"); @@ -574,6 +626,178 @@ public MethodVisitor visitMethod(int acc, String name, String desc, String sig, return models.size(); } + /// Replaces the runtime's `RoomFactory` with `create(String)` answering + /// `new Name_Impl()` for every `@Database` class -- what Room's + /// `Room.getGeneratedImplementation` finds by `Class.forName` on Android. + private int writeRoomFactory() throws IOException { + List all = new ArrayList(); + ClassRelocator.collectClassFiles(classesDir, all); + for (File dir : handlerDirs) { + ClassRelocator.collectClassFiles(dir, all); + } + final List dbs = new ArrayList(); + for (File f : all) { + ClassReader cr = new ClassReader(Files.readAllBytes(f.toPath())); + final String cls = cr.getClassName(); + if (cls.startsWith(TARGET)) { + continue; + } + final boolean[] found = new boolean[1]; + cr.accept(new ClassVisitor(Opcodes.ASM9) { + @Override + public org.objectweb.asm.AnnotationVisitor visitAnnotation(String desc, boolean visible) { + if (desc.equals(RoomGenerator.DATABASE) || desc.equals(RoomGenerator.DATABASE_RELOCATED)) { + found[0] = true; + } + return null; + } + }, ClassReader.SKIP_CODE); + if (found[0]) { + String impl = RoomGenerator.implName(cls); + if (!new File(classesDir, impl + ".class").isFile()) { + throw new IOException("Room: " + cls.replace('/', '.') + " has no generated " + impl); + } + dbs.add(cls); + } + } + java.util.Collections.sort(dbs); + ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS); + cw.visit(Opcodes.V1_5, Opcodes.ACC_PUBLIC | Opcodes.ACC_FINAL | Opcodes.ACC_SUPER, ROOM_FACTORY, null, + "java/lang/Object", null); + MethodVisitor ctor = cw.visitMethod(Opcodes.ACC_PRIVATE, "", "()V", null, null); + ctor.visitCode(); + ctor.visitVarInsn(Opcodes.ALOAD, 0); + ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false); + ctor.visitInsn(Opcodes.RETURN); + ctor.visitMaxs(0, 0); + ctor.visitEnd(); + MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "create", + "(Ljava/lang/String;)Ljava/lang/Object;", null, null); + mv.visitCode(); + for (String cls : dbs) { + Label next = new Label(); + String impl = RoomGenerator.implName(cls); + mv.visitLdcInsn(cls.replace('/', '.')); + mv.visitVarInsn(Opcodes.ALOAD, 0); + mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/String", "equals", "(Ljava/lang/Object;)Z", false); + mv.visitJumpInsn(Opcodes.IFEQ, next); + mv.visitTypeInsn(Opcodes.NEW, impl); + mv.visitInsn(Opcodes.DUP); + mv.visitMethodInsn(Opcodes.INVOKESPECIAL, impl, "", "()V", false); + mv.visitInsn(Opcodes.ARETURN); + mv.visitLabel(next); + } + mv.visitInsn(Opcodes.ACONST_NULL); + mv.visitInsn(Opcodes.ARETURN); + mv.visitMaxs(0, 0); + mv.visitEnd(); + cw.visitEnd(); + File out = new File(classesDir, ROOM_FACTORY + ".class"); + out.getParentFile().mkdirs(); + ClassRelocator.write(out, cw.toByteArray()); + return dbs.size(); + } + + /// Writes `WorkerInstantiator.create(String, Object, Object)`: for every + /// public, concrete application class extending a worker base with a + /// public `(Context, WorkerParameters)` constructor, a name comparison and + /// `new` -- what WorkManager's default `WorkerFactory` does by reflection. + /// A worker with any other constructor needs a `WorkerFactory`, on + /// Android too. + private int writeWorkerInstantiator() throws IOException { + final Map supers = new HashMap(); + List all = new ArrayList(); + ClassRelocator.collectClassFiles(classesDir, all); + for (File dir : handlerDirs) { + ClassRelocator.collectClassFiles(dir, all); + } + List candidates = new ArrayList(); + for (File f : all) { + ClassReader cr = new ClassReader(Files.readAllBytes(f.toPath())); + final String cls = relocator.map(cr.getClassName()); + if (cr.getSuperName() != null) { + supers.put(cls, relocator.map(cr.getSuperName())); + } + int access = cr.getAccess(); + if ((access & (Opcodes.ACC_INTERFACE | Opcodes.ACC_ABSTRACT)) != 0 || (access & Opcodes.ACC_PUBLIC) == 0 + || cls.startsWith(TARGET)) { + continue; + } + final boolean[] found = new boolean[1]; + cr.accept(new ClassVisitor(Opcodes.ASM9) { + @Override + public MethodVisitor visitMethod(int acc, String name, String desc, String sig, String[] ex) { + if (name.equals("") && (acc & Opcodes.ACC_PUBLIC) != 0 + && WORKER_CTOR.equals(relocator.remapper().mapMethodDesc(desc))) { + found[0] = true; + } + return null; + } + }, ClassReader.SKIP_CODE); + if (found[0]) { + candidates.add(cls); + } + } + List workers = new ArrayList(); + for (String cls : candidates) { + for (String base : WORKER_BASES) { + if (extendsBase(cls, supers, base)) { + workers.add(cls); + break; + } + } + } + java.util.Collections.sort(workers); + String context = TARGET + "android/content/Context"; + String params = TARGET + "androidx/work/WorkerParameters"; + ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS); + cw.visit(Opcodes.V1_5, Opcodes.ACC_PUBLIC | Opcodes.ACC_FINAL | Opcodes.ACC_SUPER, WORKER_INSTANTIATOR, null, + "java/lang/Object", null); + MethodVisitor ctor = cw.visitMethod(Opcodes.ACC_PRIVATE, "", "()V", null, null); + ctor.visitCode(); + ctor.visitVarInsn(Opcodes.ALOAD, 0); + ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false); + ctor.visitInsn(Opcodes.RETURN); + ctor.visitMaxs(0, 0); + ctor.visitEnd(); + MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_STATIC, "create", + "(Ljava/lang/String;Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;", null, null); + mv.visitCode(); + for (String cls : workers) { + Label next = new Label(); + mv.visitLdcInsn(cls.replace('/', '.')); + mv.visitVarInsn(Opcodes.ALOAD, 0); + mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, "java/lang/String", "equals", "(Ljava/lang/Object;)Z", false); + mv.visitJumpInsn(Opcodes.IFEQ, next); + // instanceof guards rather than bare casts: ParparVM's CHECKCAST + // is unchecked, so a wrong argument must not reach the constructor. + mv.visitVarInsn(Opcodes.ALOAD, 1); + mv.visitTypeInsn(Opcodes.INSTANCEOF, context); + mv.visitJumpInsn(Opcodes.IFEQ, next); + mv.visitVarInsn(Opcodes.ALOAD, 2); + mv.visitTypeInsn(Opcodes.INSTANCEOF, params); + mv.visitJumpInsn(Opcodes.IFEQ, next); + mv.visitTypeInsn(Opcodes.NEW, cls); + mv.visitInsn(Opcodes.DUP); + mv.visitVarInsn(Opcodes.ALOAD, 1); + mv.visitTypeInsn(Opcodes.CHECKCAST, context); + mv.visitVarInsn(Opcodes.ALOAD, 2); + mv.visitTypeInsn(Opcodes.CHECKCAST, params); + mv.visitMethodInsn(Opcodes.INVOKESPECIAL, cls, "", WORKER_CTOR, false); + mv.visitInsn(Opcodes.ARETURN); + mv.visitLabel(next); + } + mv.visitInsn(Opcodes.ACONST_NULL); + mv.visitInsn(Opcodes.ARETURN); + mv.visitMaxs(0, 0); + mv.visitEnd(); + cw.visitEnd(); + File out = new File(classesDir, WORKER_INSTANTIATOR + ".class"); + out.getParentFile().mkdirs(); + ClassRelocator.write(out, cw.toByteArray()); + return workers.size(); + } + private static boolean extendsBase(String cls, Map supers, String base) { String c = supers.get(cls); for (int depth = 0; c != null && depth < 64; depth++) { diff --git a/maven/build-engine/src/main/java/com/codename1/maven/AndroidResourceRunner.java b/maven/build-engine/src/main/java/com/codename1/maven/AndroidResourceRunner.java index 02958f6b84f..d44f3d0908b 100644 --- a/maven/build-engine/src/main/java/com/codename1/maven/AndroidResourceRunner.java +++ b/maven/build-engine/src/main/java/com/codename1/maven/AndroidResourceRunner.java @@ -227,6 +227,12 @@ && new File(resourcesOut, ResourceCompiler.APP_TABLE).isFile() && javaOut.isDire } if (result.manifest != null) { writeMainClass(result.manifest.packageName); + try { + IosLocationHints.apply(IosLocationHints.settingsFor(androidDir), result.manifest.permissions, + sourceRoots, log); + } catch (IOException e) { + log.warn("Cannot add the iOS location build hints: " + e.getMessage()); + } } log.info("Compiled " + result.resourceCount + " Android resources"); try { diff --git a/maven/build-engine/src/main/java/com/codename1/maven/IosLocationHints.java b/maven/build-engine/src/main/java/com/codename1/maven/IosLocationHints.java new file mode 100644 index 00000000000..b084e5bcbc1 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/IosLocationHints.java @@ -0,0 +1,225 @@ +/* + * 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.maven; + +import com.codename1.build.Log; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// Adds the iOS build hints an imported Android application's location use +/// needs, when the project does not set them itself. +/// +/// Android declares location access with manifest permissions; iOS refuses +/// to ask for it at all without a usage description in `Info.plist`, and +/// stops background updates without the `location` background mode. An +/// Android project carries neither, so the same code that tracks a run on +/// Android silently gets no fixes on iOS. The hints are written into +/// `codenameone_settings.properties` rather than supplied behind the +/// developer's back, so they are visible, cloud builds see them, and editing +/// the text is how the prompt is changed. +/// +/// A key already in the file, or declared through the `@IosPrivacy` +/// annotation in the project's sources, is left alone: the build refuses a +/// hint declared both ways. Lines are appended (or the one +/// `ios.background_modes` line extended); the file is never re-saved through +/// `Properties`, which would drop its comments and reorder it. +public final class IosLocationHints { + + static final String WHEN_IN_USE = "codename1.arg.ios.NSLocationWhenInUseUsageDescription"; + static final String ALWAYS = "codename1.arg.ios.NSLocationAlwaysUsageDescription"; + static final String ALWAYS_AND_WHEN_IN_USE = "codename1.arg.ios.NSLocationAlwaysAndWhenInUseUsageDescription"; + static final String BACKGROUND_MODES = "codename1.arg.ios.background_modes"; + + private static final String SETTINGS = "codenameone_settings.properties"; + + private IosLocationHints() { + } + + /// The settings file of the project `androidDir` (`src/main/android`) + /// belongs to, or null. + public static File settingsFor(File androidDir) { + File dir = androidDir == null ? null : androidDir.getAbsoluteFile(); + for (int i = 0; i < 5 && dir != null; i++) { + File f = new File(dir, SETTINGS); + if (f.isFile()) { + return f; + } + dir = dir.getParentFile(); + } + return null; + } + + /// Updates `settings` for the manifest `permissions`; answers the keys + /// it added or changed. + public static List apply(File settings, List permissions, List sourceRoots, Log log) + throws IOException { + List changed = new ArrayList(); + if (settings == null || !settings.isFile() || permissions == null) { + return changed; + } + boolean foreground = permissions.contains("android.permission.ACCESS_FINE_LOCATION") + || permissions.contains("android.permission.ACCESS_COARSE_LOCATION"); + boolean background = permissions.contains("android.permission.ACCESS_BACKGROUND_LOCATION") + || permissions.contains("android.permission.FOREGROUND_SERVICE_LOCATION"); + if (!foreground && !background) { + return changed; + } + Map wanted = new LinkedHashMap(); + wanted.put(WHEN_IN_USE, "This app uses your location while you are using it."); + if (background) { + wanted.put(ALWAYS, "This app uses your location in the background."); + wanted.put(ALWAYS_AND_WHEN_IN_USE, "This app uses your location, including in the background."); + } + String text = new String(Files.readAllBytes(settings.toPath()), "ISO-8859-1"); + String eol = text.indexOf("\r\n") >= 0 ? "\r\n" : "\n"; + StringBuilder out = new StringBuilder(text); + for (Map.Entry e : wanted.entrySet()) { + if (lineOf(text, e.getKey()) >= 0 || declaredByAnnotation(e.getKey(), sourceRoots)) { + continue; + } + appendLine(out, eol, e.getKey() + "=" + e.getValue()); + changed.add(e.getKey()); + } + if (background) { + int at = lineOf(out.toString(), BACKGROUND_MODES); + if (at < 0) { + appendLine(out, eol, BACKGROUND_MODES + "=location"); + changed.add(BACKGROUND_MODES); + } else { + int end = lineEnd(out, at); + String line = out.substring(at, end); + String value = line.substring(line.indexOf('=') + 1).trim(); + if (!hasMode(value, "location")) { + out.replace(at, end, BACKGROUND_MODES + "=" + (value.length() == 0 ? "" : value + ",") + + "location"); + changed.add(BACKGROUND_MODES); + } + } + } + if (!changed.isEmpty()) { + Files.write(settings.toPath(), out.toString().getBytes("ISO-8859-1")); + if (log != null) { + log.info("Added iOS location build hints for the manifest's location permissions to " + + settings + ": " + changed); + } + } + return changed; + } + + static boolean hasMode(String value, String mode) { + for (String m : value.split("[,\\s]+")) { + if (m.equals(mode)) { + return true; + } + } + return false; + } + + /// Offset of the line that sets `key`, or -1. A commented line does not + /// count. + private static int lineOf(String text, String key) { + int from = 0; + while (from <= text.length()) { + int end = text.indexOf('\n', from); + if (end < 0) { + end = text.length(); + } + String line = text.substring(from, end).trim(); + if (line.startsWith(key)) { + String rest = line.substring(key.length()).trim(); + if (rest.startsWith("=") || rest.startsWith(":")) { + // Indentation is preserved by pointing at the key itself. + return text.indexOf(key, from); + } + } + from = end + 1; + } + return -1; + } + + private static int lineEnd(CharSequence text, int from) { + int i = from; + while (i < text.length() && text.charAt(i) != '\n' && text.charAt(i) != '\r') { + i++; + } + return i; + } + + private static void appendLine(StringBuilder out, String eol, String line) { + if (out.length() > 0 && out.charAt(out.length() - 1) != '\n') { + out.append(eol); + } + out.append(line).append(eol); + } + + /// Whether a source names the `@IosPrivacy` member behind `key`. Textual, + /// since this runs before anything is compiled; a false positive only + /// means a hint the developer has to add by hand. + private static boolean declaredByAnnotation(String key, List sourceRoots) throws IOException { + String member; + if (WHEN_IN_USE.equals(key)) { + member = "locationWhenInUseUsageDescription"; + } else if (ALWAYS.equals(key)) { + member = "locationAlwaysUsageDescription"; + } else if (ALWAYS_AND_WHEN_IN_USE.equals(key)) { + member = "locationAlwaysAndWhenInUseUsageDescription"; + } else { + return false; + } + if (sourceRoots == null) { + return false; + } + for (File root : sourceRoots) { + if (mentions(root, member)) { + return true; + } + } + return false; + } + + private static boolean mentions(File f, String member) throws IOException { + if (f.isDirectory()) { + File[] kids = f.listFiles(); + if (kids != null) { + for (File k : kids) { + if (mentions(k, member)) { + return true; + } + } + } + return false; + } + String n = f.getName(); + if (!n.endsWith(".java") && !n.endsWith(".kt")) { + return false; + } + String src = new String(Files.readAllBytes(f.toPath()), "ISO-8859-1"); + return src.indexOf("IosPrivacy") >= 0 && src.indexOf(member) >= 0; + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/RoomGenerator.java b/maven/build-engine/src/main/java/com/codename1/maven/RoomGenerator.java new file mode 100644 index 00000000000..9ecf94ff511 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/RoomGenerator.java @@ -0,0 +1,2348 @@ +/* + * 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.maven; + +import com.codename1.build.Log; +import com.codename1.builders.BuildException; +import com.codename1.maven.RoomTypes.ClassSig; +import com.codename1.maven.RoomTypes.JType; +import com.codename1.maven.RoomTypes.MethodSig; +import com.codename1.maven.annotations.JavaSourceCompiler; +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.Type; +import org.objectweb.asm.tree.AnnotationNode; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.FieldNode; +import org.objectweb.asm.tree.LocalVariableNode; +import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.tree.ParameterNode; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +/// The build-time half of AndroidX Room on the compatibility runtime: what +/// Room's annotation processor does on Android, done from the compiled +/// classes. For every `@Database` class it writes `Name_Impl` (the schema, +/// the DAO getters, the converter instances) and for every DAO it returns +/// `Dao_Impl` (each `@Query`, `@Insert`, `@Update`, `@Delete`, `@Upsert`, +/// `@RawQuery` and `@Transaction` method as plain code), as Java source +/// compiled against the application's classes and the runtime jar. Nothing +/// it writes uses reflection. +/// +/// It runs before the classes are relocated, so the source names the real +/// `androidx` and `android` types. Anything Room accepts that this does not +/// fails the build with the class and member named: a construct is never +/// skipped silently. +final class RoomGenerator { + + static final String DATABASE = "Landroidx/room/Database;"; + static final String DATABASE_RELOCATED = "L" + AndroidRemapper.TARGET + "androidx/room/Database;"; + private static final String DAO = "Landroidx/room/Dao;"; + private static final String ENTITY = "Landroidx/room/Entity;"; + private static final String PRIMARY_KEY = "Landroidx/room/PrimaryKey;"; + private static final String COLUMN_INFO = "Landroidx/room/ColumnInfo;"; + private static final String IGNORE = "Landroidx/room/Ignore;"; + private static final String EMBEDDED = "Landroidx/room/Embedded;"; + private static final String RELATION = "Landroidx/room/Relation;"; + private static final String QUERY = "Landroidx/room/Query;"; + private static final String RAW_QUERY = "Landroidx/room/RawQuery;"; + private static final String INSERT = "Landroidx/room/Insert;"; + private static final String UPDATE = "Landroidx/room/Update;"; + private static final String DELETE = "Landroidx/room/Delete;"; + private static final String UPSERT = "Landroidx/room/Upsert;"; + private static final String TRANSACTION = "Landroidx/room/Transaction;"; + private static final String TYPE_CONVERTER = "Landroidx/room/TypeConverter;"; + private static final String TYPE_CONVERTERS = "Landroidx/room/TypeConverters;"; + private static final String PROVIDED_CONVERTER = "Landroidx/room/ProvidedTypeConverter;"; + private static final String FTS3 = "Landroidx/room/Fts3;"; + private static final String FTS4 = "Landroidx/room/Fts4;"; + private static final String DATABASE_VIEW = "Landroidx/room/DatabaseView;"; + private static final String[] NOT_NULL = {"Landroidx/annotation/NonNull;", "Lorg/jetbrains/annotations/NotNull;", + "Ljavax/annotation/Nonnull;", "Landroid/support/annotation/NonNull;"}; + private static final String RT = "com.codename1.androidcompat.runtime.RoomSupport"; + private static final String[] LIST_TYPES = {"java/util/List", "java/util/ArrayList", "java/util/Collection", + "java/lang/Iterable"}; + private static final String[] SET_TYPES = {"java/util/Set", "java/util/HashSet", "java/util/LinkedHashSet"}; + /// Return types Room maps through an integration this runtime lacks. + private static final String[][] REACTIVE = { + {"kotlinx/coroutines/flow/Flow", "Kotlin Flow"}, + {"io/reactivex/Flowable", "RxJava"}, {"io/reactivex/Observable", "RxJava"}, + {"io/reactivex/Single", "RxJava"}, {"io/reactivex/Maybe", "RxJava"}, + {"io/reactivex/Completable", "RxJava"}, + {"io/reactivex/rxjava3/core/Flowable", "RxJava"}, {"io/reactivex/rxjava3/core/Observable", "RxJava"}, + {"io/reactivex/rxjava3/core/Single", "RxJava"}, {"io/reactivex/rxjava3/core/Maybe", "RxJava"}, + {"io/reactivex/rxjava3/core/Completable", "RxJava"}, + {"com/google/common/util/concurrent/ListenableFuture", "Guava ListenableFuture"}, + {"androidx/paging/PagingSource", "Paging"}, {"androidx/paging/DataSource$Factory", "Paging"}, + {"java/util/Optional", "java.util.Optional"}, {"com/google/common/base/Optional", "Guava Optional"}, + {"java/util/Map", "multimap"}, {"java/util/HashMap", "multimap"}, {"java/util/LinkedHashMap", "multimap"}, + {"java/util/TreeMap", "multimap"}, {"androidx/collection/LongSparseArray", "multimap"}, + {"androidx/collection/ArrayMap", "multimap"}}; + + private final File classesDir; + private final List extraDirs; + private final File compatJar; + private final Log log; + private final Map app = new LinkedHashMap(); + private final Map lib = new HashMap(); + private ZipFile compatZip; + private int uid; + + RoomGenerator(File classesDir, List extraDirs, File compatJar, Log log) { + this.classesDir = classesDir; + this.extraDirs = extraDirs; + this.compatJar = compatJar; + this.log = log; + } + + /// A Room construct the build cannot translate, with where it is. + static final class RoomError extends RuntimeException { + private static final long serialVersionUID = 1L; + + RoomError(String message) { + super(message); + } + } + + /// Writes and compiles the implementations; answers how many databases + /// there were. + int generate() throws BuildException { + try { + load(classesDir); + for (File d : extraDirs) { + load(d); + } + List dbs = new ArrayList(); + for (ClassNode n : app.values()) { + if (ann(n.visibleAnnotations, n.invisibleAnnotations, DATABASE) != null) { + dbs.add(n); + } + } + if (dbs.isEmpty()) { + return 0; + } + Map sources = new LinkedHashMap(); + Set daos = new HashSet(); + for (ClassNode db : dbs) { + new DbGen(db).generate(sources, daos); + } + List cp = new ArrayList(); + cp.add(classesDir); + cp.addAll(extraDirs); + if (compatJar != null) { + cp.add(compatJar); + } + File core = coreJar(); + if (core != null) { + cp.add(core); + } + try { + JavaSourceCompiler.compile(sources, classesDir, cp); + } catch (IOException e) { + throw new BuildException("Room: the generated implementations did not compile; this is a bug in" + + " the Room build step, not in the application.\n" + e.getMessage(), e); + } + log.info("Room: generated " + dbs.size() + " database and " + daos.size() + " DAO implementation(s)"); + return dbs.size(); + } catch (RoomError e) { + throw new BuildException("Room: " + e.getMessage()); + } catch (IOException e) { + throw new BuildException("Room code generation failed: " + e.getMessage(), e); + } finally { + if (compatZip != null) { + try { + compatZip.close(); + } catch (IOException ignored) { + // Read-only; nothing to lose. + } + } + } + } + + /// The Codename One core jar the build engine itself runs against: the + /// runtime's classes refer to it. + private static File coreJar() { + java.security.CodeSource cs = com.codename1.db.Database.class.getProtectionDomain().getCodeSource(); + if (cs == null || cs.getLocation() == null) { + return null; + } + try { + return new File(cs.getLocation().toURI()); + } catch (java.net.URISyntaxException | IllegalArgumentException e) { + return null; + } + } + + /// The generated implementation of a database or DAO class, by Room's + /// naming: nested names joined with `_`, then `_Impl`. + static String implName(String internal) { + return internal.replace('$', '_') + "_Impl"; + } + + // ------------------------------------------------------------ loading + + private void load(File dir) throws IOException { + List files = new ArrayList(); + collect(dir, files); + for (File f : files) { + ClassNode n = new ClassNode(); + new ClassReader(Files.readAllBytes(f.toPath())).accept(n, ClassReader.SKIP_FRAMES); + if (n.name.startsWith(AndroidRemapper.TARGET)) { + continue; + } + app.put(n.name, n); + } + } + + private static void collect(File dir, List out) { + File[] files = dir.listFiles(); + if (files == null) { + return; + } + for (File f : files) { + if (f.isDirectory()) { + collect(f, out); + } else if (f.getName().endsWith(".class")) { + out.add(f); + } + } + } + + private ClassNode node(String internal) { + ClassNode n = app.get(internal); + if (n != null) { + return n; + } + if (lib.containsKey(internal)) { + return lib.get(internal); + } + n = null; + try { + if (compatJar != null && compatJar.isFile()) { + if (compatZip == null) { + compatZip = new ZipFile(compatJar); + } + ZipEntry e = compatZip.getEntry(internal + ".class"); + if (e != null) { + InputStream in = compatZip.getInputStream(e); + try { + n = new ClassNode(); + new ClassReader(in).accept(n, ClassReader.SKIP_CODE); + } finally { + in.close(); + } + } + } + } catch (IOException e) { + n = null; + } + lib.put(internal, n); + return n; + } + + private boolean isEnum(String internal) { + ClassNode n = node(internal); + return n != null && (n.access & Opcodes.ACC_ENUM) != 0; + } + + private boolean isSubclass(String cls, String base) { + String c = cls; + int guard = 0; + while (c != null && guard++ < 64) { + if (c.equals(base)) { + return true; + } + ClassNode n = node(c); + c = n == null ? null : n.superName; + } + return false; + } + + // ------------------------------------------------------------ annotations + + static AnnotationNode ann(List visible, List invisible, String desc) { + if (visible != null) { + for (AnnotationNode a : visible) { + if (a.desc.equals(desc)) { + return a; + } + } + } + if (invisible != null) { + for (AnnotationNode a : invisible) { + if (a.desc.equals(desc)) { + return a; + } + } + } + return null; + } + + private static AnnotationNode ann(ClassNode n, String desc) { + return ann(n.visibleAnnotations, n.invisibleAnnotations, desc); + } + + private static AnnotationNode ann(FieldNode n, String desc) { + return ann(n.visibleAnnotations, n.invisibleAnnotations, desc); + } + + private static AnnotationNode ann(MethodNode n, String desc) { + return ann(n.visibleAnnotations, n.invisibleAnnotations, desc); + } + + private static Object val(AnnotationNode a, String name) { + if (a == null || a.values == null) { + return null; + } + for (int i = 0; i + 1 < a.values.size(); i += 2) { + if (name.equals(a.values.get(i))) { + return a.values.get(i + 1); + } + } + return null; + } + + private static String str(AnnotationNode a, String name, String def) { + Object v = val(a, name); + return v instanceof String ? (String) v : def; + } + + private static int intv(AnnotationNode a, String name, int def) { + Object v = val(a, name); + return v instanceof Integer ? ((Integer) v).intValue() : def; + } + + private static boolean bool(AnnotationNode a, String name, boolean def) { + Object v = val(a, name); + return v instanceof Boolean ? ((Boolean) v).booleanValue() : def; + } + + private static List list(AnnotationNode a, String name) { + Object v = val(a, name); + if (v instanceof List) { + List out = new ArrayList(); + for (Object o : (List) v) { + out.add(o); + } + return out; + } + return Collections.emptyList(); + } + + private static List types(AnnotationNode a, String name) { + List out = new ArrayList(); + for (Object o : list(a, name)) { + if (o instanceof Type) { + out.add(((Type) o).getInternalName()); + } + } + return out; + } + + private static String typeVal(AnnotationNode a, String name) { + Object v = val(a, name); + return v instanceof Type ? ((Type) v).getInternalName() : null; + } + + // ------------------------------------------------------------ names + + static String pkg(String internal) { + int i = internal.lastIndexOf('/'); + return i < 0 ? "" : internal.substring(0, i).replace('/', '.'); + } + + static String simple(String internal) { + int i = Math.max(internal.lastIndexOf('/'), internal.lastIndexOf('$')); + return internal.substring(i + 1); + } + + static String fqn(String internal) { + return internal.replace('/', '.'); + } + + private static String cap(String s) { + return s.isEmpty() ? s : Character.toUpperCase(s.charAt(0)) + s.substring(1); + } + + static String quote(String s) { + StringBuilder sb = new StringBuilder("\""); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '"' || c == '\\') { + sb.append('\\').append(c); + } else if (c == '\n') { + sb.append("\\n"); + } else if (c == '\r') { + sb.append("\\r"); + } else if (c == '\t') { + sb.append("\\t"); + } else if (c < 0x20 || c > 0x7e) { + sb.append(String.format("\\u%04x", Integer.valueOf(c))); + } else { + sb.append(c); + } + } + return sb.append('"').toString(); + } + + static String asciiLower(String s) { + StringBuilder sb = new StringBuilder(s.length()); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + sb.append(c >= 'A' && c <= 'Z' ? (char) (c + ('a' - 'A')) : c); + } + return sb.toString(); + } + + private static String where(ClassNode n, MethodNode m) { + return fqn(n.name) + "." + m.name + "()"; + } + + private static String sourceFile(ClassNode n) { + return n.sourceFile == null ? "" : " (" + n.sourceFile + ")"; + } + + // ------------------------------------------------------------ converters + + /// A `@TypeConverter` method. + static final class Conv { + String owner; + String name; + boolean isStatic; + JType from; + JType to; + } + + /// How one Java type is read from a column and bound to a statement. + abstract static class Codec { + abstract String read(String c, String i); + + abstract String bind(String v); + + abstract String affinity(); + } + + static boolean isNative(JType t) { + if (t.isPrimitive()) { + return !t.isVoid(); + } + if (t.dims == 1 && t.kind == 'B') { + return true; + } + return RoomTypes.unboxed(t) != 0 || t.isClass("java/lang/String"); + } + + /// Primitives, their boxes, `String` and `byte[]`. + static final class NativeCodec extends Codec { + final JType t; + + NativeCodec(JType t) { + this.t = t; + } + + static String readPrim(char k, String c, String i) { + switch (k) { + case 'Z': return "(" + c + ".getInt(" + i + ") != 0)"; + case 'B': return "((byte) " + c + ".getShort(" + i + "))"; + case 'C': return "((char) " + c + ".getInt(" + i + "))"; + case 'S': return c + ".getShort(" + i + ")"; + case 'I': return c + ".getInt(" + i + ")"; + case 'J': return c + ".getLong(" + i + ")"; + case 'F': return c + ".getFloat(" + i + ")"; + case 'D': return c + ".getDouble(" + i + ")"; + default: throw new IllegalArgumentException(String.valueOf(k)); + } + } + + @Override + String read(String c, String i) { + if (t.isPrimitive()) { + return readPrim(t.kind, c, i); + } + if (t.isArray()) { + return "(" + c + ".isNull(" + i + ") ? null : " + c + ".getBlob(" + i + "))"; + } + if (t.isClass("java/lang/String")) { + return "(" + c + ".isNull(" + i + ") ? null : " + c + ".getString(" + i + "))"; + } + char k = RoomTypes.unboxed(t); + return "(" + c + ".isNull(" + i + ") ? null : " + RoomTypes.boxName(k) + ".valueOf(" + + readPrim(k, c, i) + "))"; + } + + @Override + String bind(String v) { + if (t.isPrimitive()) { + if (t.kind == 'Z') { + return RT + ".bindBoolean(" + v + ")"; + } + if (t.kind == 'C') { + return "Integer.valueOf(" + v + ")"; + } + return RoomTypes.boxName(t.kind) + ".valueOf(" + v + ")"; + } + if (t.isClass("java/lang/Boolean")) { + return RT + ".bindBoolean(" + v + ")"; + } + if (t.isClass("java/lang/Character")) { + return RT + ".bindChar(" + v + ")"; + } + return v; + } + + @Override + String affinity() { + if (t.isArray()) { + return "BLOB"; + } + if (t.isClass("java/lang/String")) { + return "TEXT"; + } + char k = t.isPrimitive() ? t.kind : RoomTypes.unboxed(t); + return k == 'F' || k == 'D' ? "REAL" : "INTEGER"; + } + } + + /// An enum, by constant name, Room's built-in converter. + static final class EnumCodec extends Codec { + final JType t; + + EnumCodec(JType t) { + this.t = t; + } + + @Override + String read(String c, String i) { + return RT + ".enumByName(" + t.rawSrc() + ".values(), " + c + ".isNull(" + i + ") ? null : " + c + + ".getString(" + i + "))"; + } + + @Override + String bind(String v) { + return RT + ".enumName(" + v + ")"; + } + + @Override + String affinity() { + return "TEXT"; + } + } + + /// A type the application's converters turn into a column type. + static final class ConvCodec extends Codec { + final JType t; + final Conv toColumn; + final Conv fromColumn; + + ConvCodec(JType t, Conv toColumn, Conv fromColumn) { + this.t = t; + this.toColumn = toColumn; + this.fromColumn = fromColumn; + } + + static String call(Conv c, String arg) { + String target = c.isStatic ? RoomTypes.sourceName(c.owner) + : "__db.getTypeConverter(" + RoomTypes.sourceName(c.owner) + ".class)"; + return target + "." + c.name + "(" + arg + ")"; + } + + @Override + String read(String c, String i) { + if (fromColumn == null) { + throw new RoomError("Cannot figure out how to read " + t.src() + " from a cursor: no @TypeConverter" + + " converts a column value to it."); + } + String col = new NativeCodec(fromColumn.from).read(c, i); + String conv = call(fromColumn, col); + if (fromColumn.from.isPrimitive() && !t.isPrimitive()) { + return "(" + c + ".isNull(" + i + ") ? null : " + conv + ")"; + } + return conv; + } + + @Override + String bind(String v) { + if (toColumn == null) { + throw new RoomError("Cannot figure out how to save " + t.src() + " into the database: no" + + " @TypeConverter converts it to a column value."); + } + String bound = new NativeCodec(toColumn.to).bind(call(toColumn, v)); + if (toColumn.to.isPrimitive() && !t.isPrimitive()) { + return "(" + v + " == null ? null : (Object) " + bound + ")"; + } + return bound; + } + + @Override + String affinity() { + JType col = toColumn != null ? toColumn.to : fromColumn.from; + return new NativeCodec(col).affinity(); + } + } + + // ------------------------------------------------------------ model + + /// A field of an entity or a query result class. + static final class Prop { + String field; + JType type; + String column; + String owner; + boolean embedded; + String prefix = ""; + Pojo emb; + String getter; + String setter; + boolean fieldReadable; + boolean fieldWritable; + Codec codec; + AnnotationNode columnInfo; + AnnotationNode primaryKey; + boolean notNullAnnotated; + } + + /// A class whose fields map to columns. + static final class Pojo { + String cls; + JType type; + final List props = new ArrayList(); + final List ctorParams = new ArrayList(); + int leafCount; + } + + /// A column, through the embedded objects that lead to it. + static final class Leaf { + String column; + Prop prop; + int index; + } + + /// An `@Entity` and its table. + static final class Entity { + String cls; + String table; + Pojo pojo; + final List leaves = new ArrayList(); + final List pk = new ArrayList(); + Leaf autoGenerate; + final List ddl = new ArrayList(); + } + + private final Map pojos = new HashMap(); + + private Pojo pojo(String cls, String pkg, List convs, Set visiting) { + String key = cls + "|" + pkg; + Pojo cached = pojos.get(key); + if (cached != null) { + return cached; + } + if (!visiting.add(cls)) { + throw new RoomError(fqn(cls) + ": recursive @Embedded"); + } + ClassNode n = app.get(cls); + if (n == null) { + throw new RoomError(fqn(cls) + " is used as an entity or query result but is not an application class"); + } + if ((n.access & (Opcodes.ACC_ABSTRACT | Opcodes.ACC_INTERFACE)) != 0) { + throw new RoomError(fqn(cls) + sourceFile(n) + ": an entity or query result class cannot be abstract"); + } + if (!accessible(n.access, cls, pkg)) { + throw new RoomError(fqn(cls) + sourceFile(n) + " must be public to be used by a DAO in package " + pkg); + } + AnnotationNode entity = ann(n, ENTITY); + Set ignored = new HashSet(); + for (Object o : list(entity, "ignoredColumns")) { + ignored.add(String.valueOf(o)); + } + List chain = new ArrayList(); + for (String c = cls; c != null; ) { + ClassNode cn = app.get(c); + if (cn == null) { + break; + } + chain.add(0, cn); + c = cn.superName; + } + Pojo p = new Pojo(); + p.cls = cls; + p.type = JType.cls(cls); + for (ClassNode cn : chain) { + for (FieldNode f : cn.fields) { + if ((f.access & (Opcodes.ACC_STATIC | Opcodes.ACC_TRANSIENT | Opcodes.ACC_SYNTHETIC)) != 0 + || ann(f, IGNORE) != null) { + continue; + } + if (ann(f, RELATION) != null) { + throw new RoomError(fqn(cn.name) + "." + f.name + sourceFile(cn) + + ": @Relation is not supported by the Codename One Room build; query the related" + + " rows with a second @Query"); + } + Prop prop = new Prop(); + prop.field = f.name; + prop.owner = cn.name; + prop.columnInfo = ann(f, COLUMN_INFO); + prop.primaryKey = ann(f, PRIMARY_KEY); + for (String nn : NOT_NULL) { + if (ann(f, nn) != null) { + prop.notNullAnnotated = true; + } + } + String col = str(prop.columnInfo, "name", "[field-name]"); + prop.column = "[field-name]".equals(col) ? f.name : col; + if (ignored.contains(prop.column)) { + continue; + } + prop.type = RoomTypes.parseField(f.signature != null ? f.signature : f.desc); + if (prop.type.hasTypeVariables()) { + throw new RoomError(fqn(cn.name) + "." + f.name + sourceFile(cn) + + ": a field of a type variable cannot be mapped to a column"); + } + AnnotationNode embedded = ann(f, EMBEDDED); + if (embedded != null) { + if (prop.type.kind != 'L' || prop.type.isArray()) { + throw new RoomError(fqn(cn.name) + "." + f.name + ": @Embedded needs a class type"); + } + prop.embedded = true; + prop.prefix = str(embedded, "prefix", ""); + prop.emb = pojo(prop.type.name, pkg, convs, visiting); + } else { + prop.codec = codec(prop.type, convs); + if (prop.codec == null) { + throw new RoomError(fqn(cn.name) + "." + f.name + sourceFile(cn) + + ": Cannot figure out how to save this field into database. You can consider" + + " adding a type converter for it. (type " + prop.type.src() + ")"); + } + } + prop.fieldReadable = accessible(f.access, cn.name, pkg); + prop.fieldWritable = prop.fieldReadable && (f.access & Opcodes.ACC_FINAL) == 0; + findAccessors(chain, prop, f.desc, pkg); + p.props.add(prop); + } + } + chooseConstructor(n, p, pkg); + for (Prop prop : p.props) { + p.leafCount += prop.embedded ? prop.emb.leafCount : 1; + } + visiting.remove(cls); + pojos.put(key, p); + return p; + } + + private static boolean accessible(int access, String owner, String pkg) { + if ((access & Opcodes.ACC_PUBLIC) != 0) { + return true; + } + if ((access & Opcodes.ACC_PRIVATE) != 0) { + return false; + } + return pkg(owner).equals(pkg); + } + + /// Room's accessor names: `getX`/`isX`/`x()` and `setX`/`x(v)`, for the + /// field name and for it without an `m` or `_` prefix. + private static void findAccessors(List chain, Prop prop, String desc, String pkg) { + List names = new ArrayList(); + names.add(prop.field); + String f = prop.field; + if (f.length() > 1 && f.charAt(0) == 'm' && Character.isUpperCase(f.charAt(1))) { + names.add(Character.toLowerCase(f.charAt(1)) + f.substring(2)); + } + if (f.length() > 1 && f.charAt(0) == '_') { + names.add(f.substring(1)); + } + List getters = new ArrayList(); + List setters = new ArrayList(); + for (String n : names) { + getters.add("get" + cap(n)); + if (desc.equals("Z") || desc.equals("Ljava/lang/Boolean;")) { + getters.add("is" + cap(n)); + getters.add("has" + cap(n)); + } + getters.add(n); + setters.add("set" + cap(n)); + if (n.length() > 2 && n.startsWith("is") && Character.isUpperCase(n.charAt(2))) { + setters.add("set" + n.substring(2)); + } + setters.add(n); + } + for (int i = chain.size() - 1; i >= 0; i--) { + ClassNode cn = chain.get(i); + for (MethodNode m : cn.methods) { + if ((m.access & Opcodes.ACC_STATIC) != 0 || !accessible(m.access, cn.name, pkg)) { + continue; + } + if (prop.getter == null && getters.contains(m.name) && m.desc.equals("()" + desc)) { + prop.getter = m.name; + } + if (prop.setter == null && setters.contains(m.name) && m.desc.startsWith("(" + desc + ")")) { + prop.setter = m.name; + } + } + } + } + + private static List paramNames(MethodNode m, int count) { + if (m.parameters != null && m.parameters.size() == count) { + List out = new ArrayList(); + for (ParameterNode p : m.parameters) { + out.add(p.name); + } + return out; + } + if (m.localVariables == null) { + return null; + } + Type[] args = Type.getArgumentTypes(m.desc); + List out = new ArrayList(); + int slot = (m.access & Opcodes.ACC_STATIC) != 0 ? 0 : 1; + for (Type a : args) { + String name = null; + for (LocalVariableNode lv : m.localVariables) { + if (lv.index == slot) { + name = lv.name; + break; + } + } + if (name == null) { + return null; + } + out.add(name); + slot += a.getSize(); + } + return out; + } + + private static boolean settable(Prop p) { + return p.setter != null || p.fieldWritable; + } + + /// A public no-argument constructor with setters, or the smallest + /// constructor whose parameters name the fields nothing else can set. + private static void chooseConstructor(ClassNode n, Pojo p, String pkg) { + Set need = new HashSet(); + for (Prop prop : p.props) { + if (!settable(prop)) { + need.add(prop); + } + } + List best = null; + for (MethodNode m : n.methods) { + if (!m.name.equals("") || (m.access & Opcodes.ACC_SYNTHETIC) != 0 || ann(m, IGNORE) != null + || !accessible(m.access, n.name, pkg)) { + continue; + } + Type[] args = Type.getArgumentTypes(m.desc); + List params = new ArrayList(); + if (args.length > 0) { + List names = paramNames(m, args.length); + if (names == null) { + continue; + } + for (int i = 0; i < args.length; i++) { + Prop match = null; + for (Prop prop : p.props) { + String f = prop.field; + String bare = f.length() > 1 && f.charAt(0) == 'm' && Character.isUpperCase(f.charAt(1)) + ? Character.toLowerCase(f.charAt(1)) + f.substring(2) : f; + if ((f.equals(names.get(i)) || bare.equals(names.get(i))) + && prop.type.desc().equals(args[i].getDescriptor())) { + match = prop; + } + } + if (match == null) { + params = null; + break; + } + params.add(match); + } + } + if (params == null || !params.containsAll(need)) { + continue; + } + if (best == null || params.size() < best.size()) { + best = params; + } + } + if (best == null) { + throw new RoomError(fqn(n.name) + sourceFile(n) + ": Entities and POJOs must have a usable public" + + " constructor. You can have an empty constructor or a constructor whose parameters match the" + + " fields (by name and type)" + (need.isEmpty() ? "" : "; these fields have no setter: " + + names(need))); + } + p.ctorParams.addAll(best); + } + + private static String names(Set props) { + List out = new ArrayList(); + for (Prop p : props) { + out.add(p.field); + } + Collections.sort(out); + return out.toString(); + } + + private static void flatten(Pojo p, String prefix, List out) { + for (Prop prop : p.props) { + if (prop.embedded) { + flatten(prop.emb, prefix + prop.prefix, out); + } else { + Leaf l = new Leaf(); + l.column = prefix + prop.column; + l.prop = prop; + l.index = out.size(); + out.add(l); + } + } + } + + private Codec codec(JType t, List convs) { + if (isNative(t)) { + return new NativeCodec(t); + } + Conv to = null; + Conv from = null; + for (Conv c : convs) { + if (c.from.equals(t) && isNative(c.to) && (to == null || (from != null && c.to.equals(from.from)))) { + to = c; + } + if (c.to.equals(t) && isNative(c.from) && (from == null || (to != null && c.from.equals(to.to)))) { + from = c; + } + } + if (to != null || from != null) { + return new ConvCodec(t, to, from); + } + if (t.kind == 'L' && t.dims == 0 && isEnum(t.name)) { + return new EnumCodec(t); + } + return null; + } + + private String tableName(String cls) { + ClassNode n = app.get(cls); + AnnotationNode e = n == null ? null : ann(n, ENTITY); + if (e == null) { + throw new RoomError(fqn(cls) + " is not an @Entity"); + } + String t = str(e, "tableName", ""); + return t.isEmpty() ? simple(cls) : t; + } + + private Entity entity(String cls, List convs) { + ClassNode n = app.get(cls); + if (n == null || ann(n, ENTITY) == null) { + throw new RoomError(fqn(cls) + " is listed in @Database(entities) but is not an @Entity class"); + } + if (ann(n, FTS3) != null || ann(n, FTS4) != null) { + throw new RoomError(fqn(cls) + sourceFile(n) + ": FTS entities are not supported by the Codename One" + + " Room build"); + } + AnnotationNode ea = ann(n, ENTITY); + Entity e = new Entity(); + e.cls = cls; + e.table = tableName(cls); + e.pojo = pojo(cls, pkg(cls), convs, new HashSet()); + flatten(e.pojo, "", e.leaves); + List declared = list(ea, "primaryKeys"); + for (Leaf l : e.leaves) { + if (l.prop.primaryKey != null) { + e.pk.add(l); + if (bool(l.prop.primaryKey, "autoGenerate", false)) { + e.autoGenerate = l; + } + } + } + // @PrimaryKey on an @Embedded field: every column of it. + collectEmbeddedPk(e.pojo, e.leaves, new int[1], e.pk); + if (!declared.isEmpty()) { + if (!e.pk.isEmpty()) { + throw new RoomError(fqn(cls) + ": You cannot have multiple primary keys defined in an Entity"); + } + for (Object o : declared) { + Leaf l = leaf(e, String.valueOf(o)); + if (l == null) { + throw new RoomError(fqn(cls) + ": primary key column " + o + " is not a column of the entity"); + } + e.pk.add(l); + } + } + if (e.pk.isEmpty()) { + throw new RoomError(fqn(cls) + sourceFile(n) + ": An entity must have at least 1 field annotated with" + + " @PrimaryKey"); + } + if (e.autoGenerate != null) { + JType t = e.autoGenerate.prop.type; + char k = t.isPrimitive() ? t.kind : RoomTypes.unboxed(t); + if (e.pk.size() != 1 || !(k == 'I' || k == 'J')) { + throw new RoomError(fqn(cls) + "." + e.autoGenerate.prop.field + ": If a primary key is annotated" + + " with autoGenerate, its type must be int, Integer, long or Long and it must be the only" + + " primary key"); + } + } + StringBuilder sql = new StringBuilder("CREATE TABLE IF NOT EXISTS `" + e.table + "` ("); + List indexed = new ArrayList(); + for (int i = 0; i < e.leaves.size(); i++) { + Leaf l = e.leaves.get(i); + if (i > 0) { + sql.append(", "); + } + sql.append('`').append(l.column).append("` ").append(affinity(l)); + if (l == e.autoGenerate) { + sql.append(" PRIMARY KEY AUTOINCREMENT"); + } + if (l.prop.type.isPrimitive() || l.prop.notNullAnnotated || e.pk.contains(l)) { + sql.append(" NOT NULL"); + } + int collate = intv(l.prop.columnInfo, "collate", 1); + String[] collations = {null, null, "BINARY", "NOCASE", "RTRIM", "LOCALIZED", "UNICODE"}; + if (collate > 1 && collate < collations.length) { + sql.append(" COLLATE ").append(collations[collate]); + } + String def = str(l.prop.columnInfo, "defaultValue", "[value-unspecified]"); + if (!"[value-unspecified]".equals(def)) { + sql.append(" DEFAULT ").append(def); + } + if (bool(l.prop.columnInfo, "index", false)) { + indexed.add(l.column); + } + } + if (e.autoGenerate == null) { + sql.append(", PRIMARY KEY("); + for (int i = 0; i < e.pk.size(); i++) { + sql.append(i > 0 ? ", " : "").append('`').append(e.pk.get(i).column).append('`'); + } + sql.append(')'); + } + String[] actions = {null, "NO ACTION", "RESTRICT", "SET NULL", "SET DEFAULT", "CASCADE"}; + for (Object o : list(ea, "foreignKeys")) { + AnnotationNode fk = (AnnotationNode) o; + String parent = tableName(typeVal(fk, "entity")); + sql.append(", FOREIGN KEY(").append(columns(list(fk, "childColumns"))).append(") REFERENCES `") + .append(parent).append("`(").append(columns(list(fk, "parentColumns"))).append(") ON UPDATE ") + .append(actions[clamp(intv(fk, "onUpdate", 1))]).append(" ON DELETE ") + .append(actions[clamp(intv(fk, "onDelete", 1))]); + if (bool(fk, "deferred", false)) { + sql.append(" DEFERRABLE INITIALLY DEFERRED"); + } + } + sql.append(')'); + e.ddl.add(sql.toString()); + for (Object o : list(ea, "indices")) { + AnnotationNode ix = (AnnotationNode) o; + List cols = list(ix, "value"); + String name = str(ix, "name", ""); + if (name.isEmpty()) { + StringBuilder nm = new StringBuilder("index_" + e.table); + for (Object c : cols) { + nm.append('_').append(c); + } + name = nm.toString(); + } + e.ddl.add("CREATE " + (bool(ix, "unique", false) ? "UNIQUE " : "") + "INDEX IF NOT EXISTS `" + name + + "` ON `" + e.table + "` (" + columns(cols) + ")"); + } + for (String c : indexed) { + e.ddl.add("CREATE INDEX IF NOT EXISTS `index_" + e.table + "_" + c + "` ON `" + e.table + "` (`" + c + + "`)"); + } + return e; + } + + private static void collectEmbeddedPk(Pojo p, List leaves, int[] next, List pk) { + for (Prop prop : p.props) { + if (prop.embedded) { + int start = next[0]; + collectEmbeddedPk(prop.emb, leaves, next, pk); + if (prop.primaryKey != null) { + for (int i = start; i < start + prop.emb.leafCount; i++) { + pk.add(leaves.get(i)); + } + } + } else { + next[0]++; + } + } + } + + private static int clamp(int action) { + return action < 1 || action > 5 ? 1 : action; + } + + private static String columns(List cols) { + StringBuilder sb = new StringBuilder(); + for (Object c : cols) { + sb.append(sb.length() > 0 ? ", " : "").append('`').append(c).append('`'); + } + return sb.toString(); + } + + private static String affinity(Leaf l) { + int a = intv(l.prop.columnInfo, "typeAffinity", 1); + switch (a) { + case 2: return "TEXT"; + case 3: return "INTEGER"; + case 4: return "REAL"; + case 5: return "BLOB"; + default: return l.prop.codec.affinity(); + } + } + + private static Leaf leaf(Entity e, String column) { + for (Leaf l : e.leaves) { + if (l.column.equals(column)) { + return l; + } + } + return null; + } + + // ------------------------------------------------------------ database + + /// One `@Database` class and what it generates. + private final class DbGen { + final ClassNode db; + final AnnotationNode da; + final List convs = new ArrayList(); + final List converterClasses = new ArrayList(); + final Map entities = new LinkedHashMap(); + + DbGen(ClassNode db) { + this.db = db; + this.da = ann(db, DATABASE); + } + + void generate(Map sources, Set daosDone) { + if (!isSubclass(db.name, "androidx/room/RoomDatabase")) { + throw new RoomError(fqn(db.name) + sourceFile(db) + ": a @Database class must extend RoomDatabase"); + } + if ((db.access & Opcodes.ACC_ABSTRACT) == 0) { + throw new RoomError(fqn(db.name) + sourceFile(db) + ": a @Database class must be abstract"); + } + if (!list(da, "autoMigrations").isEmpty()) { + throw new RoomError(fqn(db.name) + sourceFile(db) + ": @AutoMigration is not supported by the" + + " Codename One Room build; write the Migration and pass it to addMigrations"); + } + if (!list(da, "views").isEmpty()) { + throw new RoomError(fqn(db.name) + sourceFile(db) + ": @DatabaseView is not supported by the" + + " Codename One Room build"); + } + List entityClasses = types(da, "entities"); + if (entityClasses.isEmpty()) { + throw new RoomError(fqn(db.name) + ": @Database must list at least one entity"); + } + addConverters(ann(db, TYPE_CONVERTERS)); + for (String ec : entityClasses) { + ClassNode en = app.get(ec); + if (en != null) { + addConverters(ann(en, TYPE_CONVERTERS)); + if (ann(en, DATABASE_VIEW) != null) { + throw new RoomError(fqn(ec) + ": @DatabaseView is not supported by the Codename One Room" + + " build"); + } + } + } + List daoGetters = new ArrayList(); + List daoTypes = new ArrayList(); + for (String c = db.name; c != null && !c.equals("androidx/room/RoomDatabase"); ) { + ClassNode cn = app.get(c); + if (cn == null) { + break; + } + for (MethodNode m : cn.methods) { + if ((m.access & Opcodes.ACC_ABSTRACT) == 0) { + continue; + } + Type ret = Type.getReturnType(m.desc); + ClassNode dn = ret.getSort() == Type.OBJECT ? app.get(ret.getInternalName()) : null; + if (Type.getArgumentTypes(m.desc).length != 0 || dn == null || ann(dn, DAO) == null) { + throw new RoomError(where(cn, m) + sourceFile(cn) + ": an abstract method of a @Database" + + " class must take no parameters and return a @Dao type"); + } + boolean dup = false; + for (MethodNode seen : daoGetters) { + dup |= seen.name.equals(m.name) && seen.desc.equals(m.desc); + } + if (!dup) { + daoGetters.add(m); + daoTypes.add(dn.name); + addConverters(ann(dn, TYPE_CONVERTERS)); + } + } + c = cn.superName; + } + for (String ec : entityClasses) { + entities.put(ec, entity(ec, convs)); + } + for (String dt : daoTypes) { + if (daosDone.add(dt)) { + String src = new DaoGen(this, app.get(dt)).generate(); + sources.put(fqn(implName(dt)), src); + } + } + sources.put(fqn(implName(db.name)), source(daoGetters, daoTypes)); + } + + private void addConverters(AnnotationNode tc) { + for (String cls : types(tc, "value")) { + if (converterClasses.contains(cls)) { + continue; + } + converterClasses.add(cls); + ClassNode cn = app.get(cls); + if (cn == null) { + throw new RoomError("@TypeConverters names " + fqn(cls) + ", which is not an application class"); + } + for (MethodNode m : cn.methods) { + if (ann(m, TYPE_CONVERTER) == null) { + continue; + } + MethodSig s = RoomTypes.parseMethod(m.signature != null ? m.signature : m.desc); + if (s.params.size() != 1 || s.ret.isVoid() || (m.access & Opcodes.ACC_PUBLIC) == 0) { + throw new RoomError(where(cn, m) + sourceFile(cn) + ": a @TypeConverter must be public, take" + + " one parameter and return a value"); + } + Conv c = new Conv(); + c.owner = cls; + c.name = m.name; + c.isStatic = (m.access & Opcodes.ACC_STATIC) != 0; + c.from = s.params.get(0); + c.to = s.ret; + convs.add(c); + } + } + } + + String tableOf(String cls) { + Entity e = entities.get(cls); + return e == null ? null : e.table; + } + + private String source(List getters, List daoTypes) { + StringBuilder s = new StringBuilder(); + String pkg = pkg(db.name); + if (!pkg.isEmpty()) { + s.append("package ").append(pkg).append(";\n\n"); + } + String impl = simple(implName(db.name)); + s.append("/** Generated by the Codename One Room build step. */\n"); + s.append("@SuppressWarnings({\"unchecked\", \"rawtypes\", \"deprecation\"})\n"); + s.append("public final class ").append(impl).append(" extends ").append(RoomTypes.sourceName(db.name)) + .append(" {\n"); + for (int i = 0; i < getters.size(); i++) { + s.append(" private ").append(RoomTypes.sourceName(daoTypes.get(i))).append(" _dao").append(i) + .append(";\n"); + } + s.append("\n public ").append(impl).append("() {\n super();\n }\n\n"); + s.append(" @Override\n protected int roomSchemaVersion() {\n return ") + .append(intv(da, "version", 1)).append(";\n }\n\n"); + s.append(" @Override\n protected String[] roomCreateStatements() {\n return new String[] {\n"); + List tables = new ArrayList(); + for (Entity e : entities.values()) { + tables.add(e.table); + for (String d : e.ddl) { + s.append(" ").append(quote(d)).append(",\n"); + } + } + s.append(" };\n }\n\n"); + s.append(" @Override\n protected String[] roomTableNames() {\n return new String[] {"); + for (String t : tables) { + s.append(quote(t)).append(", "); + } + s.append("};\n }\n\n"); + // What a migration must leave behind, checked after it runs. + s.append(" @Override\n protected String[][] roomTableColumns() {\n return new String[][] {\n"); + for (Entity e : entities.values()) { + s.append(" {").append(quote(e.table)); + for (Leaf l : e.leaves) { + s.append(", ").append(quote(l.column)); + } + s.append("},\n"); + } + s.append(" };\n }\n\n"); + s.append(" @Override\n protected Object roomCreateTypeConverter(Class c) {\n"); + for (String cls : converterClasses) { + ClassNode cn = app.get(cls); + boolean instance = false; + for (Conv c : convs) { + instance |= c.owner.equals(cls) && !c.isStatic; + } + if (!instance || ann(cn, PROVIDED_CONVERTER) != null) { + continue; + } + boolean ctor = false; + for (MethodNode m : cn.methods) { + ctor |= m.name.equals("") && m.desc.equals("()V") && (m.access & Opcodes.ACC_PUBLIC) != 0; + } + if (!ctor) { + throw new RoomError(fqn(cls) + ": a type converter class with instance methods needs a public" + + " no-argument constructor, or @ProvidedTypeConverter and Builder.addTypeConverter"); + } + String n = RoomTypes.sourceName(cls); + s.append(" if (c == ").append(n).append(".class) {\n return new ").append(n) + .append("();\n }\n"); + } + s.append(" return null;\n }\n"); + for (int i = 0; i < getters.size(); i++) { + MethodNode m = getters.get(i); + String t = RoomTypes.sourceName(daoTypes.get(i)); + s.append("\n @Override\n public ").append(t).append(' ').append(m.name).append("() {\n") + .append(" if (_dao").append(i).append(" == null) {\n _dao").append(i) + .append(" = new ").append(fqn(implName(daoTypes.get(i)))).append("(this);\n }\n") + .append(" return _dao").append(i).append(";\n }\n"); + } + s.append("}\n"); + return s.toString(); + } + } + + // ------------------------------------------------------------ DAO + + /// A DAO method as the DAO type sees it, type variables resolved. + static final class DaoMethod { + ClassNode owner; + MethodNode m; + MethodSig sig; + boolean isAbstract; + } + + private void collectMethods(String cls, Map binding, Map out, + Set concrete) { + ClassNode n = app.get(cls); + if (n == null) { + return; + } + for (MethodNode m : n.methods) { + if (m.name.startsWith("<") || (m.access & (Opcodes.ACC_STATIC | Opcodes.ACC_SYNTHETIC + | Opcodes.ACC_BRIDGE | Opcodes.ACC_PRIVATE)) != 0) { + continue; + } + MethodSig s = RoomTypes.parseMethod(m.signature != null ? m.signature : m.desc); + MethodSig r = new MethodSig(); + r.typeParams.addAll(s.typeParams); + StringBuilder key = new StringBuilder(m.name).append('('); + for (JType p : s.params) { + JType q = p.subst(binding); + r.params.add(q); + key.append(q.desc()); + } + key.append(')'); + r.ret = s.ret.subst(binding); + for (JType x : s.exceptions) { + r.exceptions.add(x.subst(binding)); + } + if (r.exceptions.isEmpty() && m.exceptions != null) { + for (String x : m.exceptions) { + r.exceptions.add(JType.cls(x)); + } + } + String k = key.toString(); + if (concrete.contains(k) || out.containsKey(k)) { + continue; + } + boolean abs = (m.access & Opcodes.ACC_ABSTRACT) != 0; + DaoMethod dm = new DaoMethod(); + dm.owner = n; + dm.m = m; + dm.sig = r; + dm.isAbstract = abs; + if (!abs) { + concrete.add(k); + if (ann(m, TRANSACTION) == null) { + continue; + } + } + out.put(k, dm); + } + List supers = new ArrayList(); + if (n.signature != null) { + ClassSig cs = RoomTypes.parseClass(n.signature); + supers.add(cs.superclass); + supers.addAll(cs.interfaces); + } else { + if (n.superName != null) { + supers.add(JType.cls(n.superName)); + } + for (String i : n.interfaces) { + supers.add(JType.cls(i)); + } + } + for (JType st : supers) { + JType bound = st.subst(binding); + ClassNode sn = app.get(bound.name); + if (sn == null) { + continue; + } + Map b2 = new HashMap(); + if (sn.signature != null) { + List formal = RoomTypes.parseClass(sn.signature).typeParams; + for (int i = 0; i < formal.size() && i < bound.args.size(); i++) { + b2.put(formal.get(i), bound.arg(i)); + } + } + collectMethods(bound.name, b2, out, concrete); + } + } + + /// One DAO and its implementation. + private final class DaoGen { + final DbGen db; + final ClassNode dao; + final String pkg; + final StringBuilder helpers = new StringBuilder(); + final Map readers = new HashMap(); + final Map binders = new HashMap(); + int methodCount; + + DaoGen(DbGen db, ClassNode dao) { + this.db = db; + this.dao = dao; + this.pkg = pkg(dao.name); + } + + String generate() { + boolean iface = (dao.access & Opcodes.ACC_INTERFACE) != 0; + if (!iface && (dao.access & Opcodes.ACC_ABSTRACT) == 0) { + throw new RoomError(fqn(dao.name) + sourceFile(dao) + ": a @Dao must be an interface or an abstract" + + " class"); + } + if (dao.signature != null && !RoomTypes.parseClass(dao.signature).typeParams.isEmpty()) { + throw new RoomError(fqn(dao.name) + sourceFile(dao) + ": a @Dao used by a database cannot be" + + " generic; declare an interface that binds its type arguments"); + } + Map methods = new LinkedHashMap(); + collectMethods(dao.name, new HashMap(), methods, new HashSet()); + StringBuilder s = new StringBuilder(); + if (!pkg.isEmpty()) { + s.append("package ").append(pkg).append(";\n\n"); + } + String impl = simple(implName(dao.name)); + String daoSrc = RoomTypes.sourceName(dao.name); + s.append("/** Generated by the Codename One Room build step. */\n"); + s.append("@SuppressWarnings({\"unchecked\", \"rawtypes\", \"deprecation\"})\n"); + s.append("public final class ").append(impl).append(iface ? " implements " : " extends ").append(daoSrc) + .append(" {\n"); + s.append(" private final androidx.room.RoomDatabase __db;\n\n"); + s.append(" public ").append(impl).append("(androidx.room.RoomDatabase __db) {\n"); + if (!iface) { + s.append(" ").append(superCall()).append('\n'); + } + s.append(" this.__db = __db;\n }\n"); + for (DaoMethod dm : methods.values()) { + s.append(method(dm, daoSrc, iface)); + } + s.append(helpers); + s.append("}\n"); + return s.toString(); + } + + private String superCall() { + String noArg = null; + String withDb = null; + for (MethodNode m : dao.methods) { + if (!m.name.equals("") || (m.access & Opcodes.ACC_PRIVATE) != 0) { + continue; + } + Type[] a = Type.getArgumentTypes(m.desc); + if (a.length == 0) { + noArg = "super();"; + } else if (a.length == 1 && a[0].getSort() == Type.OBJECT + && isSubclassOrSuper(a[0].getInternalName())) { + withDb = "super((" + RoomTypes.sourceName(a[0].getInternalName()) + ") __db);"; + } + } + if (withDb != null) { + return withDb; + } + if (noArg != null) { + return noArg; + } + throw new RoomError(fqn(dao.name) + sourceFile(dao) + ": an abstract @Dao class needs a constructor that" + + " takes no arguments or the database"); + } + + private boolean isSubclassOrSuper(String type) { + return isSubclass(db.db.name, type); + } + + private String method(DaoMethod dm, String daoSrc, boolean iface) { + MethodNode m = dm.m; + String w = where(dm.owner, m) + sourceFile(dm.owner); + if (!dm.sig.typeParams.isEmpty()) { + throw new RoomError(w + ": generic DAO methods are not supported"); + } + List params = dm.sig.params; + if (!params.isEmpty() && params.get(params.size() - 1).isClass("kotlin/coroutines/Continuation")) { + throw new RoomError(w + ": Kotlin suspend DAO functions are not supported by the Codename One Room" + + " build; declare a blocking function and call it from a background thread"); + } + for (String[] r : REACTIVE) { + JType ret = dm.sig.ret; + if (ret.isClass(r[0]) || (ret.isClass("androidx/lifecycle/LiveData") && !ret.args.isEmpty() + && ret.arg(0).isClass(r[0]))) { + throw new RoomError(w + ": " + r[1] + " return types are not supported by the Codename One" + + " Room build; return the value, a List or LiveData"); + } + } + String[] kinds = {QUERY, RAW_QUERY, INSERT, UPDATE, DELETE, UPSERT}; + AnnotationNode found = null; + for (String k : kinds) { + AnnotationNode a = ann(m, k); + if (a != null) { + if (found != null) { + throw new RoomError(w + ": a DAO method can have only one of @Query, @RawQuery, @Insert," + + " @Update, @Delete and @Upsert"); + } + found = a; + } + } + boolean tx = ann(m, TRANSACTION) != null; + if (found == null && dm.isAbstract) { + throw new RoomError(w + ": an abstract DAO method must be annotated with @Query, @RawQuery," + + " @Insert, @Update, @Delete or @Upsert (a Kotlin interface function with a body compiles" + + " to an abstract method; use an abstract class)"); + } + StringBuilder decl = new StringBuilder(); + StringBuilder args = new StringBuilder(); + StringBuilder formals = new StringBuilder(); + for (int i = 0; i < params.size(); i++) { + if (i > 0) { + args.append(", "); + formals.append(", "); + } + formals.append("final ").append(params.get(i).src()).append(" _p").append(i); + args.append("_p").append(i); + } + String throwsClause = ""; + if (!dm.sig.exceptions.isEmpty()) { + StringBuilder t = new StringBuilder(" throws "); + for (int i = 0; i < dm.sig.exceptions.size(); i++) { + t.append(i > 0 ? ", " : "").append(dm.sig.exceptions.get(i).src()); + } + throwsClause = t.toString(); + } + JType ret = dm.sig.ret; + String retSrc = ret.src(); + boolean isVoid = ret.isVoid(); + decl.append("\n @Override\n public ").append(retSrc).append(' ').append(m.name).append('(') + .append(formals).append(')').append(throwsClause).append(" {\n"); + boolean liveData = ret.isClass("androidx/lifecycle/LiveData"); + String body; + if (found == null) { + // A concrete @Transaction method: the application's body, + // in a transaction. + String target = iface ? daoSrc + ".super." : "super."; + body = " " + (isVoid ? "" : "return ") + target + m.name + "(" + args + ");\n"; + } else if (found.desc.equals(QUERY)) { + body = query(dm, w, str(found, "value", ""), tx && liveData); + } else if (found.desc.equals(RAW_QUERY)) { + body = rawQuery(dm, w, found, tx && liveData); + } else { + body = write(dm, w, found); + } + if (!tx || liveData) { + return decl + body + " }\n"; + } + int n = methodCount++; + String helper = "__tx" + n; + helpers.append("\n private ").append(retSrc).append(' ').append(helper).append('(').append(formals) + .append(')').append(throwsClause).append(" {\n").append(body).append(" }\n"); + StringBuilder s = new StringBuilder(decl); + s.append(" __db.beginTransaction();\n try {\n"); + if (isVoid) { + s.append(" ").append(helper).append('(').append(args).append(");\n") + .append(" __db.setTransactionSuccessful();\n"); + } else { + s.append(" ").append(retSrc).append(" _r = ").append(helper).append('(').append(args) + .append(");\n __db.setTransactionSuccessful();\n return _r;\n"); + } + s.append(" } finally {\n __db.endTransaction();\n }\n }\n"); + return s.toString(); + } + + // ---------------------------------------------------- @Query + + private String query(DaoMethod dm, String w, String sql, boolean liveTx) { + List parts = parseSql(sql, w); + List names = paramNames(dm.m, dm.sig.params.size()); + Map byName = new HashMap(); + List distinct = new ArrayList(); + int positional = 0; + for (Object p : parts) { + if (p instanceof String[]) { + String n = ((String[]) p)[0]; + if (n == null) { + positional++; + } else if (!distinct.contains(n)) { + distinct.add(n); + } + } + } + if (positional > 0 && !distinct.isEmpty()) { + throw new RoomError(w + ": a query cannot mix ? and :name parameters"); + } + if (names != null) { + for (int i = 0; i < names.size(); i++) { + byName.put(names.get(i), Integer.valueOf(i)); + } + } else if (!distinct.isEmpty()) { + if (distinct.size() == dm.sig.params.size()) { + log.warn("Room: " + w + " was compiled without parameter names; binding " + distinct + + " to its parameters in order. Compile with -parameters (Kotlin: -java-parameters)" + + " so the names are kept."); + for (int i = 0; i < distinct.size(); i++) { + byName.put(distinct.get(i), Integer.valueOf(i)); + } + } else { + throw new RoomError(w + " was compiled without parameter names, so :" + distinct.get(0) + + " cannot be bound. Compile with -parameters (Kotlin: -java-parameters)"); + } + } + StringBuilder build = new StringBuilder(); + build.append("StringBuilder _sql = new StringBuilder();\n"); + build.append("java.util.ArrayList _args = new java.util.ArrayList();\n"); + int nextPositional = 0; + for (Object p : parts) { + if (p instanceof String) { + build.append("_sql.append(").append(quote((String) p)).append(");\n"); + continue; + } + String n = ((String[]) p)[0]; + int idx; + if (n == null) { + idx = nextPositional++; + if (idx >= dm.sig.params.size()) { + throw new RoomError(w + ": the query has more ? placeholders than the method has parameters"); + } + } else { + Integer i = byName.get(n); + if (i == null) { + throw new RoomError(w + ": Cannot find method parameters for :" + n); + } + idx = i.intValue(); + } + build.append(bindParam(dm.sig.params.get(idx), "_p" + idx, w)); + } + String kind = firstWord(sql); + Set tables = tables(sql); + JType ret = dm.sig.ret; + if (kind.equals("INSERT") || kind.equals("UPDATE") || kind.equals("DELETE") || kind.equals("REPLACE")) { + return writeQuery(w, kind, ret, build.toString(), tables); + } + return read(w, ret, build.toString(), "__sqlCursor", tables, liveTx); + } + + private String bindParam(JType t, String v, String w) { + boolean array = t.isArray() && !(t.dims == 1 && t.kind == 'B'); + boolean collection = false; + for (String l : LIST_TYPES) { + collection |= t.isClass(l); + } + for (String l : SET_TYPES) { + collection |= t.isClass(l); + } + if (array || collection) { + JType e = array ? t.element() : t.args.isEmpty() ? JType.cls("java/lang/Object") : t.arg(0); + if (e.isPrimitive()) { + if ("JISDF".indexOf(e.kind) < 0) { + throw new RoomError(w + ": a " + t.src() + " parameter cannot be expanded; use a List"); + } + return RT + ".appendIn(_sql, _args, " + v + ");\n"; + } + Codec c = codec(e, db.convs); + if (c == null) { + throw new RoomError(w + ": Query method parameters should either be a type that can be" + + " converted into a database column or a List / Array that contains such type (" + + t.src() + ")"); + } + String n = "_n" + (uid++); + String x = "_x" + (uid++); + return "int " + n + " = 0;\nif (" + v + " != null) {\n for (" + e.src() + " " + x + " : " + v + + ") {\n _sql.append(" + n + "++ == 0 ? \"?\" : \",?\");\n _args.add(" + + c.bind(x) + ");\n }\n}\n"; + } + Codec c = codec(t, db.convs); + if (c == null) { + throw new RoomError(w + ": Query method parameters should either be a type that can be converted" + + " into a database column or a List / Array that contains such type (" + t.src() + ")"); + } + return "_sql.append(\"?\");\n_args.add(" + c.bind(v) + ");\n"; + } + + private String writeQuery(String w, String kind, JType ret, String build, Set tables) { + char k = ret.isPrimitive() ? ret.kind : RoomTypes.unboxed(ret); + boolean insertId = kind.equals("INSERT") || kind.equals("REPLACE"); + StringBuilder s = new StringBuilder(); + s.append(indent(build, " ")); + s.append(" __db.beginTransaction();\n try {\n"); + String run; + if (insertId && (k == 'J' || ret.isVoid())) { + run = RT + ".insert(__db, _sql.toString(), _args.toArray())"; + } else if (ret.isVoid() || k == 'I') { + run = RT + ".updateDelete(__db, _sql.toString(), _args)"; + } else { + throw new RoomError(w + ": a " + kind + " query can return void, int" + (insertId ? " or long" : "")); + } + if (ret.isVoid()) { + s.append(" ").append(run).append(";\n"); + } else { + s.append(" ").append(k == 'J' ? "long" : "int").append(" _r = ").append(run).append(";\n"); + } + s.append(notify(tables, " ")); + s.append(" __db.setTransactionSuccessful();\n"); + if (!ret.isVoid()) { + s.append(" return _r;\n"); + } + s.append(" } finally {\n __db.endTransaction();\n }\n"); + return s.toString(); + } + + private String notify(Set tables, String ind) { + if (tables.isEmpty()) { + return ""; + } + StringBuilder s = new StringBuilder(ind + "__db.getInvalidationTracker().notifyTablesChanged("); + int i = 0; + for (String t : tables) { + s.append(i++ > 0 ? ", " : "").append(quote(t)); + } + return s.append(");\n").toString(); + } + + private String rawQuery(DaoMethod dm, String w, AnnotationNode a, boolean liveTx) { + if (dm.sig.params.size() != 1 || !(dm.sig.params.get(0).isClass("androidx/sqlite/db/SupportSQLiteQuery") + || dm.sig.params.get(0).isClass("androidx/sqlite/db/SimpleSQLiteQuery"))) { + throw new RoomError(w + ": a @RawQuery method takes one SupportSQLiteQuery"); + } + Set tables = new LinkedHashSet(); + for (String e : types(a, "observedEntities")) { + String t = db.tableOf(e); + if (t == null) { + throw new RoomError(w + ": observedEntities names " + fqn(e) + ", not an entity of the database"); + } + tables.add(t); + } + return read(w, dm.sig.ret, "", "__rawCursor", tables, liveTx); + } + + /// A read: build the SQL, run it, map the rows to `ret`. + private String read(String w, JType ret, String build, String cursorKind, Set tables, + boolean liveTx) { + if (ret.isClass("androidx/lifecycle/LiveData")) { + if (ret.args.isEmpty()) { + throw new RoomError(w + ": LiveData needs a type argument"); + } + if (tables.isEmpty()) { + throw new RoomError(w + ": Observable query return type (LiveData) used but the query" + + " returns no tables (cannot find any table to observe)"); + } + JType inner = ret.arg(0); + String in = inner.src(true); + StringBuilder s = new StringBuilder(); + s.append(" return __db.getInvalidationTracker().createLiveData(new String[] {"); + int i = 0; + for (String t : tables) { + s.append(i++ > 0 ? ", " : "").append(quote(t)); + } + s.append("}, ").append(liveTx).append(", new java.util.concurrent.Callable<").append(in) + .append(">() {\n @Override\n public ").append(in) + .append(" call() throws Exception {\n"); + s.append(indent(build + rows(w, inner, cursorKind), " ")); + s.append(" }\n });\n"); + return s.toString(); + } + if (ret.isVoid()) { + throw new RoomError(w + ": a SELECT query must return a value"); + } + return indent(build + rows(w, ret, cursorKind), " "); + } + + private String cursorExpr(String kind) { + return kind.equals("__rawCursor") ? "__db.query(_p0)" : RT + ".query(__db, _sql.toString(), _args)"; + } + + /// Statements that run the query and return `ret` from its rows. + private String rows(String w, JType ret, String kind) { + if (ret.isClass("android/database/Cursor")) { + return "return " + cursorExpr(kind) + ";\n"; + } + StringBuilder s = new StringBuilder(); + s.append("android.database.Cursor _c = ").append(cursorExpr(kind)).append(";\ntry {\n"); + boolean list = false; + for (String l : LIST_TYPES) { + list |= ret.isClass(l); + } + boolean set = false; + for (String l : SET_TYPES) { + set |= ret.isClass(l); + } + boolean array = ret.isArray() && !(ret.dims == 1 && ret.kind == 'B'); + if (list || set || array) { + JType e = array ? ret.element() : ret.args.isEmpty() ? null : ret.arg(0); + if (e == null) { + throw new RoomError(w + ": a raw " + ret.src() + " cannot be mapped; give it a type argument"); + } + String coll = (set ? "java.util.LinkedHashSet<" : "java.util.ArrayList<") + e.src(true) + ">"; + s.append(" ").append(coll).append(" _r = new ").append(coll).append("();\n"); + s.append(indent(rowSetup(w, e), " ")); + s.append(" while (_c.moveToNext()) {\n _r.add(").append(row(w, e)).append(");\n }\n"); + if (!array) { + s.append(" return _r;\n"); + } else if (e.isPrimitive()) { + s.append(" ").append(ret.src()).append(" _a = new ").append(e.src()).append("[_r.size()];\n") + .append(" for (int _i = 0; _i < _a.length; _i++) {\n _a[_i] = _r.get(_i);\n") + .append(" }\n return _a;\n"); + } else { + s.append(" return _r.toArray(new ").append(e.rawSrc()).append("[_r.size()]);\n"); + } + } else { + s.append(indent(rowSetup(w, ret), " ")); + s.append(" if (_c.moveToFirst()) {\n return ").append(row(w, ret)).append(";\n }\n"); + s.append(" return ").append(defaultValue(ret)).append(";\n"); + } + s.append("} finally {\n _c.close();\n}\n"); + return s.toString(); + } + + private String rowSetup(String w, JType t) { + if (codec(t, db.convs) != null) { + return ""; + } + return "int[] _ix = " + reader(w, t) + "_index(_c);\n"; + } + + private String row(String w, JType t) { + Codec c = codec(t, db.convs); + if (c != null) { + return c.read("_c", "0"); + } + return reader(w, t) + "(_c, _ix)"; + } + + /// The name of the reader of `t`'s rows, written once per DAO. + private String reader(String w, JType t) { + if (t.kind != 'L' || t.isArray() || !t.args.isEmpty()) { + throw new RoomError(w + ": Not sure how to convert a Cursor to this method's return type (" + + t.src() + ")"); + } + String name = readers.get(t.name); + if (name != null) { + return name; + } + if (app.get(t.name) == null) { + throw new RoomError(w + ": Not sure how to convert a Cursor to this method's return type (" + + t.src() + "): it is neither a column type nor an application class"); + } + Pojo p = pojo(t.name, pkg, db.convs, new HashSet()); + name = "__read_" + simple(t.name) + "_" + (uid++); + readers.put(t.name, name); + List leaves = new ArrayList(); + flatten(p, "", leaves); + StringBuilder h = new StringBuilder(); + h.append("\n private int[] ").append(name).append("_index(android.database.Cursor _c) {\n") + .append(" return new int[] {"); + for (int i = 0; i < leaves.size(); i++) { + h.append(i > 0 ? ", " : "").append(RT).append(".columnIndex(_c, ").append(quote(leaves.get(i).column)) + .append(')'); + } + h.append("};\n }\n"); + h.append("\n private ").append(p.type.src()).append(' ').append(name) + .append("(android.database.Cursor _c, int[] _ix) {\n"); + StringBuilder body = new StringBuilder(); + String v = build(body, p, new int[1]); + body.append("return ").append(v).append(";\n"); + h.append(indent(body.toString(), " ")); + h.append(" }\n"); + helpers.append(h); + return name; + } + + /// Statements creating a `p` from the row; answers its variable. + private String build(StringBuilder out, Pojo p, int[] next) { + String var = "_o" + (uid++); + Map locals = new HashMap(); + List after = new ArrayList(); + for (Prop prop : p.props) { + boolean viaCtor = p.ctorParams.contains(prop); + if (prop.embedded) { + int start = next[0]; + String ev = "_e" + (uid++); + out.append(prop.emb.type.src()).append(' ').append(ev).append(" = null;\n"); + StringBuilder cond = new StringBuilder(); + for (int k = start; k < start + prop.emb.leafCount; k++) { + cond.append(cond.length() > 0 ? " || " : "").append("(_ix[").append(k).append("] != -1 && !_c") + .append(".isNull(_ix[").append(k).append("]))"); + } + out.append("if (").append(cond.length() == 0 ? "true" : cond).append(") {\n"); + StringBuilder inner = new StringBuilder(); + String iv = build(inner, prop.emb, next); + out.append(indent(inner.toString(), " ")); + out.append(" ").append(ev).append(" = ").append(iv).append(";\n}\n"); + locals.put(prop, ev); + if (!viaCtor) { + after.add(assign(var, prop, ev)); + } + } else { + int k = next[0]++; + String read = prop.codec.read("_c", "_ix[" + k + "]"); + if (viaCtor) { + String lv = "_v" + (uid++); + out.append(prop.type.src()).append(' ').append(lv).append(" = _ix[").append(k) + .append("] == -1 ? ").append(defaultValue(prop.type)).append(" : ").append(read) + .append(";\n"); + locals.put(prop, lv); + } else { + after.add("if (_ix[" + k + "] != -1) {\n " + assign(var, prop, read) + "\n}"); + } + } + } + out.append(p.type.src()).append(' ').append(var).append(" = new ").append(p.type.src()).append('('); + for (int i = 0; i < p.ctorParams.size(); i++) { + out.append(i > 0 ? ", " : "").append(locals.get(p.ctorParams.get(i))); + } + out.append(");\n"); + for (String a : after) { + out.append(a).append('\n'); + } + return var; + } + + private String assign(String var, Prop prop, String expr) { + if (prop.setter != null) { + return var + "." + prop.setter + "(" + expr + ");"; + } + return var + "." + prop.field + " = " + expr + ";"; + } + + private String getter(String obj, Prop prop, String cls) { + if (prop.getter != null) { + return obj + "." + prop.getter + "()"; + } + if (prop.fieldReadable) { + return obj + "." + prop.field; + } + throw new RoomError(fqn(cls) + "." + prop.field + ": Cannot find getter for field (it is private and" + + " has no accessible getX()/isX() method)"); + } + + // ---------------------------------------------------- writes + + /// The binder of an entity's columns, written once per DAO. + private String binder(Entity e) { + String name = binders.get(e.cls); + if (name != null) { + return name; + } + Pojo p = pojo(e.cls, pkg, db.convs, new HashSet()); + name = "__bind_" + simple(e.cls) + "_" + (uid++); + binders.put(e.cls, name); + int auto = e.autoGenerate == null ? -1 : e.autoGenerate.index; + Set pkIdx = new HashSet(); + for (Leaf l : e.pk) { + pkIdx.add(Integer.valueOf(l.index)); + } + StringBuilder h = new StringBuilder(); + h.append("\n private Object[] ").append(name).append('(').append(p.type.src()).append(" _o) {\n") + .append(" Object[] _a = new Object[").append(e.leaves.size()).append("];\n"); + StringBuilder body = new StringBuilder(); + emitBind(body, p, "_o", new int[1], null, auto, new int[1]); + h.append(indent(body.toString(), " ")); + h.append(" return _a;\n }\n"); + h.append("\n private Object[] ").append(name).append("_pk(").append(p.type.src()).append(" _o) {\n") + .append(" Object[] _a = new Object[").append(e.pk.size()).append("];\n"); + body = new StringBuilder(); + emitBind(body, p, "_o", new int[1], pkIdx, -1, new int[1]); + h.append(indent(body.toString(), " ")); + h.append(" return _a;\n }\n"); + helpers.append(h); + return name; + } + + private void emitBind(StringBuilder out, Pojo p, String obj, int[] next, Set only, int auto, + int[] slot) { + for (Prop prop : p.props) { + if (prop.embedded) { + String ev = "_e" + (uid++); + out.append(prop.emb.type.src()).append(' ').append(ev).append(" = ") + .append(getter(obj, prop, p.cls)).append(";\n"); + out.append("if (").append(ev).append(" != null) {\n"); + StringBuilder inner = new StringBuilder(); + int[] innerSlot = {slot[0]}; + emitBind(inner, prop.emb, ev, next, only, auto, innerSlot); + out.append(indent(inner.toString(), " ")).append("}\n"); + slot[0] = innerSlot[0]; + continue; + } + int k = next[0]++; + if (only != null && !only.contains(Integer.valueOf(k))) { + continue; + } + int s = slot[0]++; + String v = "_v" + (uid++); + out.append(prop.type.src()).append(' ').append(v).append(" = ").append(getter(obj, prop, p.cls)) + .append(";\n"); + if (k == auto && prop.type.isPrimitive()) { + out.append("_a[").append(s).append("] = ").append(v).append(" == 0 ? null : (Object) ") + .append(prop.codec.bind(v)).append(";\n"); + } else { + out.append("_a[").append(s).append("] = ").append(prop.codec.bind(v)).append(";\n"); + } + } + } + + private String insertSql(Entity e, int conflict) { + String[] words = {"ABORT", "REPLACE", "ROLLBACK", "ABORT", "FAIL", "IGNORE"}; + StringBuilder s = new StringBuilder("INSERT OR " + words[conflict < 0 || conflict > 5 ? 3 : conflict] + + " INTO `" + e.table + "` ("); + StringBuilder q = new StringBuilder(); + for (int i = 0; i < e.leaves.size(); i++) { + s.append(i > 0 ? "," : "").append('`').append(e.leaves.get(i).column).append('`'); + q.append(i > 0 ? "," : "").append('?'); + } + return s.append(") VALUES (").append(q).append(')').toString(); + } + + private String pkWhere(Entity e) { + StringBuilder s = new StringBuilder(" WHERE "); + for (int i = 0; i < e.pk.size(); i++) { + s.append(i > 0 ? " AND " : "").append('`').append(e.pk.get(i).column).append("` = ?"); + } + return s.toString(); + } + + private String updateSql(Entity e, int conflict) { + String[] words = {"ABORT", "REPLACE", "ROLLBACK", "ABORT", "FAIL", "IGNORE"}; + StringBuilder s = new StringBuilder("UPDATE OR " + words[conflict < 0 || conflict > 5 ? 3 : conflict] + + " `" + e.table + "` SET "); + for (int i = 0; i < e.leaves.size(); i++) { + s.append(i > 0 ? "," : "").append('`').append(e.leaves.get(i).column).append("` = ?"); + } + return s.append(pkWhere(e)).toString(); + } + + /// @Insert, @Update, @Delete and @Upsert. + private String write(DaoMethod dm, String w, AnnotationNode a) { + String kind = a.desc.equals(INSERT) ? "insert" : a.desc.equals(UPDATE) ? "update" + : a.desc.equals(DELETE) ? "delete" : "upsert"; + String partial = typeVal(a, "entity"); + if (partial != null && !partial.equals("java/lang/Object")) { + throw new RoomError(w + ": partial entities (@" + cap(kind) + "(entity = ...)) are not supported" + + " by the Codename One Room build; pass the entity itself"); + } + int conflict = intv(a, "onConflict", 3); + List params = dm.sig.params; + if (params.isEmpty()) { + throw new RoomError(w + ": a @" + cap(kind) + " method must have at least one parameter"); + } + List ents = new ArrayList(); + List shapes = new ArrayList(); + for (JType p : params) { + int shape = 0; + JType e = p; + if (p.isArray()) { + shape = 1; + e = p.element(); + } else { + for (String l : LIST_TYPES) { + if (p.isClass(l) && !p.args.isEmpty()) { + shape = 1; + e = p.arg(0); + } + } + for (String l : SET_TYPES) { + if (p.isClass(l) && !p.args.isEmpty()) { + shape = 1; + e = p.arg(0); + } + } + } + Entity ent = e.kind == 'L' && e.dims == 0 ? db.entities.get(e.name) : null; + if (ent == null) { + throw new RoomError(w + ": Type of the parameter must be a class annotated with @Entity of this" + + " database or a collection/array of it (" + p.src() + ")"); + } + ents.add(ent); + shapes.add(Integer.valueOf(shape)); + } + JType ret = dm.sig.ret; + boolean ids = kind.equals("insert") || kind.equals("upsert"); + String retKind; + char rk = ret.isPrimitive() ? ret.kind : RoomTypes.unboxed(ret); + if (ret.isVoid()) { + retKind = "void"; + } else if (ids && params.size() == 1 && shapes.get(0).intValue() == 0 && rk == 'J') { + retKind = "id"; + } else if (ids && params.size() == 1 && shapes.get(0).intValue() == 1 + && (ret.dims == 1 && (ret.kind == 'J' || (ret.kind == 'L' && ret.name.equals("java/lang/Long"))) + || ((ret.isClass("java/util/List") || ret.isClass("java/util/Collection") + || ret.isClass("java/util/ArrayList")) && !ret.args.isEmpty() + && ret.arg(0).isClass("java/lang/Long")))) { + retKind = "ids"; + } else if (!ids && rk == 'I') { + retKind = "count"; + } else { + throw new RoomError(w + ": " + (ids ? "an @Insert or @Upsert method can return void, long (one" + + " entity) or long[] / List (a collection)" + : "an @Update or @Delete method can return void or int") + "; it returns " + ret.src()); + } + StringBuilder s = new StringBuilder(); + s.append(" __db.beginTransaction();\n try {\n"); + if (retKind.equals("id")) { + s.append(" long _id = -1;\n"); + } else if (retKind.equals("ids")) { + s.append(" java.util.ArrayList _ids = new java.util.ArrayList();\n"); + } else if (retKind.equals("count")) { + s.append(" int _n = 0;\n"); + } + Set tables = new LinkedHashSet(); + for (int i = 0; i < params.size(); i++) { + Entity e = ents.get(i); + tables.add(e.table); + String b = binder(e); + String x = "_p" + i; + String stmt; + if (kind.equals("insert")) { + String call = RT + ".insert(__db, " + quote(insertSql(e, conflict)) + ", " + b + "(%s))"; + stmt = retKind.equals("id") ? "_id = " + call + ";" : retKind.equals("ids") + ? "_ids.add(Long.valueOf(" + call + "));" : call + ";"; + } else if (kind.equals("upsert")) { + String y = "_u" + (uid++); + stmt = "long " + y + ";\ntry {\n " + y + " = " + RT + ".insert(__db, " + + quote(insertSql(e, 3)) + ", " + b + "(%s));\n} catch (RuntimeException _ex) {\n" + + " if (!" + RT + ".isUniqueViolation(_ex)) {\n throw _ex;\n }\n " + + RT + ".updateDelete(__db, " + quote(updateSql(e, 3)) + ", " + RT + ".concat(" + b + + "(%s), " + b + "_pk(%s)));\n " + y + " = -1;\n}\n" + + (retKind.equals("id") ? "_id = " + y + ";" : retKind.equals("ids") + ? "_ids.add(Long.valueOf(" + y + "));" : ""); + } else if (kind.equals("update")) { + String call = RT + ".updateDelete(__db, " + quote(updateSql(e, conflict)) + ", " + RT + ".concat(" + + b + "(%s), " + b + "_pk(%s)))"; + stmt = retKind.equals("count") ? "_n += " + call + ";" : call + ";"; + } else { + String call = RT + ".updateDelete(__db, " + quote("DELETE FROM `" + e.table + "`" + pkWhere(e)) + + ", " + b + "_pk(%s))"; + stmt = retKind.equals("count") ? "_n += " + call + ";" : call + ";"; + } + if (shapes.get(i).intValue() == 0) { + s.append(indent(stmt.replace("%s", x), " ")); + s.append('\n'); + } else { + String el = "_e" + (uid++); + JType et = JType.cls(e.cls); + s.append(" if (").append(x).append(" != null) {\n for (") + .append(et.src()).append(' ').append(el).append(" : ").append(x).append(") {\n"); + s.append(indent(stmt.replace("%s", el), " ")); + s.append("\n }\n }\n"); + } + } + s.append(notify(tables, " ")); + s.append(" __db.setTransactionSuccessful();\n"); + if (retKind.equals("id")) { + s.append(" return _id;\n"); + } else if (retKind.equals("count")) { + s.append(" return _n;\n"); + } else if (retKind.equals("ids")) { + if (ret.isArray()) { + s.append(" ").append(ret.src()).append(" _a = new ").append(ret.element().src()) + .append("[_ids.size()];\n for (int _i = 0; _i < _a.length; _i++) {\n") + .append(" _a[_i] = _ids.get(_i);\n }\n") + .append(" return _a;\n"); + } else { + s.append(" return _ids;\n"); + } + } + s.append(" } finally {\n __db.endTransaction();\n }\n"); + return s.toString(); + } + + private Set tables(String sql) { + Set out = new LinkedHashSet(); + Map known = new HashMap(); + for (Entity e : db.entities.values()) { + known.put(asciiLower(e.table), e.table); + } + int i = 0; + while (i < sql.length()) { + char c = sql.charAt(i); + if (Character.isLetter(c) || c == '_') { + int j = i; + while (j < sql.length() && (Character.isLetterOrDigit(sql.charAt(j)) || sql.charAt(j) == '_')) { + j++; + } + String t = known.get(asciiLower(sql.substring(i, j))); + if (t != null && (i == 0 || sql.charAt(i - 1) != ':')) { + out.add(t); + } + i = j; + } else if (c == '`' || c == '"' || c == '[') { + char close = c == '[' ? ']' : c; + int j = sql.indexOf(close, i + 1); + if (j < 0) { + break; + } + String t = known.get(asciiLower(sql.substring(i + 1, j))); + if (t != null) { + out.add(t); + } + i = j + 1; + } else if (c == '\'') { + int j = sql.indexOf('\'', i + 1); + i = j < 0 ? sql.length() : j + 1; + } else { + i++; + } + } + return out; + } + } + + // ------------------------------------------------------------ SQL + + /// The SQL as literal strings and parameters (`String[] {name}`, a null + /// name for `?`). + static List parseSql(String sql, String w) { + List out = new ArrayList(); + StringBuilder lit = new StringBuilder(); + int i = 0; + while (i < sql.length()) { + char c = sql.charAt(i); + if (c == '\'' || c == '"' || c == '`') { + int j = sql.indexOf(c, i + 1); + if (j < 0) { + throw new RoomError(w + ": unterminated quote in query"); + } + lit.append(sql, i, j + 1); + i = j + 1; + } else if (c == ':' && i + 1 < sql.length() + && (Character.isLetter(sql.charAt(i + 1)) || sql.charAt(i + 1) == '_') + && (i == 0 || sql.charAt(i - 1) != ':')) { + int j = i + 1; + while (j < sql.length() && (Character.isLetterOrDigit(sql.charAt(j)) || sql.charAt(j) == '_')) { + j++; + } + if (lit.length() > 0) { + out.add(lit.toString()); + lit.setLength(0); + } + out.add(new String[] {sql.substring(i + 1, j)}); + i = j; + } else if (c == '?') { + int j = i + 1; + while (j < sql.length() && Character.isDigit(sql.charAt(j))) { + j++; + } + if (j > i + 1) { + throw new RoomError(w + ": numbered ?NNN parameters are not supported; use :name"); + } + if (lit.length() > 0) { + out.add(lit.toString()); + lit.setLength(0); + } + out.add(new String[] {null}); + i = j; + } else { + lit.append(c); + i++; + } + } + if (lit.length() > 0) { + out.add(lit.toString()); + } + return out; + } + + static String firstWord(String sql) { + int i = 0; + while (i < sql.length()) { + char c = sql.charAt(i); + if (Character.isWhitespace(c) || c == '(') { + i++; + } else if (sql.startsWith("--", i)) { + int j = sql.indexOf('\n', i); + i = j < 0 ? sql.length() : j + 1; + } else if (sql.startsWith("/*", i)) { + int j = sql.indexOf("*/", i); + i = j < 0 ? sql.length() : j + 2; + } else { + break; + } + } + int j = i; + while (j < sql.length() && Character.isLetter(sql.charAt(j))) { + j++; + } + String w = sql.substring(i, j); + StringBuilder sb = new StringBuilder(); + for (int k = 0; k < w.length(); k++) { + char c = w.charAt(k); + sb.append(c >= 'a' && c <= 'z' ? (char) (c - ('a' - 'A')) : c); + } + return sb.toString(); + } + + static String defaultValue(JType t) { + if (!t.isPrimitive()) { + return "null"; + } + switch (t.kind) { + case 'Z': return "false"; + case 'C': return "(char) 0"; + case 'B': return "(byte) 0"; + case 'S': return "(short) 0"; + case 'J': return "0L"; + case 'F': return "0f"; + case 'D': return "0d"; + default: return "0"; + } + } + + static String indent(String code, String ind) { + if (code.isEmpty()) { + return code; + } + StringBuilder sb = new StringBuilder(); + for (String line : code.split("\n", -1)) { + if (line.isEmpty()) { + sb.append('\n'); + } else { + sb.append(ind).append(line).append('\n'); + } + } + // split keeps the empty piece after the last newline + sb.setLength(sb.length() - 1); + return sb.toString(); + } +} diff --git a/maven/build-engine/src/main/java/com/codename1/maven/RoomTypes.java b/maven/build-engine/src/main/java/com/codename1/maven/RoomTypes.java new file mode 100644 index 00000000000..5481222c9e9 --- /dev/null +++ b/maven/build-engine/src/main/java/com/codename1/maven/RoomTypes.java @@ -0,0 +1,395 @@ +/* + * 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.maven; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/// Java types as the Room generator reads them from class-file descriptors +/// and generic signatures, and writes them back as source. +final class RoomTypes { + + private RoomTypes() { + } + + /// A type: a primitive (`kind` is its descriptor letter), a class + /// (`kind` 'L', with type arguments) or a type variable ('T'), with array + /// dimensions and, as a type argument, a wildcard bound ('+', '-', '*'). + static final class JType { + final char kind; + final String name; + final List args; + final int dims; + final char wildcard; + + JType(char kind, String name, List args, int dims, char wildcard) { + this.kind = kind; + this.name = name; + this.args = args == null ? Collections.emptyList() : args; + this.dims = dims; + this.wildcard = wildcard; + } + + static JType cls(String internal) { + return new JType('L', internal, null, 0, (char) 0); + } + + boolean isPrimitive() { + return dims == 0 && kind != 'L' && kind != 'T'; + } + + boolean isVoid() { + return dims == 0 && kind == 'V'; + } + + boolean isClass(String internal) { + return dims == 0 && kind == 'L' && name.equals(internal); + } + + boolean isArray() { + return dims > 0; + } + + JType element() { + return new JType(kind, name, args, dims - 1, (char) 0); + } + + JType arg(int i) { + JType a = args.get(i); + return a.wildcard == 0 ? a : new JType(a.kind, a.name, a.args, a.dims, (char) 0); + } + + boolean hasTypeVariables() { + if (kind == 'T') { + return true; + } + for (JType a : args) { + if (a != null && a.hasTypeVariables()) { + return true; + } + } + return false; + } + + JType subst(Map binding) { + if (kind == 'T') { + JType b = binding.get(name); + if (b == null) { + return this; + } + return new JType(b.kind, b.name, b.args, b.dims + dims, wildcard); + } + if (args.isEmpty()) { + return this; + } + List out = new ArrayList(); + for (JType a : args) { + out.add(a == null ? null : a.subst(binding)); + } + return new JType(kind, name, out, dims, wildcard); + } + + /// The erased descriptor. + String desc() { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < dims; i++) { + sb.append('['); + } + if (kind == 'L') { + sb.append('L').append(name).append(';'); + } else if (kind == 'T') { + sb.append("Ljava/lang/Object;"); + } else { + sb.append(kind); + } + return sb.toString(); + } + + /// Java source, generics included; primitives boxed when `boxed`. + String src(boolean boxed) { + StringBuilder sb = new StringBuilder(); + if (wildcard == '*') { + return "?"; + } + if (wildcard == '+') { + sb.append("? extends "); + } else if (wildcard == '-') { + sb.append("? super "); + } + if (kind == 'L') { + sb.append(sourceName(name)); + if (!args.isEmpty()) { + sb.append('<'); + for (int i = 0; i < args.size(); i++) { + if (i > 0) { + sb.append(", "); + } + sb.append(args.get(i).src(true)); + } + sb.append('>'); + } + } else if (kind == 'T') { + sb.append(name); + } else if (dims == 0 && boxed) { + sb.append(boxName(kind)); + } else { + sb.append(primitiveName(kind)); + } + for (int i = 0; i < dims; i++) { + sb.append("[]"); + } + return sb.toString(); + } + + String src() { + return src(false); + } + + /// The source of the erased type, for class literals and casts. + String rawSrc() { + StringBuilder sb = new StringBuilder(kind == 'L' ? sourceName(name) + : kind == 'T' ? "Object" : primitiveName(kind)); + for (int i = 0; i < dims; i++) { + sb.append("[]"); + } + return sb.toString(); + } + + @Override + public boolean equals(Object o) { + return o instanceof JType && ((JType) o).src().equals(src()); + } + + @Override + public int hashCode() { + return src().hashCode(); + } + + @Override + public String toString() { + return src(); + } + } + + static String sourceName(String internal) { + return internal.replace('/', '.').replace('$', '.'); + } + + static String primitiveName(char c) { + switch (c) { + case 'Z': return "boolean"; + case 'B': return "byte"; + case 'C': return "char"; + case 'S': return "short"; + case 'I': return "int"; + case 'J': return "long"; + case 'F': return "float"; + case 'D': return "double"; + case 'V': return "void"; + default: throw new IllegalArgumentException("Not a primitive: " + c); + } + } + + static String boxName(char c) { + switch (c) { + case 'Z': return "Boolean"; + case 'B': return "Byte"; + case 'C': return "Character"; + case 'S': return "Short"; + case 'I': return "Integer"; + case 'J': return "Long"; + case 'F': return "Float"; + case 'D': return "Double"; + case 'V': return "Void"; + default: throw new IllegalArgumentException("Not a primitive: " + c); + } + } + + /// The primitive a box holds, or 0. + static char unboxed(JType t) { + if (t.dims != 0 || t.kind != 'L') { + return 0; + } + String n = t.name; + if (n.equals("java/lang/Boolean")) { + return 'Z'; + } else if (n.equals("java/lang/Byte")) { + return 'B'; + } else if (n.equals("java/lang/Character")) { + return 'C'; + } else if (n.equals("java/lang/Short")) { + return 'S'; + } else if (n.equals("java/lang/Integer")) { + return 'I'; + } else if (n.equals("java/lang/Long")) { + return 'J'; + } else if (n.equals("java/lang/Float")) { + return 'F'; + } else if (n.equals("java/lang/Double")) { + return 'D'; + } + return 0; + } + + /// A method's generic signature, or its descriptor when it has none. + static final class MethodSig { + final List typeParams = new ArrayList(); + final List params = new ArrayList(); + JType ret; + final List exceptions = new ArrayList(); + } + + /// A class signature: its type parameters, superclass and interfaces. + static final class ClassSig { + final List typeParams = new ArrayList(); + JType superclass; + final List interfaces = new ArrayList(); + } + + static MethodSig parseMethod(String sig) { + Parser p = new Parser(sig); + MethodSig m = new MethodSig(); + p.typeParams(m.typeParams); + p.expect('('); + while (p.peek() != ')') { + m.params.add(p.type()); + } + p.expect(')'); + m.ret = p.type(); + while (p.pos < sig.length() && p.peek() == '^') { + p.pos++; + m.exceptions.add(p.type()); + } + return m; + } + + static ClassSig parseClass(String sig) { + Parser p = new Parser(sig); + ClassSig c = new ClassSig(); + p.typeParams(c.typeParams); + c.superclass = p.type(); + while (p.pos < sig.length()) { + c.interfaces.add(p.type()); + } + return c; + } + + static JType parseField(String sig) { + return new Parser(sig).type(); + } + + private static final class Parser { + final String s; + int pos; + + Parser(String s) { + this.s = s; + } + + char peek() { + return s.charAt(pos); + } + + void expect(char c) { + if (s.charAt(pos) != c) { + throw new IllegalArgumentException("Bad signature " + s + " at " + pos); + } + pos++; + } + + void typeParams(List out) { + if (pos >= s.length() || peek() != '<') { + return; + } + pos++; + while (peek() != '>') { + int colon = s.indexOf(':', pos); + out.add(s.substring(pos, colon)); + pos = colon; + while (peek() == ':') { + pos++; + if (peek() != ':' && peek() != '>') { + char c = peek(); + if (c == 'L' || c == 'T' || c == '[') { + type(); + } + } + } + } + pos++; + } + + JType type() { + int dims = 0; + while (peek() == '[') { + dims++; + pos++; + } + char c = s.charAt(pos++); + if (c == 'T') { + int semi = s.indexOf(';', pos); + String n = s.substring(pos, semi); + pos = semi + 1; + return new JType('T', n, null, dims, (char) 0); + } + if (c != 'L') { + return new JType(c, null, null, dims, (char) 0); + } + StringBuilder name = new StringBuilder(); + List args = null; + while (true) { + char ch = s.charAt(pos++); + if (ch == ';') { + break; + } + if (ch == '<') { + args = new ArrayList(); + while (peek() != '>') { + char w = peek(); + if (w == '*') { + pos++; + args.add(new JType('L', "java/lang/Object", null, 0, '*')); + } else if (w == '+' || w == '-') { + pos++; + JType t = type(); + args.add(new JType(t.kind, t.name, t.args, t.dims, w)); + } else { + args.add(type()); + } + } + pos++; + } else if (ch == '.') { + // An inner class of a parameterized outer: keep the + // innermost arguments only. + name.append('$'); + args = null; + } else { + name.append(ch); + } + } + return new JType('L', name.toString(), args, dims, (char) 0); + } + } +} diff --git a/maven/build-engine/src/test/java/com/codename1/maven/AndroidRemapperTest.java b/maven/build-engine/src/test/java/com/codename1/maven/AndroidRemapperTest.java index 555cbf85646..feee963c6fe 100644 --- a/maven/build-engine/src/test/java/com/codename1/maven/AndroidRemapperTest.java +++ b/maven/build-engine/src/test/java/com/codename1/maven/AndroidRemapperTest.java @@ -492,6 +492,48 @@ public void visit(int v, int a, String name, String sig, String sup, String[] in assertTrue(r.toString(), r.contains("com/codename1/androidcompat/jdk/FileInputStream")); } + /// The Keystore surface goes to the shims, nested types included, while + /// the security classes the Codename One class library already has stay. + @Test + public void mapsKeystoreShimsAndLeavesLibraryClasses() { + assertEquals("com/codename1/androidcompat/jdk/KeyStore$SecretKeyEntry", + AndroidRemapper.map("java/security/KeyStore$SecretKeyEntry")); + assertEquals("com/codename1/androidcompat/jdk/AEADBadTagException", + AndroidRemapper.map("javax/crypto/AEADBadTagException")); + assertEquals("com/codename1/androidcompat/jdk/GCMParameterSpec", + AndroidRemapper.map("javax/crypto/spec/GCMParameterSpec")); + assertEquals("java/security/GeneralSecurityException", + AndroidRemapper.map("java/security/GeneralSecurityException")); + assertEquals("java/security/NoSuchAlgorithmException", + AndroidRemapper.map("java/security/NoSuchAlgorithmException")); + assertEquals("java/security/SecureRandom", AndroidRemapper.map("java/security/SecureRandom")); + } + + /// A shim for a class both Codename One class libraries already define + /// would replace the real one; a shim target with no source would fail at + /// link time on the device. Both are checked against the tree. + @Test + public void everyJdkShimIsMissingFromTheLibraryAndPresentInTheRuntime() { + java.io.File root = new java.io.File("../..").getAbsoluteFile(); + java.io.File cldc = new java.io.File(root, "Ports/CLDC11/src"); + java.io.File javaApi = new java.io.File(root, "vm/JavaAPI/src"); + java.io.File runtime = new java.io.File(root, "maven/android-compat/src/main/java"); + org.junit.Assume.assumeTrue(cldc.isDirectory() && javaApi.isDirectory() && runtime.isDirectory()); + for (String[] shim : AndroidRemapper.ANDROID_JDK_SHIMS) { + String outer = shim[0].indexOf('$') < 0 ? shim[0] : shim[0].substring(0, shim[0].indexOf('$')); + boolean inBoth = new java.io.File(cldc, outer + ".java").isFile() + && new java.io.File(javaApi, outer + ".java").isFile(); + // The java.io shims replace library classes on purpose (the + // library's lack the File constructors); the security ones never do. + boolean security = shim[0].startsWith("java/security/") || shim[0].startsWith("javax/crypto/"); + assertTrue(shim[0] + " exists in both class libraries", !security || !inBoth); + if (shim[1].startsWith("com/codename1/androidcompat/jdk/")) { + String target = shim[1].indexOf('$') < 0 ? shim[1] : shim[1].substring(0, shim[1].indexOf('$')); + assertTrue(shim[1] + " has no source", new java.io.File(runtime, target + ".java").isFile()); + } + } + } + /// A public class `name` extending `superName` with public constructors /// of the given descriptors, each calling the superclass's `()V`. private static byte[] classWithCtors(String name, String superName, String... descs) { @@ -510,6 +552,58 @@ private static byte[] classWithCtors(String name, String superName, String... de return cw.toByteArray(); } + @Test + public void generatesTheWorkerInstantiatorFromCompiledClasses() throws Exception { + File classes = tmp.newFolder("workerclasses"); + new File(classes, "com/x").mkdirs(); + String ctor = "(Landroid/content/Context;Landroidx/work/WorkerParameters;)V"; + Files.write(new File(classes, "com/x/Sync.class").toPath(), + classWithCtors("com/x/Sync", "androidx/work/Worker", ctor, "()V")); + Files.write(new File(classes, "com/x/Direct.class").toPath(), + classWithCtors("com/x/Direct", "androidx/work/ListenableWorker", ctor)); + Files.write(new File(classes, "com/x/Deeper.class").toPath(), + classWithCtors("com/x/Deeper", "com/x/Sync", ctor)); + Files.write(new File(classes, "com/x/Injected.class").toPath(), + classWithCtors("com/x/Injected", "androidx/work/Worker", "()V")); + Files.write(new File(classes, "com/x/NotAWorker.class").toPath(), + classWithCtors("com/x/NotAWorker", "java/lang/Object", ctor)); + File jar = tmp.newFile("codenameone-android-compat-work.jar"); + ZipOutputStream z = new ZipOutputStream(new FileOutputStream(jar)); + int pub = Opcodes.ACC_PUBLIC | Opcodes.ACC_SUPER; + for (String[] c : new String[][] {{"androidx/work/ListenableWorker", "java/lang/Object"}, + {"androidx/work/Worker", "androidx/work/ListenableWorker"}, {"android/content/Context", "java/lang/Object"}, + {"androidx/work/WorkerParameters", "java/lang/Object"}}) { + z.putNextEntry(new ZipEntry(c[0] + ".class")); + z.write(classWithCtor(c[0], c[1], pub, Opcodes.ACC_PUBLIC)); + z.closeEntry(); + } + z.close(); + + new AndroidRemapper(classes, jar, null, LOG).run(); + + java.net.URLClassLoader loader = new java.net.URLClassLoader(new java.net.URL[] {classes.toURI().toURL()}, + null); + try { + Class inst = loader.loadClass(AndroidRemapper.WORKER_INSTANTIATOR.replace('/', '.')); + java.lang.reflect.Method m = inst.getMethod("create", String.class, Object.class, Object.class); + Object context = loader.loadClass("com.codename1.androidcompat.android.content.Context").newInstance(); + Object params = loader.loadClass("com.codename1.androidcompat.androidx.work.WorkerParameters") + .newInstance(); + for (String name : new String[] {"com.x.Sync", "com.x.Direct", "com.x.Deeper"}) { + Object o = m.invoke(null, name, context, params); + assertTrue(name, o != null && o.getClass().getName().equals(name)); + } + // Wrong arguments are refused rather than cast. + assertEquals(null, m.invoke(null, "com.x.Sync", params, context)); + assertEquals(null, m.invoke(null, "com.x.Sync", null, params)); + for (String name : new String[] {"com.x.Injected", "com.x.NotAWorker", "nope"}) { + assertEquals(name, null, m.invoke(null, name, context, params)); + } + } finally { + loader.close(); + } + } + @Test public void generatesTheViewModelFactoryFromCompiledClasses() throws Exception { File classes = tmp.newFolder("vmclasses"); diff --git a/maven/build-engine/src/test/java/com/codename1/maven/IosLocationHintsTest.java b/maven/build-engine/src/test/java/com/codename1/maven/IosLocationHintsTest.java new file mode 100644 index 00000000000..e655edb7763 --- /dev/null +++ b/maven/build-engine/src/test/java/com/codename1/maven/IosLocationHintsTest.java @@ -0,0 +1,107 @@ +/* + * 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.maven; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.io.File; +import java.nio.file.Files; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class IosLocationHintsTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private File settings(String text) throws Exception { + File common = tmp.newFolder("common"); + File android = new File(common, "src/main/android"); + assertTrue(android.mkdirs()); + File f = new File(common, "codenameone_settings.properties"); + Files.write(f.toPath(), text.getBytes("ISO-8859-1")); + assertEquals(f.getCanonicalFile(), IosLocationHints.settingsFor(android).getCanonicalFile()); + return f; + } + + private static String read(File f) throws Exception { + return new String(Files.readAllBytes(f.toPath()), "ISO-8859-1"); + } + + @Test + public void foregroundLocationAddsOnlyTheWhenInUseDescription() throws Exception { + File f = settings("# keep me\ncodename1.mainName=Main\n"); + List changed = IosLocationHints.apply(f, + Arrays.asList("android.permission.ACCESS_FINE_LOCATION"), null, null); + assertEquals(Collections.singletonList(IosLocationHints.WHEN_IN_USE), changed); + String text = read(f); + assertTrue(text.startsWith("# keep me\ncodename1.mainName=Main\n")); + assertTrue(text.contains(IosLocationHints.WHEN_IN_USE + "=")); + assertFalse(text.contains("background_modes")); + assertTrue("a second run changes nothing", + IosLocationHints.apply(f, Arrays.asList("android.permission.ACCESS_FINE_LOCATION"), null, null) + .isEmpty()); + } + + @Test + public void backgroundLocationExtendsBackgroundModesAndKeepsTheDevelopersText() throws Exception { + File f = settings("codename1.arg.ios.NSLocationWhenInUseUsageDescription=Mine\r\n" + + "codename1.arg.ios.background_modes=fetch\r\n"); + IosLocationHints.apply(f, Arrays.asList("android.permission.ACCESS_COARSE_LOCATION", + "android.permission.ACCESS_BACKGROUND_LOCATION"), null, null); + String text = read(f); + assertTrue(text.contains("NSLocationWhenInUseUsageDescription=Mine\r\n")); + assertEquals(1, text.split("NSLocationWhenInUseUsageDescription", -1).length - 1); + assertTrue(text.contains("codename1.arg.ios.background_modes=fetch,location\r\n")); + assertTrue(text.contains(IosLocationHints.ALWAYS + "=")); + assertTrue(text.contains(IosLocationHints.ALWAYS_AND_WHEN_IN_USE + "=")); + assertFalse("line endings are kept", text.replace("\r\n", "").contains("\n")); + } + + @Test + public void anAnnotationDeclarationIsNotDuplicated() throws Exception { + File f = settings(""); + File src = tmp.newFolder("src"); + Files.write(new File(src, "Main.java").toPath(), ("@IosPrivacy(locationWhenInUseUsageDescription = \"x\")\n" + + "public class Main {}\n").getBytes("UTF-8")); + IosLocationHints.apply(f, Arrays.asList("android.permission.FOREGROUND_SERVICE_LOCATION"), + Collections.singletonList(src), null); + String text = read(f); + assertFalse(text.contains(IosLocationHints.WHEN_IN_USE)); + assertTrue(text.contains(IosLocationHints.BACKGROUND_MODES + "=location")); + } + + @Test + public void noLocationPermissionLeavesTheFileAlone() throws Exception { + File f = settings("a=b\n"); + assertTrue(IosLocationHints.apply(f, Arrays.asList("android.permission.INTERNET"), null, null).isEmpty()); + assertEquals("a=b\n", read(f)); + } +} diff --git a/maven/build-engine/src/test/java/com/codename1/maven/RoomGeneratorTest.java b/maven/build-engine/src/test/java/com/codename1/maven/RoomGeneratorTest.java new file mode 100644 index 00000000000..867eaf8b9a5 --- /dev/null +++ b/maven/build-engine/src/test/java/com/codename1/maven/RoomGeneratorTest.java @@ -0,0 +1,469 @@ +/* + * 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.maven; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +import com.codename1.builders.BuildException; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import javax.tools.JavaCompiler; +import javax.tools.ToolProvider; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/// A small Room application compiled against the real compatibility +/// runtime, put through the remap step (which runs the Room generator) and +/// then run on the JVM from the relocated classes alone. +public class RoomGeneratorTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private final List warnings = new ArrayList(); + + private final com.codename1.build.Log log = new com.codename1.build.Log() { + public void debug(CharSequence c) { } + public void debug(CharSequence c, Throwable e) { } + public void debug(Throwable e) { } + public void info(CharSequence c) { } + public void info(CharSequence c, Throwable e) { } + public void info(Throwable e) { } + public void warn(CharSequence c) { + warnings.add(c.toString()); + } + public void warn(CharSequence c, Throwable e) { } + public void warn(Throwable e) { } + public void error(CharSequence c) { } + public void error(CharSequence c, Throwable e) { } + public void error(Throwable e) { } + public boolean isDebugEnabled() { return false; } + public boolean isInfoEnabled() { return false; } + public boolean isWarnEnabled() { return false; } + public boolean isErrorEnabled() { return false; } + }; + + private static Map appSources() { + Map s = new LinkedHashMap(); + s.put("app/Priority.java", "package app;\npublic enum Priority { LOW, HIGH }\n"); + s.put("app/Meta.java", "package app;\n" + + "public class Meta {\n" + + " public String author;\n" + + " public Integer rev;\n" + + "}\n"); + s.put("app/Converters.java", "package app;\n" + + "import androidx.room.TypeConverter;\n" + + "public class Converters {\n" + + " @TypeConverter public static Long fromDate(java.util.Date d) {\n" + + " return d == null ? null : Long.valueOf(d.getTime());\n" + + " }\n" + + " @TypeConverter public static java.util.Date toDate(Long v) {\n" + + " return v == null ? null : new java.util.Date(v.longValue());\n" + + " }\n" + + "}\n"); + s.put("app/Note.java", "package app;\n" + + "import androidx.room.*;\n" + + "@Entity(tableName = \"notes\", indices = {@Index(value = {\"title\"}, unique = true)})\n" + + "public class Note {\n" + + " @PrimaryKey(autoGenerate = true) private long id;\n" + + " @ColumnInfo(name = \"title\") private String mTitle;\n" + + " private boolean done;\n" + + " public Priority priority;\n" + + " public java.util.Date created;\n" + + " @Embedded(prefix = \"meta_\") public Meta meta;\n" + + " @Ignore public String scratch;\n" + + " public Note() {}\n" + + " @Ignore public Note(String t) { mTitle = t; priority = Priority.LOW; }\n" + + " public long getId() { return id; }\n" + + " public void setId(long id) { this.id = id; }\n" + + " public String getTitle() { return mTitle; }\n" + + " public void setTitle(String t) { mTitle = t; }\n" + + " public boolean isDone() { return done; }\n" + + " public void setDone(boolean d) { done = d; }\n" + + "}\n"); + s.put("app/TitleCount.java", "package app;\n" + + "public final class TitleCount {\n" + + " public final String title;\n" + + " public final int n;\n" + + " public TitleCount(String title, int n) { this.title = title; this.n = n; }\n" + + "}\n"); + s.put("app/BaseDao.java", "package app;\n" + + "import androidx.room.*;\n" + + "public interface BaseDao {\n" + + " @Insert long insert(T t);\n" + + " @Update int update(T t);\n" + + " @Delete void delete(T t);\n" + + "}\n"); + s.put("app/NoteDao.java", "package app;\n" + + "import androidx.room.*;\n" + + "import androidx.lifecycle.LiveData;\n" + + "import java.util.List;\n" + + "@Dao\n" + + "public interface NoteDao extends BaseDao {\n" + + " @Insert(onConflict = OnConflictStrategy.IGNORE) long[] insertAll(List notes);\n" + + " @Upsert long upsert(Note n);\n" + + " @Query(\"SELECT * FROM notes WHERE id = :id\") Note byId(long id);\n" + + " @Query(\"SELECT * FROM notes WHERE id IN (:ids) ORDER BY id\") List byIds(List ids);\n" + + " @Query(\"SELECT * FROM notes WHERE priority = :p ORDER BY id\") List byPriority(Priority p);\n" + + " @Query(\"SELECT title FROM notes ORDER BY title\") List titles();\n" + + " @Query(\"SELECT COUNT(*) FROM notes WHERE done = :done\") int count(boolean done);\n" + + " @Query(\"SELECT title, COUNT(*) AS n FROM notes GROUP BY title ORDER BY title\")" + + " List counts();\n" + + " @Query(\"SELECT * FROM notes ORDER BY id\") LiveData> live();\n" + + " @Query(\"UPDATE notes SET done = 1 WHERE title = :title\") int markDone(String title);\n" + + " @Query(\"DELETE FROM notes\") void clear();\n" + + " @RawQuery(observedEntities = Note.class) List raw(androidx.sqlite.db.SupportSQLiteQuery q);\n" + + "}\n"); + s.put("app/BulkDao.java", "package app;\n" + + "import androidx.room.*;\n" + + "@Dao\n" + + "public abstract class BulkDao {\n" + + " @Insert public abstract void insert(Note... notes);\n" + + " @Query(\"DELETE FROM notes\") public abstract int clear();\n" + + " @Transaction public void replaceAll(Note... notes) {\n" + + " clear();\n" + + " insert(notes);\n" + + " }\n" + + "}\n"); + s.put("app/AppDb.java", "package app;\n" + + "import androidx.room.*;\n" + + "@Database(entities = {Note.class}, version = 1)\n" + + "@TypeConverters({Converters.class})\n" + + "public abstract class AppDb extends RoomDatabase {\n" + + " public abstract NoteDao notes();\n" + + " public abstract BulkDao bulk();\n" + + "}\n"); + s.put("app/AppDb2.java", "package app;\n" + + "import androidx.room.*;\n" + + "@Database(entities = {Note.class}, version = 2)\n" + + "@TypeConverters({Converters.class})\n" + + "public abstract class AppDb2 extends RoomDatabase {\n" + + " public abstract NoteDao notes();\n" + + "}\n"); + s.put("android/database/sqlite/RoomDriver.java", DRIVER); + return s; + } + + /// Runs inside the relocated application; in this package for the + /// JDBC-backed context, as the runtime's own tests are. + private static final String DRIVER = "package android.database.sqlite;\n" + + "import android.content.Context;\n" + + "import android.content.ContextWrapper;\n" + + "import android.database.DatabaseErrorHandler;\n" + + "import androidx.lifecycle.Observer;\n" + + "import androidx.room.Room;\n" + + "import androidx.room.migration.Migration;\n" + + "import androidx.sqlite.db.SimpleSQLiteQuery;\n" + + "import androidx.sqlite.db.SupportSQLiteDatabase;\n" + + "import app.*;\n" + + "import java.util.ArrayList;\n" + + "import java.util.Arrays;\n" + + "import java.util.List;\n" + + "public final class RoomDriver {\n" + + " static void check(boolean b, String what) {\n" + + " if (!b) { throw new AssertionError(what); }\n" + + " }\n" + + " static void eq(Object a, Object b, String what) {\n" + + " check(a == null ? b == null : a.equals(b), what + \": expected \" + a + \" got \" + b);\n" + + " }\n" + + " static Context context(final String dir) {\n" + + " return new ContextWrapper(null) {\n" + + " @Override\n" + + " public SQLiteDatabase openOrCreateDatabase(String name, int mode,\n" + + " SQLiteDatabase.CursorFactory factory, DatabaseErrorHandler eh) {\n" + + " try {\n" + + " return SQLiteDatabase.wrap(new JdbcDatabase(\"jdbc:sqlite:\" + dir + \"/\" + name),\n" + + " name, factory, eh);\n" + + " } catch (java.io.IOException e) {\n" + + " throw new SQLiteException(e.getMessage());\n" + + " }\n" + + " }\n" + + " };\n" + + " }\n" + + " static Note note(String t, Priority p) {\n" + + " Note n = new Note(t);\n" + + " n.priority = p;\n" + + " return n;\n" + + " }\n" + + " public static String run(String dir) {\n" + + " Context ctx = context(dir);\n" + // Live queries run on the query executor, as in AndroidX; a direct + // one keeps every value in order on this thread. + + " AppDb db = Room.databaseBuilder(ctx, AppDb.class, \"app.db\").allowMainThreadQueries()\n" + + " .setQueryExecutor(new java.util.concurrent.Executor() {\n" + + " public void execute(Runnable r) { r.run(); }\n" + + " }).build();\n" + + " NoteDao dao = db.notes();\n" + + " check(dao == db.notes(), \"dao is cached\");\n" + + " final List sizes = new ArrayList();\n" + + " Observer> obs = new Observer>() {\n" + + " public void onChanged(List v) { sizes.add(Integer.valueOf(v.size())); }\n" + + " };\n" + + " dao.live().observeForever(obs);\n" + + " Note a = note(\"a\", Priority.HIGH);\n" + + " a.meta = new Meta();\n" + + " a.meta.author = \"ann\";\n" + + " a.meta.rev = 3;\n" + + " a.created = new java.util.Date(1234L);\n" + + " long id = dao.insert(a);\n" + + " eq(Long.valueOf(1), Long.valueOf(id), \"first id\");\n" + + " long[] ids = dao.insertAll(Arrays.asList(note(\"b\", Priority.LOW), note(\"a\", Priority.LOW)));\n" + + " eq(Long.valueOf(2), Long.valueOf(ids[0]), \"second id\");\n" + + " eq(Long.valueOf(-1), Long.valueOf(ids[1]), \"ignored duplicate\");\n" + + " Note back = dao.byId(1);\n" + + " eq(\"a\", back.getTitle(), \"title\");\n" + + " eq(Priority.HIGH, back.priority, \"enum\");\n" + + " eq(Long.valueOf(1234L), Long.valueOf(back.created.getTime()), \"converter\");\n" + + " eq(\"ann\", back.meta.author, \"embedded\");\n" + + " eq(Integer.valueOf(3), back.meta.rev, \"embedded int\");\n" + + " check(dao.byId(2).meta == null, \"all-null embedded stays null\");\n" + + " check(dao.byId(99) == null, \"missing row\");\n" + + " eq(Arrays.asList(\"a\", \"b\"), dao.titles(), \"titles\");\n" + + " eq(Integer.valueOf(2), Integer.valueOf(dao.byIds(Arrays.asList(Long.valueOf(1), Long.valueOf(2),\n" + + " Long.valueOf(7))).size()), \"IN list\");\n" + + " eq(\"b\", dao.byPriority(Priority.LOW).get(0).getTitle(), \"enum parameter\");\n" + + " eq(Integer.valueOf(1), Integer.valueOf(dao.markDone(\"b\")), \"write query\");\n" + + " eq(Integer.valueOf(1), Integer.valueOf(dao.count(true)), \"boolean parameter\");\n" + + " check(dao.byId(2).isDone(), \"boolean column\");\n" + + " back.setTitle(\"c\");\n" + + " eq(Integer.valueOf(1), Integer.valueOf(dao.update(back)), \"update\");\n" + + " eq(\"c\", dao.counts().get(1).title, \"constructor pojo\");\n" + + " Note up = dao.byId(2);\n" + + " up.setTitle(\"bb\");\n" + + " eq(Long.valueOf(-1), Long.valueOf(dao.upsert(up)), \"upsert updates\");\n" + + " long dId = dao.upsert(note(\"d\", Priority.LOW));\n" + + " eq(\"d\", dao.byId(dId).getTitle(), \"upsert inserts\");\n" + + " eq(Integer.valueOf(3), Integer.valueOf(dao.raw(new SimpleSQLiteQuery(\"SELECT * FROM notes\")).size()), \"raw\");\n" + + " dao.delete(dao.byId(dId));\n" + + " eq(Arrays.asList(\"bb\", \"c\"), dao.titles(), \"delete\");\n" + + " db.bulk().replaceAll(note(\"x\", Priority.LOW), note(\"y\", Priority.LOW), note(\"z\", Priority.LOW));\n" + + " eq(Arrays.asList(\"x\", \"y\", \"z\"), dao.titles(), \"transaction\");\n" + + " dao.live().removeObserver(obs);\n" + + " String live = sizes.toString();\n" + + " db.close();\n" + + " final List ran = new ArrayList();\n" + + " AppDb2 db2 = Room.databaseBuilder(ctx, AppDb2.class, \"app.db\").allowMainThreadQueries()\n" + + " .addMigrations(new Migration(1, 2) {\n" + + " public void migrate(SupportSQLiteDatabase d) { ran.add(\"1-2\"); }\n" + + " }).build();\n" + + " eq(Arrays.asList(\"x\", \"y\", \"z\"), db2.notes().titles(), \"migrated data\");\n" + + " eq(Arrays.asList(\"1-2\"), ran, \"migration ran\");\n" + + " db2.close();\n" + + " return live;\n" + + " }\n" + + "}\n"; + + @Test + public void generatedRoomAppRunsFromRelocatedClasses() throws Exception { + File classes = tmp.newFolder("classes"); + Map sources = appSources(); + File jdbc = new File("../android-compat/src/test/java/android/database/sqlite/JdbcDatabase.java"); + assertTrue("android-compat test sources are needed: " + jdbc.getAbsolutePath(), jdbc.isFile()); + sources.put("android/database/sqlite/JdbcDatabase.java", + new String(Files.readAllBytes(jdbc.toPath()), StandardCharsets.UTF_8)); + File jar = compatJar(); + compile(sources, classes, jar); + new AndroidRemapper(classes, jar, null, log).run(); + assertTrue(new File(classes, "app/AppDb_Impl.class").isFile()); + assertTrue(new File(classes, "app/NoteDao_Impl.class").isFile()); + URLClassLoader loader = isolatedLoader(classes); + try { + Class driver = loader.loadClass(AndroidRemapper.TARGET.replace('/', '.') + + "android.database.sqlite.RoomDriver"); + Method run = driver.getMethod("run", String.class); + String live; + try { + live = (String) run.invoke(null, tmp.newFolder("db").getAbsolutePath()); + } catch (InvocationTargetException e) { + throw new AssertionError(e.getCause().toString(), e.getCause()); + } + // One value on registration, one per write outside the + // transaction (the IGNOREd insert still notifies), one for it. + assertTrue(live, live.startsWith("[0, 1, ")); + assertTrue(live, live.endsWith(", 3]")); + } finally { + loader.close(); + } + } + + @Test + public void suspendFunctionFailsTheBuildNamingIt() throws Exception { + assertRefused("app/KDao.java", "package app;\n" + + "import androidx.room.*;\n" + + "@Dao public interface KDao {\n" + + " @Query(\"SELECT * FROM notes\") Object all(kotlin.coroutines.Continuation> c);\n" + + "}\n", "app.KDao.all()", "suspend"); + } + + @Test + public void relationFailsTheBuildNamingIt() throws Exception { + assertRefused("app/WithRel.java", "package app;\n" + + "import androidx.room.*;\n" + + "public class WithRel {\n" + + " @Embedded public Note note;\n" + + " @Relation(parentColumn = \"id\", entityColumn = \"id\") public java.util.List others;\n" + + "}\n", "app.WithRel.others", "@Relation"); + } + + private void assertRefused(String name, String src, String where, String what) throws Exception { + File classes = tmp.newFolder(); + Map sources = appSources(); + sources.remove("android/database/sqlite/RoomDriver.java"); + sources.put(name, src); + if (name.contains("KDao")) { + sources.put("kotlin/coroutines/Continuation.java", + "package kotlin.coroutines;\npublic interface Continuation {}\n"); + sources.put("app/AppDb.java", sources.get("app/AppDb.java").replace("public abstract BulkDao bulk();", + "public abstract BulkDao bulk();\n public abstract KDao k();")); + } else { + sources.put("app/RelDao.java", "package app;\nimport androidx.room.*;\n@Dao public interface RelDao {\n" + + " @Query(\"SELECT * FROM notes\") java.util.List all();\n}\n"); + sources.put("app/AppDb.java", sources.get("app/AppDb.java").replace("public abstract BulkDao bulk();", + "public abstract BulkDao bulk();\n public abstract RelDao rel();")); + } + File jar = compatJar(); + compile(sources, classes, jar); + try { + new AndroidRemapper(classes, jar, null, log).run(); + fail("expected the build to fail"); + } catch (BuildException expected) { + String m = expected.getMessage(); + assertTrue(m, m.contains(where)); + assertTrue(m, m.contains(what)); + } + } + + /// The runtime jar on the test class path, zipped when the reactor hands + /// over its classes directory. + private File compatJar() throws IOException { + File f; + try { + f = new File(Class.forName("androidx.room.Room").getProtectionDomain().getCodeSource().getLocation() + .toURI()); + } catch (Exception e) { + throw new IOException(e); + } + if (f.isFile()) { + return f; + } + File out = tmp.newFile("android-compat.jar"); + ZipOutputStream zip = new ZipOutputStream(new FileOutputStream(out)); + try { + List all = new ArrayList(); + collect(f, all); + String root = f.getAbsolutePath() + File.separator; + for (File c : all) { + zip.putNextEntry(new ZipEntry(c.getAbsolutePath().substring(root.length()) + .replace(File.separatorChar, '/'))); + zip.write(Files.readAllBytes(c.toPath())); + zip.closeEntry(); + } + } finally { + zip.close(); + } + return out; + } + + private static void collect(File dir, List out) { + File[] files = dir.listFiles(); + if (files != null) { + for (File f : files) { + if (f.isDirectory()) { + collect(f, out); + } else { + out.add(f); + } + } + } + } + + /// Compiles against the runtime and the core only, the way an + /// application's Android sources are, with parameter names kept. + private void compile(Map sources, File out, File jar) throws Exception { + File src = tmp.newFolder(); + List args = new ArrayList(Arrays.asList("-source", "1.8", "-target", "1.8", "-parameters", + "-nowarn", "-encoding", "UTF-8", "-d", out.getAbsolutePath(), "-cp", + jar.getAbsolutePath() + File.pathSeparator + coreJar())); + for (Map.Entry e : sources.entrySet()) { + File f = new File(src, e.getKey()); + f.getParentFile().mkdirs(); + Files.write(f.toPath(), e.getValue().getBytes(StandardCharsets.UTF_8)); + args.add(f.getAbsolutePath()); + } + JavaCompiler javac = ToolProvider.getSystemJavaCompiler(); + java.io.ByteArrayOutputStream err = new java.io.ByteArrayOutputStream(); + int rc = javac.run(null, null, err, args.toArray(new String[0])); + assertEquals(err.toString("UTF-8"), 0, rc); + } + + private static String coreJar() throws Exception { + return new File(com.codename1.db.Database.class.getProtectionDomain().getCodeSource().getLocation().toURI()) + .getAbsolutePath(); + } + + /// The relocated application with a Codename One of its own. Other tests + /// in this JVM install a headless Display, whose EDT would then deliver + /// the invalidations on another thread; the driver runs as an + /// application with no display does, where the main thread is its own. + private URLClassLoader isolatedLoader(File classes) throws Exception { + URL core = com.codename1.ui.Display.class.getProtectionDomain().getCodeSource().getLocation(); + return new URLClassLoader(new URL[] {classes.toURI().toURL(), core}, getClass().getClassLoader()) { + @Override + protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { + if (!name.startsWith("com.codename1.")) { + return super.loadClass(name, resolve); + } + synchronized (getClassLoadingLock(name)) { + Class c = findLoadedClass(name); + if (c == null) { + c = findClass(name); + } + if (resolve) { + resolveClass(c); + } + return c; + } + } + }; + } +} diff --git a/maven/compat-testing/src/main/java/com/codename1/compat/testing/HeadlessAes.java b/maven/compat-testing/src/main/java/com/codename1/compat/testing/HeadlessAes.java new file mode 100644 index 00000000000..7a3314051bf --- /dev/null +++ b/maven/compat-testing/src/main/java/com/codename1/compat/testing/HeadlessAes.java @@ -0,0 +1,51 @@ +/* + * 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.compat.testing; + +/// [HeadlessImplementation]'s AES, through the host JDK, kept in a file of +/// its own so the CodeQL exclusion for it covers nothing else. +final class HeadlessAes { + + private HeadlessAes() { + } + + static byte[] run(int mode, String transformation, byte[] key, byte[] iv, byte[] aad, byte[] data) { + try { + javax.crypto.Cipher c = javax.crypto.Cipher.getInstance(transformation); + javax.crypto.spec.SecretKeySpec k = new javax.crypto.spec.SecretKeySpec(key, "AES"); + if (iv == null) { + c.init(mode, k); + } else if (transformation.indexOf("GCM") >= 0) { + c.init(mode, k, new javax.crypto.spec.GCMParameterSpec(128, iv)); + } else { + c.init(mode, k, new javax.crypto.spec.IvParameterSpec(iv)); + } + if (aad != null) { + c.updateAAD(aad); + } + return c.doFinal(data); + } catch (java.security.GeneralSecurityException e) { + throw new RuntimeException(e.getMessage(), e); + } + } +} diff --git a/maven/compat-testing/src/main/java/com/codename1/compat/testing/HeadlessImplementation.java b/maven/compat-testing/src/main/java/com/codename1/compat/testing/HeadlessImplementation.java index 632734cd736..622de8090f9 100644 --- a/maven/compat-testing/src/main/java/com/codename1/compat/testing/HeadlessImplementation.java +++ b/maven/compat-testing/src/main/java/com/codename1/compat/testing/HeadlessImplementation.java @@ -1400,28 +1400,47 @@ public java.lang.String[] getHeaderFields(java.lang.String a0, java.lang.Object return null; } + /// Storage held in memory for the life of the test JVM, so code that + /// persists through com.codename1.io.Storage (the AndroidKeyStore) can be + /// read back by a fresh instance. + public static final java.util.Map STORAGE = + java.util.Collections.synchronizedMap(new java.util.TreeMap()); + @Override public void deleteStorageFile(java.lang.String a0) { + STORAGE.remove(a0); } @Override - public java.io.OutputStream createStorageOutputStream(java.lang.String a0) throws java.io.IOException { - return null; + public java.io.OutputStream createStorageOutputStream(final java.lang.String a0) throws java.io.IOException { + return new java.io.ByteArrayOutputStream() { + @Override + public void close() throws java.io.IOException { + super.close(); + STORAGE.put(a0, toByteArray()); + } + }; } @Override public java.io.InputStream createStorageInputStream(java.lang.String a0) throws java.io.IOException { - return null; + byte[] b = STORAGE.get(a0); + if (b == null) { + throw new java.io.IOException("No storage entry " + a0); + } + return new java.io.ByteArrayInputStream(b); } @Override public boolean storageFileExists(java.lang.String a0) { - return false; + return STORAGE.containsKey(a0); } @Override public java.lang.String[] listStorageEntries() { - return null; + synchronized (STORAGE) { + return STORAGE.keySet().toArray(new String[0]); + } } @Override @@ -1519,4 +1538,55 @@ public java.lang.String getPlatformName() { public com.codename1.l10n.L10NManager getLocalizationManager() { return L10N; } + + /// The quiet half of SecureStorage, held in memory, for the tests that + /// install it with [#SECURE_STORAGE_ON]; otherwise the port has none, as on + /// a platform without a secure store. + public static final java.util.Map SECURE = java.util.Collections.synchronizedMap( + new java.util.TreeMap()); + + /// Whether [#getSecureStorage()] answers the in-memory store. + public static final java.util.concurrent.atomic.AtomicBoolean SECURE_STORAGE_ON = + new java.util.concurrent.atomic.AtomicBoolean(); + + private static final com.codename1.security.SecureStorage QUIET = new com.codename1.security.SecureStorage() { + @Override + public boolean set(String account, String value) { + SECURE.put(account, value); + return true; + } + + @Override + public String get(String account) { + return SECURE.get(account); + } + + @Override + public boolean remove(String account) { + return SECURE.remove(account) != null; + } + }; + + @Override + public com.codename1.security.SecureStorage getSecureStorage() { + return SECURE_STORAGE_ON.get() ? QUIET : null; + } + + /// AES through the host JDK, the same contract the ports implement. + @Override + public byte[] aesEncrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] plaintext) { + return HeadlessAes.run(javax.crypto.Cipher.ENCRYPT_MODE, transformation, key, iv, aad, plaintext); + } + + @Override + public byte[] aesDecrypt(String transformation, byte[] key, byte[] iv, byte[] aad, byte[] ciphertext) { + return HeadlessAes.run(javax.crypto.Cipher.DECRYPT_MODE, transformation, key, iv, aad, ciphertext); + } + + private static final java.security.SecureRandom RANDOM = new java.security.SecureRandom(); + + @Override + public void secureRandomBytes(byte[] out) { + RANDOM.nextBytes(out); + } } diff --git a/maven/core-unittests/src/test/java/com/codename1/location/LocationExtrasTest.java b/maven/core-unittests/src/test/java/com/codename1/location/LocationExtrasTest.java index a652f23536b..a92f1544a8e 100644 --- a/maven/core-unittests/src/test/java/com/codename1/location/LocationExtrasTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/location/LocationExtrasTest.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.location; import com.codename1.junit.FormTest; @@ -7,6 +29,15 @@ class LocationExtrasTest extends UITestBase { + @FormTest + void testAccuracyIsStoredAndNaNIsIgnored() { + Location loc = new Location(0, 0); + loc.setAccuracy(12.5f); + assertEquals(12.5f, loc.getAccuracy()); + loc.setAccuracy(Float.NaN); + assertEquals(12.5f, loc.getAccuracy()); + } + @FormTest void testGeofenceComparator() { Location loc = new Location(0, 0); diff --git a/scripts/copyright-header-exclusions.txt b/scripts/copyright-header-exclusions.txt index 9657f9b2f3f..eb8430e815a 100644 --- a/scripts/copyright-header-exclusions.txt +++ b/scripts/copyright-header-exclusions.txt @@ -248,6 +248,7 @@ vm/JavaAPI/src/java/text/Format.java | Codename One contribution whose copyright vm/JavaAPI/src/java/text/ParseException.java | Codename One contribution whose copyright line names its contributor; the notice is kept as written vm/JavaAPI/src/java/text/SimpleDateFormat.java | Codename One contribution whose copyright line names its contributor; the notice is kept as written maven/build-engine/src/main/java/com/codename1/builders/util/JSONSanitizer.java | Adapted from the OWASP JSON Sanitizer (Apache-2.0), whose documentation it keeps verbatim +maven/android-compat/src/main/java/android/hardware/SensorMath.java | Rewritten from the static methods of AOSP android.hardware.SensorManager, Apache-2.0, retaining its original notice; adapted to the Codename One runtime maven/android-compat/src/main/java/androidx/constraintlayout/core/ArrayLinkedVariables.java | Vendored AOSP androidx.constraintlayout (constraintlayout-core 1.0.4 sources, Google Maven), Apache-2.0, retaining its original notice; adapted to the Codename One runtime maven/android-compat/src/main/java/androidx/constraintlayout/core/ArrayRow.java | Vendored AOSP androidx.constraintlayout (constraintlayout-core 1.0.4 sources, Google Maven), Apache-2.0, retaining its original notice; adapted to the Codename One runtime maven/android-compat/src/main/java/androidx/constraintlayout/core/Cache.java | Vendored AOSP androidx.constraintlayout (constraintlayout-core 1.0.4 sources, Google Maven), Apache-2.0, retaining its original notice; adapted to the Codename One runtime