diff --git a/CodenameOne/src/com/codename1/ui/TextInputClient.java b/CodenameOne/src/com/codename1/ui/TextInputClient.java index c8eebd58135..bbaf44e4e93 100644 --- a/CodenameOne/src/com/codename1/ui/TextInputClient.java +++ b/CodenameOne/src/com/codename1/ui/TextInputClient.java @@ -38,6 +38,15 @@ /// All offsets exchanged with the platform are UTF-16 character indices. All callbacks are delivered on /// the EDT. public interface TextInputClient { + /// Optional contract for clients that can restore a canceled composition, including rich text + /// attributes and undo state. Ports may restore text and selection for older clients; rich editors + /// should implement this contract to restore their complete pre-composition state. + interface CompositionCancellation { + /// Restores the text, selection and attributes from before composition without recording an + /// undo entry. Does nothing when no composition is active. + void cancelComposing(); + } + /// Key command: move / extend to the left. int KEY_LEFT = 1; /// Key command: move / extend to the right. diff --git a/CodenameOne/src/com/codename1/ui/editor/EditorView.java b/CodenameOne/src/com/codename1/ui/editor/EditorView.java index 0671e450fcc..6736df7d908 100644 --- a/CodenameOne/src/com/codename1/ui/editor/EditorView.java +++ b/CodenameOne/src/com/codename1/ui/editor/EditorView.java @@ -46,7 +46,7 @@ /// This class provides the shared editing behavior. Feature layers (syntax highlighting, the gutter, /// styled rich text) subclass it and override `#paintContent(Graphics, int, int, int, int)` and the /// document/measurement hooks. -public class EditorView extends Component implements TextInputClient, com.codename1.ui.TextInputGeometry.Provider { +public class EditorView extends Component implements TextInputClient, TextInputClient.CompositionCancellation, com.codename1.ui.TextInputGeometry.Provider { private EditorHost host; private final boolean codeMode; private EditorDocument doc = new EditorDocument(); @@ -60,6 +60,8 @@ public class EditorView extends Component implements TextInputClient, com.codena // pre-composition snapshot so the finalized composition forms a single undo unit private String composingBaseRemoved; private Object composingBeforeState; + private int composingBeforeCaret; + private int composingBeforeAnchor; private boolean editable = true; private int scrollY; @@ -1900,6 +1902,8 @@ public void setComposingText(String text, int relativeCaret) { // commitText / finishComposing; intermediate compose updates are never recorded composingBaseRemoved = doc.substring(start, end); composingBeforeState = captureDocumentState(); + composingBeforeCaret = caret; + composingBeforeAnchor = anchor; } String removed = doc.substring(start, end); doc.delete(start, end); @@ -1924,6 +1928,25 @@ public void setComposingText(String text, int relativeCaret) { } } + @Override + public void cancelComposing() { + if (composingStart < 0) { + return; + } + Object before = composingBeforeState; + int oldCaret = composingBeforeCaret; + int oldAnchor = composingBeforeAnchor; + replaceRange(composingStart, composingEnd, + composingBaseRemoved == null ? "" : composingBaseRemoved, false); + restoreDocumentState(before); + caret = doc.clamp(oldCaret); + anchor = oldAnchor < 0 ? -1 : doc.clamp(oldAnchor); + invalidateLayout(); + scrollCaretVisible(); + pushInputState(); + repaint(); + } + @Override public void finishComposing() { if (composingStart >= 0) { diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_desktopwindow.c b/Ports/LinuxPort/nativeSources/cn1_linux_desktopwindow.c index 95526c2f3ef..91bb053105d 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_desktopwindow.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_desktopwindow.c @@ -135,6 +135,11 @@ CN1Graphics* cn1LinuxDesktopGraphics(int slot) { return &w->g; } +GtkWidget* cn1LinuxDesktopDrawingArea(int slot) { + CN1LinuxWindow* w = slotAt(slot); + return w == 0 ? 0 : w->drawingArea; +} + GtkWidget* cn1LinuxDesktopWidget(int slot) { CN1LinuxWindow* w = slotAt(slot); return w == 0 ? 0 : w->window; @@ -432,6 +437,9 @@ static gboolean cn1DesktopOnKey(GtkWidget* widget, GdkEventKey* e, gpointer data return FALSE; } } + if (cn1LinuxTextInputFilter((int) (w - cn1DesktopWindows), e)) { + return TRUE; + } /* Same mapping the main window uses: GDK encodes many Unicode keyvals * differently from their code point, so a raw keyval gives the wrong key code * for anything outside ASCII. Navigation keys have no Unicode mapping and fall @@ -730,6 +738,8 @@ static void cn1DesktopCreateOnMain(void* arg) { gtk_container_add(GTK_CONTAINER(w->overlay), w->drawingArea); gtk_overlay_add_overlay(GTK_OVERLAY(w->overlay), w->fixed); + /* Empty peer-layer space must reach the canvas, as in the main window. */ + gtk_overlay_set_overlay_pass_through(GTK_OVERLAY(w->overlay), w->fixed, TRUE); gtk_container_add(GTK_CONTAINER(w->window), w->overlay); gtk_widget_add_events(w->drawingArea, diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_edit.c b/Ports/LinuxPort/nativeSources/cn1_linux_edit.c index 25b73c87035..67c03cdda3f 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_edit.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_edit.c @@ -209,7 +209,8 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_editGetText___long_R_java_lang_ return JAVA_NULL; } pthread_mutex_lock(&e->lock); - result = newStringFromCString(threadStateData, e->text ? e->text : ""); + result = newStringFromUtf8Len(threadStateData, e->text ? e->text : "", + e->text ? (int) strlen(e->text) : 0); pthread_mutex_unlock(&e->lock); return result; } diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_gfx.h b/Ports/LinuxPort/nativeSources/cn1_linux_gfx.h index 6d9fe33786b..3f2699ca3e9 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_gfx.h +++ b/Ports/LinuxPort/nativeSources/cn1_linux_gfx.h @@ -132,6 +132,9 @@ void cn1LinuxOverlayRemove(int slot, GtkWidget* w); /* The top-level GtkWindow (NULL in headless mode). */ GtkWidget* cn1LinuxWindowWidget(void); +GtkWidget* cn1LinuxTextCanvas(int slot); +GtkWidget* cn1LinuxDesktopDrawingArea(int slot); +gboolean cn1LinuxTextInputFilter(int slot, GdkEventKey* event); /* Additional desktop windows (cn1_linux_desktopwindow.c). A secondary window * carries its own GtkWindow, drawing area, peer overlay and cairo back buffer; diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_services.c b/Ports/LinuxPort/nativeSources/cn1_linux_services.c index be3eb6c4a9c..5c2ec35b5e7 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_services.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_services.c @@ -210,7 +210,7 @@ JAVA_OBJECT com_codename1_impl_linux_LinuxNative_clipboardGetText___R_java_lang_ JAVA_OBJECT result; r.text = NULL; cn1LinuxRunOnMainAndWait(cn1ClipGetTextOnMain, &r); - result = r.text ? newStringFromCString(threadStateData, r.text) : JAVA_NULL; + result = r.text ? newStringFromUtf8Len(threadStateData, r.text, (int) strlen(r.text)) : JAVA_NULL; if (r.text) { g_free(r.text); } diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_socket.c b/Ports/LinuxPort/nativeSources/cn1_linux_socket.c index a11f3370228..2aa6f036dc7 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_socket.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_socket.c @@ -21,13 +21,10 @@ * need additional information or have any questions. */ -/* - * Raw TCP sockets for the native Codename One Linux port, backed by the POSIX - * BSD socket API. The socket peer is a small heap struct holding the fd and the - * last errno; the bridge mirrors the Codename One Socket SPI (connect / blocking - * read+write / available / close). - */ - +/* POSIX sockets. Tokens, rather than exposed heap addresses or descriptors, make + * close/read/accept races safe: operations retain a peer while the registry owns + * another reference. Shutdown wakes blocking I/O; the last operation closes the + * descriptor, so it cannot be recycled underneath a blocked syscall. */ #define _GNU_SOURCE #include "cn1_linux.h" #include @@ -35,177 +32,332 @@ #include #include #include -#include +#include +#include #include #include #include #include -#include #include extern JAVA_OBJECT newStringFromCString(CODENAME_ONE_THREAD_STATE, const char* str); extern const char* stringToUTF8(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT str); - -/* Forces a socket buffer object to stay live across a parked blocking read/write so - * the conservative GC cannot sweep it mid-I/O (see socketRead). An address-escape asm - * (the same idiom as the frameless GC anchor): the compiler must materialize the - * reference and keep it live up to this point, and unlike a store to a shared - * `volatile void*` static -- which qualifies the POINTEE, not the pointer, so the - * write is an eliminable dead store under LTO -- this cannot be optimized away and - * involves no cross-thread shared state. */ #define CN1_SOCKET_KEEP_ALIVE(obj) __asm__ __volatile__("" : : "r"(obj)) -typedef struct { - int fd; - int lastError; - int connected; +typedef struct CN1Socket { + JAVA_LONG token; + int fd, lastError, connected, refs, port; + struct CN1Socket *next, *nextListener; } CN1Socket; +#define CN1_SOCKET_BUCKETS 256 +static pthread_mutex_t cn1SocketMutex = PTHREAD_MUTEX_INITIALIZER; +static CN1Socket* cn1Sockets[CN1_SOCKET_BUCKETS]; +static CN1Socket* cn1Listeners; +static JAVA_LONG cn1NextSocketToken = 1; + +/* All registry helpers require cn1SocketMutex. */ +static CN1Socket* cn1SocketNew(int fd, int listenerPort) { + CN1Socket* s; + unsigned bucket; + if (cn1NextSocketToken == LLONG_MAX) { + close(fd); + return NULL; + } + s = (CN1Socket*) calloc(1, sizeof(CN1Socket)); + if (!s) { + close(fd); + return NULL; + } + s->token = cn1NextSocketToken++; + s->fd = fd; + s->connected = 1; + s->refs = 1; + s->port = listenerPort; + bucket = (unsigned) (s->token % CN1_SOCKET_BUCKETS); + s->next = cn1Sockets[bucket]; + cn1Sockets[bucket] = s; + if (listenerPort != 0) { + s->nextListener = cn1Listeners; + cn1Listeners = s; + } + return s; +} + +static CN1Socket* cn1SocketFind(JAVA_LONG token) { + CN1Socket* s; + if (token <= 0) { + return NULL; + } + for (s = cn1Sockets[token % CN1_SOCKET_BUCKETS]; s; s = s->next) { + if (s->token == token) { + return s; + } + } + return NULL; +} + +static void cn1SocketReleaseLocked(CN1Socket* s) { + if (--s->refs == 0) { + close(s->fd); + free(s); + } +} + +static CN1Socket* cn1SocketAcquire(JAVA_LONG token) { + CN1Socket* s; + pthread_mutex_lock(&cn1SocketMutex); + s = cn1SocketFind(token); + if (s) { + s->refs++; + } + pthread_mutex_unlock(&cn1SocketMutex); + return s; +} + +static void cn1SocketRelease(CN1Socket* s) { + pthread_mutex_lock(&cn1SocketMutex); + cn1SocketReleaseLocked(s); + pthread_mutex_unlock(&cn1SocketMutex); +} + +static void cn1SocketCloseLocked(CN1Socket* s) { + CN1Socket** link = &cn1Sockets[s->token % CN1_SOCKET_BUCKETS]; + while (*link != s) { + link = &(*link)->next; + } + *link = s->next; + if (s->port != 0) { + link = &cn1Listeners; + while (*link != s) { + link = &(*link)->nextListener; + } + *link = s->nextListener; + } + s->connected = 0; + shutdown(s->fd, SHUT_RDWR); + cn1SocketReleaseLocked(s); +} + JAVA_LONG com_codename1_impl_linux_LinuxNative_socketConnect___java_lang_String_int_int_R_long(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT host, JAVA_INT port, JAVA_INT timeoutMillis) { - const char* h = host == JAVA_NULL ? 0 : stringToUTF8(threadStateData, host); - struct addrinfo hints; - struct addrinfo* res = 0; - struct addrinfo* it; + const char* h = host == JAVA_NULL ? NULL : stringToUTF8(threadStateData, host); + struct addrinfo hints, *res = NULL, *it; char portStr[16]; int fd = -1; + JAVA_LONG token = 0; CN1Socket* s; (void) timeoutMillis; - if (!h) { + if (!h || port < 1 || port > 65535) { return 0; } memset(&hints, 0, sizeof(hints)); hints.ai_family = AF_UNSPEC; hints.ai_socktype = SOCK_STREAM; snprintf(portStr, sizeof(portStr), "%d", (int) port); - if (getaddrinfo(h, portStr, &hints, &res) != 0) { - return 0; - } - /* connect() blocks during the TCP handshake; yield so a GC mark isn't stalled. */ CN1_YIELD_THREAD; - for (it = res; it != 0; it = it->ai_next) { - fd = socket(it->ai_family, it->ai_socktype, it->ai_protocol); - if (fd < 0) { - continue; + if (getaddrinfo(h, portStr, &hints, &res) == 0) { + for (it = res; it; it = it->ai_next) { + fd = socket(it->ai_family, it->ai_socktype | SOCK_CLOEXEC, it->ai_protocol); + if (fd < 0) { + continue; + } + if (connect(fd, it->ai_addr, it->ai_addrlen) == 0) { + break; + } + close(fd); + fd = -1; } - if (connect(fd, it->ai_addr, it->ai_addrlen) == 0) { - break; - } - close(fd); - fd = -1; + freeaddrinfo(res); } CN1_RESUME_THREAD; - freeaddrinfo(res); + CN1_SOCKET_KEEP_ALIVE(host); if (fd < 0) { return 0; } - s = (CN1Socket*) calloc(1, sizeof(CN1Socket)); - s->fd = fd; - s->connected = 1; - return (JAVA_LONG) (intptr_t) s; + pthread_mutex_lock(&cn1SocketMutex); + s = cn1SocketNew(fd, 0); + if (s) { + token = s->token; + } + pthread_mutex_unlock(&cn1SocketMutex); + return token; +} + +JAVA_LONG com_codename1_impl_linux_LinuxNative_socketListenLoopback___int_R_long(CODENAME_ONE_THREAD_STATE, JAVA_INT port) { + CN1Socket *listener, *client; + int fd, accepted; + JAVA_LONG token = 0; + if (port < 1 || port > 65535) { + return 0; + } + pthread_mutex_lock(&cn1SocketMutex); + for (listener = cn1Listeners; listener && listener->port != port; listener = listener->nextListener) {} + if (!listener) { + struct sockaddr_in address; + int reuse = 1; + fd = socket(AF_INET, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (fd >= 0) { + setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); + memset(&address, 0, sizeof(address)); + address.sin_family = AF_INET; + address.sin_port = htons((unsigned short) port); + address.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + if (bind(fd, (struct sockaddr*) &address, sizeof(address)) != 0 || listen(fd, 16) != 0) { + close(fd); + } else { + listener = cn1SocketNew(fd, port); + } + } + } + if (listener) { + listener->refs++; + } + pthread_mutex_unlock(&cn1SocketMutex); + if (!listener) { + return 0; + } + CN1_YIELD_THREAD; + do { + accepted = accept4(listener->fd, NULL, NULL, SOCK_CLOEXEC); + } while (accepted < 0 && errno == EINTR); + CN1_RESUME_THREAD; + pthread_mutex_lock(&cn1SocketMutex); + if (accepted >= 0) { + if (listener->connected) { + client = cn1SocketNew(accepted, 0); + if (client) { + token = client->token; + } + } else { + close(accepted); + } + } + cn1SocketReleaseLocked(listener); + pthread_mutex_unlock(&cn1SocketMutex); + return token; +} + +JAVA_VOID com_codename1_impl_linux_LinuxNative_socketStopListening___int(CODENAME_ONE_THREAD_STATE, JAVA_INT port) { + CN1Socket* s; + pthread_mutex_lock(&cn1SocketMutex); + for (s = cn1Listeners; s && s->port != port; s = s->nextListener) {} + if (s) { + cn1SocketCloseLocked(s); + } + pthread_mutex_unlock(&cn1SocketMutex); } JAVA_INT com_codename1_impl_linux_LinuxNative_socketRead___long_byte_1ARRAY_int_int_R_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG socket, JAVA_OBJECT buffer, JAVA_INT offset, JAVA_INT length) { - CN1Socket* s = (CN1Socket*) (intptr_t) socket; - char* data; + CN1Socket* s; ssize_t n; - int readErrno; - if (!s || s->fd < 0 || buffer == JAVA_NULL || length <= 0) { + char* data; + int savedError; + if (buffer == JAVA_NULL || offset < 0 || length < 0 || offset > CN1_ARRAY_LENGTH(buffer) - length) { + return -1; + } + s = cn1SocketAcquire(socket); + if (!s) { return -1; } + if (length == 0) { + cn1SocketRelease(s); + return 0; + } data = (char*) CN1_ARRAY_DATA(buffer); - /* read() can block indefinitely (e.g. the cn1ss WebSocket reader waiting on - * the server). Mark the thread inactive across the blocking call so the - * concurrent GC -- which spins waiting for every lightweight thread to pause - * before it can traverse stacks -- is not deadlocked by a thread parked in a - * syscall. Every other CN1 port's blocking I/O does the same. */ CN1_YIELD_THREAD; - n = read(s->fd, data + offset, (size_t) length); - /* Captured before CN1_RESUME_THREAD. The resume is a GC safepoint and can park - * this thread on a timed wait, which overwrites errno -- so lastError below - * reported the WAIT's outcome rather than the read's, handing Java a misleading - * error for a failure that had nothing to do with it. */ - readErrno = errno; + do { + n = recv(s->fd, data + offset, (size_t) length, 0); + } while (n < 0 && errno == EINTR); + savedError = n < 0 ? errno : 0; CN1_RESUME_THREAD; - /* Keep the buffer array object reachable across the parked read: only `data` (an - * interior pointer) is used, so the optimizer may drop `buffer` and the concurrent GC - * -- scanning this parked thread -- can sweep it mid-read (a use-after-free; see the - * Windows port where this manifested on the cn1ss WebSocket reader). Force liveness. */ CN1_SOCKET_KEEP_ALIVE(buffer); + pthread_mutex_lock(&cn1SocketMutex); if (n <= 0) { - s->lastError = n < 0 ? readErrno : 0; - if (n == 0) { - s->connected = 0; - } - return -1; + s->lastError = savedError; + s->connected = 0; } - return (JAVA_INT) n; + cn1SocketReleaseLocked(s); + pthread_mutex_unlock(&cn1SocketMutex); + return n <= 0 ? -1 : (JAVA_INT) n; } JAVA_INT com_codename1_impl_linux_LinuxNative_socketWrite___long_byte_1ARRAY_int_int_R_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG socket, JAVA_OBJECT buffer, JAVA_INT offset, JAVA_INT length) { - CN1Socket* s = (CN1Socket*) (intptr_t) socket; + CN1Socket* s; + int written = 0, savedError = 0; char* data; - int written = 0; - if (!s || s->fd < 0 || buffer == JAVA_NULL || length <= 0) { + if (buffer == JAVA_NULL || offset < 0 || length < 0 || offset > CN1_ARRAY_LENGTH(buffer) - length) { + return -1; + } + s = cn1SocketAcquire(socket); + if (!s) { return -1; } data = (char*) CN1_ARRAY_DATA(buffer); - /* Yield to the concurrent GC across the (potentially blocking) write loop -- a - * thread parked in write() must not stall a GC mark (see socketRead). */ CN1_YIELD_THREAD; while (written < length) { - ssize_t n = write(s->fd, data + offset + written, (size_t) (length - written)); + ssize_t n = send(s->fd, data + offset + written, + (size_t) (length - written), MSG_NOSIGNAL); + if (n < 0 && errno == EINTR) { + continue; + } if (n <= 0) { - s->lastError = errno; - CN1_RESUME_THREAD; - /* Keep the buffer array reachable across the parked write, exactly like - * socketRead above: only `data` (an interior pointer) is used after the - * yield, so the optimizer may drop `buffer` and the concurrent GC -- - * scanning this parked thread -- can sweep the array WHILE write() is - * still reading from it (use-after-free / corrupted network output). */ - CN1_SOCKET_KEEP_ALIVE(buffer); - return written > 0 ? written : -1; + savedError = n < 0 ? errno : EPIPE; + break; } written += (int) n; } CN1_RESUME_THREAD; - /* See above: force the array live across the yielded write loop. */ CN1_SOCKET_KEEP_ALIVE(buffer); - return written; + pthread_mutex_lock(&cn1SocketMutex); + if (savedError) { + s->lastError = savedError; + s->connected = 0; + } + cn1SocketReleaseLocked(s); + pthread_mutex_unlock(&cn1SocketMutex); + return written == 0 && savedError ? -1 : written; } JAVA_INT com_codename1_impl_linux_LinuxNative_socketAvailable___long_R_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG socket) { - CN1Socket* s = (CN1Socket*) (intptr_t) socket; - int avail = 0; - if (!s || s->fd < 0) { - return 0; - } - if (ioctl(s->fd, FIONREAD, &avail) != 0) { - return 0; + CN1Socket* s = cn1SocketAcquire(socket); + int available = 0; + if (s) { + if (ioctl(s->fd, FIONREAD, &available) != 0) { + available = 0; + } + cn1SocketRelease(s); } - return avail; + return available; } JAVA_VOID com_codename1_impl_linux_LinuxNative_socketClose___long(CODENAME_ONE_THREAD_STATE, JAVA_LONG socket) { - CN1Socket* s = (CN1Socket*) (intptr_t) socket; - if (!s) { - return; - } - if (s->fd >= 0) { - close(s->fd); - s->fd = -1; + CN1Socket* s; + pthread_mutex_lock(&cn1SocketMutex); + s = cn1SocketFind(socket); + if (s) { + cn1SocketCloseLocked(s); } - s->connected = 0; - free(s); + pthread_mutex_unlock(&cn1SocketMutex); } JAVA_INT com_codename1_impl_linux_LinuxNative_socketErrorCode___long_R_int(CODENAME_ONE_THREAD_STATE, JAVA_LONG socket) { - CN1Socket* s = (CN1Socket*) (intptr_t) socket; - return s ? s->lastError : -1; + CN1Socket* s; + int result; + pthread_mutex_lock(&cn1SocketMutex); + s = cn1SocketFind(socket); + result = s ? s->lastError : -1; + pthread_mutex_unlock(&cn1SocketMutex); + return result; } JAVA_BOOLEAN com_codename1_impl_linux_LinuxNative_socketConnected___long_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_LONG socket) { - CN1Socket* s = (CN1Socket*) (intptr_t) socket; - return (s && s->connected && s->fd >= 0) ? JAVA_TRUE : JAVA_FALSE; + CN1Socket* s; + int connected; + pthread_mutex_lock(&cn1SocketMutex); + s = cn1SocketFind(socket); + connected = s && s->connected; + pthread_mutex_unlock(&cn1SocketMutex); + return connected ? JAVA_TRUE : JAVA_FALSE; } JAVA_OBJECT com_codename1_impl_linux_LinuxNative_getHostOrIP___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_text_input.c b/Ports/LinuxPort/nativeSources/cn1_linux_text_input.c new file mode 100644 index 00000000000..64f909a8bd0 --- /dev/null +++ b/Ports/LinuxPort/nativeSources/cn1_linux_text_input.c @@ -0,0 +1,346 @@ +/* + * 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. + */ +/* GTK owns the IM context. Java owns the document; queued edits carry session and + * sequence numbers, so neither a late callback nor a stale state push can edit + * a successor. Surrounding context is withheld until Java acknowledges all + * queued edits instead of handing an IME stale text. */ +#include "cn1_linux_gfx.h" +#include +#include + +extern const char* stringToUTF8(CODENAME_ONE_THREAD_STATE, JAVA_OBJECT text); +extern JAVA_OBJECT newStringFromUtf8Len(CODENAME_ONE_THREAD_STATE, const char* text, int length); + +typedef struct { + int session, sequence, slot, focused, surroundingValid, caretBytes, sensitive, preediting; + GtkIMContext* im; + GtkWidget *window, *canvas; + char* surrounding; +} CN1TextInput; +static CN1TextInput cn1TextInput; +static GQueue cn1TextEvents = G_QUEUE_INIT; +static GMutex cn1TextEventsMutex; + +static void cn1TextQueue(int kind, int value, int modifiers, const char* text) { + CN1TextInput* input = &cn1TextInput; + char* event; + if (!input->session || input->sequence == INT_MAX) { + return; + } + input->surroundingValid = 0; + event = g_strdup_printf("%d;%d;%d;%d;%d;%s", input->session, ++input->sequence, + kind, value, modifiers, text ? text : ""); + g_mutex_lock(&cn1TextEventsMutex); + g_queue_push_tail(&cn1TextEvents, event); + g_mutex_unlock(&cn1TextEventsMutex); +} + +static void cn1TextCommit(GtkIMContext* im, const gchar* text, gpointer unused) { + (void) im; (void) unused; + cn1TextInput.preediting = 0; + cn1TextQueue(0, 0, 0, text); +} + +static void cn1TextPreedit(GtkIMContext* im, gpointer unused) { + gchar* text = NULL; + PangoAttrList* attributes = NULL; + gint cursor = 0; + (void) unused; + gtk_im_context_get_preedit_string(im, &text, &attributes, &cursor); + if (text && *text) { + cn1TextInput.preediting = 1; + } + cn1TextQueue(1, cursor, 0, text); + g_free(text); + if (attributes) { + pango_attr_list_unref(attributes); + } +} + +static void cn1TextPreeditEnd(GtkIMContext* im, gpointer unused) { + (void) im; (void) unused; + cn1TextInput.preediting = 0; + /* End without a commit means cancellation; Java restores its saved selection. + * A following commit still replaces that restored selection normally. */ + cn1TextQueue(2, 0, 0, NULL); +} + +static gboolean cn1TextSurrounding(GtkIMContext* im, gpointer unused) { + CN1TextInput* input = &cn1TextInput; + (void) unused; + if (input->sensitive || !input->surroundingValid || !input->surrounding) { + return FALSE; + } + gtk_im_context_set_surrounding(im, input->surrounding, -1, input->caretBytes); + return TRUE; +} + +static gboolean cn1TextDelete(GtkIMContext* im, gint offset, gint count, gpointer unused) { + (void) im; (void) unused; + /* TextInputClient can delete immediately before/after the caret, not an + * unrelated range in a windowed surrounding snapshot. Decline that request. */ + if (!cn1TextInput.surroundingValid || offset > 0 || count < 0 + || (long long) offset + count < 0) { + return FALSE; + } + cn1TextQueue(5, offset, count, NULL); + return TRUE; +} + +static gboolean cn1TextFocus(GtkWidget* widget, GdkEventFocus* event, gpointer unused) { + (void) widget; (void) unused; + if (!cn1TextInput.im) { + return FALSE; + } + /* A deactivated window retains its focused component. Only component + * focus loss or destruction ends the Java input session. */ + if (event->in) { + cn1TextInput.focused = 1; + gtk_im_context_focus_in(cn1TextInput.im); + } else { + cn1TextInput.focused = 0; + gtk_im_context_reset(cn1TextInput.im); + if (cn1TextInput.preediting) { + cn1TextPreeditEnd(cn1TextInput.im, NULL); + } + gtk_im_context_focus_out(cn1TextInput.im); + } + return FALSE; +} + +static void cn1TextDestroyed(GtkWidget* widget, gpointer unused) { + (void) widget; (void) unused; + cn1TextInput.focused = 0; + cn1TextQueue(6, 0, 0, NULL); +} + +static void cn1TextClear(void) { + CN1TextInput* input = &cn1TextInput; + input->session = 0; /* Reset may emit callbacks: the old session is already gone. */ + if (input->im) { + g_signal_handlers_disconnect_by_data(input->im, input); + gtk_im_context_reset(input->im); + gtk_im_context_focus_out(input->im); + gtk_im_context_set_client_window(input->im, NULL); + g_object_unref(input->im); + } + if (input->window) { + g_signal_handlers_disconnect_by_data(input->window, input); + g_object_unref(input->window); + } + if (input->canvas) { + g_object_unref(input->canvas); + } + g_free(input->surrounding); + memset(input, 0, sizeof(*input)); +} + +typedef struct { int session, slot, constraint, correct, capitalize, result; } CN1TextStart; +static void cn1TextStartOnMain(void* data) { + CN1TextStart* request = data; + CN1TextInput* input = &cn1TextInput; + GtkWidget* window = request->slot == CN1_MAIN_WINDOW_SLOT + ? cn1LinuxWindowWidget() : cn1LinuxDesktopWidget(request->slot); + GtkWidget* canvas = cn1LinuxTextCanvas(request->slot); + GtkInputPurpose purpose = GTK_INPUT_PURPOSE_FREE_FORM; + GtkInputHints hints = request->correct + ? GTK_INPUT_HINT_SPELLCHECK | GTK_INPUT_HINT_WORD_COMPLETION : GTK_INPUT_HINT_NO_SPELLCHECK; + cn1TextClear(); + if (!window || !canvas) { + return; + } + /* Window.show() establishes component focus before mapping the GTK window. + * Realize the anchored canvas now so the initially focused editor receives + * an input context even before the first map. This does not show the window. */ + gtk_widget_realize(canvas); + if (!gtk_widget_get_realized(canvas)) { + return; + } + switch (request->constraint & 0xffff) { + case 1: purpose = GTK_INPUT_PURPOSE_EMAIL; break; + case 2: purpose = GTK_INPUT_PURPOSE_DIGITS; break; + case 3: purpose = GTK_INPUT_PURPOSE_PHONE; break; + case 4: purpose = GTK_INPUT_PURPOSE_URL; break; + case 5: purpose = GTK_INPUT_PURPOSE_NUMBER; break; + default: break; + } + if (request->constraint & 0x800000) { /* UPPERCASE */ + hints |= GTK_INPUT_HINT_UPPERCASE_CHARS; + } else if (request->constraint & 0x100000) { /* INITIAL_CAPS_WORD */ + hints |= GTK_INPUT_HINT_UPPERCASE_WORDS; + } else if ((request->constraint & 0x200000) || request->capitalize) { + hints |= GTK_INPUT_HINT_UPPERCASE_SENTENCES; + } + if (request->constraint & (0x80000 | 0x10000 | 0x40000)) { + hints &= ~(GTK_INPUT_HINT_SPELLCHECK | GTK_INPUT_HINT_WORD_COMPLETION); + hints |= GTK_INPUT_HINT_NO_SPELLCHECK; + /* GTK 3 has no PRIVATE hint. Do not request spelling/completion or + * supply surrounding text; retain the ordinary IME purpose. */ + } + if (request->constraint & 0x10000) { + purpose = (request->constraint & 0xffff) == 2 ? GTK_INPUT_PURPOSE_PIN : GTK_INPUT_PURPOSE_PASSWORD; + hints = GTK_INPUT_HINT_NO_SPELLCHECK; + } + input->session = request->session; + input->slot = request->slot; + input->sensitive = (request->constraint & (0x10000 | 0x40000)) != 0; + input->window = g_object_ref(window); + input->canvas = g_object_ref(canvas); + input->im = gtk_im_multicontext_new(); + g_object_set(input->im, "input-purpose", purpose, "input-hints", hints, NULL); + gtk_im_context_set_client_window(input->im, gtk_widget_get_window(canvas)); + g_signal_connect(input->im, "commit", G_CALLBACK(cn1TextCommit), input); + g_signal_connect(input->im, "preedit-changed", G_CALLBACK(cn1TextPreedit), input); + g_signal_connect(input->im, "preedit-end", G_CALLBACK(cn1TextPreeditEnd), input); + g_signal_connect(input->im, "retrieve-surrounding", G_CALLBACK(cn1TextSurrounding), input); + g_signal_connect(input->im, "delete-surrounding", G_CALLBACK(cn1TextDelete), input); + g_signal_connect(window, "focus-in-event", G_CALLBACK(cn1TextFocus), input); + g_signal_connect(window, "focus-out-event", G_CALLBACK(cn1TextFocus), input); + g_signal_connect(window, "destroy", G_CALLBACK(cn1TextDestroyed), input); + gtk_widget_set_can_focus(canvas, TRUE); + gtk_widget_grab_focus(canvas); + input->focused = 1; + gtk_im_context_focus_in(input->im); + request->result = 1; +} + +JAVA_BOOLEAN com_codename1_impl_linux_LinuxTextInput_startNative___int_int_int_boolean_boolean_R_boolean(CODENAME_ONE_THREAD_STATE, JAVA_INT session, JAVA_INT slot, JAVA_INT constraint, JAVA_BOOLEAN correct, JAVA_BOOLEAN capitalize) { + CN1TextStart request = {session, slot, constraint, correct, capitalize, 0}; + cn1LinuxRunOnMainAndWait(cn1TextStartOnMain, &request); + return request.result ? JAVA_TRUE : JAVA_FALSE; +} + +static void cn1TextStopOnMain(void* data) { + if (cn1TextInput.session == *(int*) data) { + cn1TextClear(); + } +} +JAVA_VOID com_codename1_impl_linux_LinuxTextInput_stopNative___int(CODENAME_ONE_THREAD_STATE, JAVA_INT session) { + int id = session; + cn1LinuxRunOnMainAndWait(cn1TextStopOnMain, &id); +} + +typedef struct { int session, sequence, caret, x, y, width, height; char* text; } CN1TextUpdate; +static void cn1TextUpdateOnMain(void* data) { + CN1TextUpdate* request = data; + CN1TextInput* input = &cn1TextInput; + const char* cursor; + int units = 0; + GdkRectangle rect; + if (!input->im || input->session != request->session || input->sequence != request->sequence) { + return; + } + g_free(input->surrounding); + input->surrounding = g_strdup(request->text); + cursor = input->surrounding; + while (*cursor && units < request->caret) { + int length = g_utf8_get_char(cursor) > 0xffff ? 2 : 1; + if (units + length > request->caret) { + break; + } + units += length; + cursor = g_utf8_next_char(cursor); + } + input->caretBytes = (int) (cursor - input->surrounding); + input->surroundingValid = 1; + /* CN1 paints in drawing-area coordinates, which is also the IM client window. */ + rect.x = request->x; + rect.y = request->y; + rect.width = request->width; + rect.height = request->height; + gtk_im_context_set_cursor_location(input->im, &rect); +} +JAVA_VOID com_codename1_impl_linux_LinuxTextInput_updateNative___int_int_java_lang_String_int_int_int_int_int(CODENAME_ONE_THREAD_STATE, JAVA_INT session, JAVA_INT sequence, JAVA_OBJECT text, JAVA_INT caret, JAVA_INT x, JAVA_INT y, JAVA_INT width, JAVA_INT height) { + CN1TextUpdate request = {session, sequence, caret, x, y, width, height, NULL}; + request.text = g_strdup(text == JAVA_NULL ? "" : stringToUTF8(threadStateData, text)); + cn1LinuxRunOnMainAndWait(cn1TextUpdateOnMain, &request); + g_free(request.text); +} +JAVA_OBJECT com_codename1_impl_linux_LinuxTextInput_pollNative___R_java_lang_String(CODENAME_ONE_THREAD_STATE) { + char* event; + JAVA_OBJECT result; + g_mutex_lock(&cn1TextEventsMutex); + event = g_queue_pop_head(&cn1TextEvents); + g_mutex_unlock(&cn1TextEventsMutex); + if (!event) { + return JAVA_NULL; + } + result = newStringFromUtf8Len(threadStateData, event, (int) strlen(event)); + g_free(event); + return result; +} + +/* Invoked after each window's native-peer focus guard, before ordinary CN1 keys. */ +gboolean cn1LinuxTextInputFilter(int slot, GdkEventKey* event) { + CN1TextInput* input = &cn1TextInput; + int command = 0, modifiers = 0; + guint key; + if (!input->im || !input->focused || slot != input->slot || !event) { + return FALSE; + } + if (event->state & GDK_SHIFT_MASK) { modifiers |= 1; } + if (event->state & GDK_CONTROL_MASK) { modifiers |= 2; } + if (event->state & GDK_MOD1_MASK) { modifiers |= 4; } + key = gdk_keyval_to_lower(event->keyval); + if ((modifiers & 2) && !(modifiers & 4)) { + switch (key) { + case GDK_KEY_c: command = 12; break; + case GDK_KEY_x: command = 13; break; + case GDK_KEY_v: command = 14; break; + case GDK_KEY_a: command = 15; break; + case GDK_KEY_z: command = (modifiers & 1) ? 17 : 16; break; + case GDK_KEY_y: command = 17; break; + default: break; + } + } + if (!command && gtk_im_context_filter_keypress(input->im, event)) { + return TRUE; + } + if (!command) { + switch (key) { + case GDK_KEY_Left: case GDK_KEY_KP_Left: command = 1; break; + case GDK_KEY_Right: case GDK_KEY_KP_Right: command = 2; break; + case GDK_KEY_Up: case GDK_KEY_KP_Up: command = 3; break; + case GDK_KEY_Down: case GDK_KEY_KP_Down: command = 4; break; + case GDK_KEY_Home: case GDK_KEY_KP_Home: command = 5; break; + case GDK_KEY_End: case GDK_KEY_KP_End: command = 6; break; + case GDK_KEY_Page_Up: case GDK_KEY_KP_Page_Up: command = 7; break; + case GDK_KEY_Page_Down: case GDK_KEY_KP_Page_Down: command = 8; break; + case GDK_KEY_BackSpace: command = 9; break; + case GDK_KEY_Delete: case GDK_KEY_KP_Delete: command = 10; break; + case GDK_KEY_Escape: command = 11; break; + case GDK_KEY_Tab: case GDK_KEY_ISO_Left_Tab: command = 18; break; + case GDK_KEY_Return: case GDK_KEY_KP_Enter: + if (event->type == GDK_KEY_PRESS) { cn1TextQueue(4, 0, modifiers, NULL); } + return TRUE; + default: break; + } + } + if (command) { + if (event->type == GDK_KEY_PRESS) { + cn1TextQueue(3, command, modifiers, NULL); + } + return TRUE; + } + return FALSE; +} diff --git a/Ports/LinuxPort/nativeSources/cn1_linux_window.c b/Ports/LinuxPort/nativeSources/cn1_linux_window.c index 6c1a7283e7e..da2841e0408 100644 --- a/Ports/LinuxPort/nativeSources/cn1_linux_window.c +++ b/Ports/LinuxPort/nativeSources/cn1_linux_window.c @@ -415,6 +415,10 @@ void cn1LinuxOverlayRemove(int slot, GtkWidget* w) { /* The top-level GtkWindow (NULL in headless mode); used as the transient parent * for modal dialogs (file chooser, print) and the WebKit/edit hosts. */ +GtkWidget* cn1LinuxTextCanvas(int slot) { + return slot == CN1_MAIN_WINDOW_SLOT ? cn1DrawingArea : cn1LinuxDesktopDrawingArea(slot); +} + GtkWidget* cn1LinuxWindowWidget(void) { return cn1Window; } @@ -715,6 +719,16 @@ static gboolean cn1OnKey(GtkWidget* widget, GdkEventKey* e, gpointer data) { return FALSE; } } + /* Our handler consumes unhandled canvas keys below, so GTK's default + * handler never gets to activate menu accelerators. Resolve them here, + * after native peers have had their normal focus-based dispatch. */ + if (e->type == GDK_KEY_PRESS && cn1Window != NULL + && gtk_window_activate_key(GTK_WINDOW(cn1Window), e)) { + return TRUE; + } + if (cn1LinuxTextInputFilter(CN1_MAIN_WINDOW_SLOT, e)) { + return TRUE; + } /* Map the GDK keyval to a Codename One key code: printable Unicode passes * through gdk_keyval_to_unicode; the navigation keys map to the CN1 game-key * codes the EDT recognises. */ diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java index 04f40467d12..777f49c68b1 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxImplementation.java @@ -822,6 +822,7 @@ void dispatchPointerHover(int windowId, int x, int y, int keyField) { } private void drainInput() { + lowLevelTextInput.drain(); while (LinuxNative.pollEvent(eventScratch)) { int type = eventScratch[0]; int x = eventScratch[1]; @@ -2104,7 +2105,7 @@ public void getRGB(Object nativeImage, int[] arr, int offset, int x, int y, int /** * Native input is supported: while a TextField/TextArea is edited it is - * overlaid by a real Win32 EDIT control with a native caret, selection, + * overlaid by a native GTK text widget with a native caret, selection, * keyboard and IME -- so {@code TextArea} routes here instead of the * lightweight (CN1-drawn) editor. */ @@ -2113,6 +2114,32 @@ public boolean isNativeInputSupported() { return true; } + private final LinuxTextInput lowLevelTextInput = new LinuxTextInput(); + + @Override + public boolean isTextInputSupported() { + return true; + } + + @Override + public Object startTextInput(com.codename1.ui.TextInputClient client, + com.codename1.ui.TextInputConfig config) { + if (editPeer != 0) { + commitEdit(); + } + return lowLevelTextInput.start(client, config); + } + + @Override + public void updateTextInputState(Object handle, com.codename1.ui.TextInputState state) { + lowLevelTextInput.update(handle, state); + } + + @Override + public void stopTextInput(Object handle) { + lowLevelTextInput.stop(handle); + } + /** * Asynchronous edit mode. editString returns immediately and the native * control owns the keystrokes; a UITimer polls for commit and the framework @@ -2127,7 +2154,7 @@ public boolean isAsyncEditMode() { } /** - * Overlays a native Win32 EDIT control over the field's text area (inside the + * Overlays a native GTK text widget over the field's text area (inside the * field's padding so its border/background still show), styled to match the * field's font and colours, and focuses it. Returns immediately (async); the * value is delivered through {@code Display.onEditingComplete} when the user @@ -2136,6 +2163,7 @@ public boolean isAsyncEditMode() { */ @Override public void editString(final Component cmp, int maxSize, int constraint, String text, int initiatingKeycode) { + lowLevelTextInput.stopCurrent(); // Tapping straight from one field into another: finish the first cleanly // before overlaying the new one, so two controls never float at once. if (editPeer != 0) { @@ -2756,6 +2784,24 @@ public boolean isSocketAvailable() { return true; } + @Override + public boolean isLoopbackServerSocketAvailable() { + return true; + } + + @Override + public Object listenSocketLoopback(int port) { + long handle = LinuxNative.socketListenLoopback(port); + return handle == 0 ? null : new LinuxSocket(handle); + } + + @Override + public void stopListeningSocket(int port, boolean loopbackOnly) { + if (loopbackOnly) { + LinuxNative.socketStopListening(port); + } + } + @Override public Object connectSocket(String host, int port, int connectTimeout) { LinuxSocket socket = new LinuxSocket(host, port, connectTimeout); @@ -2769,7 +2815,9 @@ public String getHostOrIP() { @Override public void disconnectSocket(Object socket) { - ((LinuxSocket) socket).close(); + if (socket instanceof LinuxSocket) { + ((LinuxSocket) socket).close(); + } } @Override @@ -2779,27 +2827,29 @@ public boolean isSocketConnected(Object socket) { @Override public int getSocketAvailableInput(Object socket) { - return ((LinuxSocket) socket).available(); + return socket instanceof LinuxSocket ? ((LinuxSocket) socket).available() : 0; } @Override public byte[] readFromSocketStream(Object socket) { - return ((LinuxSocket) socket).readChunk(); + return socket instanceof LinuxSocket ? ((LinuxSocket) socket).readChunk() : null; } @Override public void writeToSocketStream(Object socket, byte[] data) { - ((LinuxSocket) socket).write(data, 0, data.length); + if (socket instanceof LinuxSocket) { + ((LinuxSocket) socket).write(data, 0, data.length); + } } @Override public String getSocketErrorMessage(Object socket) { - return ((LinuxSocket) socket).getErrorMessage(); + return socket instanceof LinuxSocket ? ((LinuxSocket) socket).getErrorMessage() : null; } @Override public int getSocketErrorCode(Object socket) { - return ((LinuxSocket) socket).getErrorCode(); + return socket instanceof LinuxSocket ? ((LinuxSocket) socket).getErrorCode() : -1; } /* ---------------------------------------------------------- websocket */ diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java index 361da314a11..ba6395003f2 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxNative.java @@ -661,6 +661,12 @@ public static native boolean verifyData(String algorithm, byte[] publicKeyX509, /* ---------------------------------------------------------- sockets */ + /** Accepts a connection on an IPv4 loopback-only listener; 0 means failure. */ + public static native long socketListenLoopback(int port); + + /** Cancels the listener and wakes its blocked accept. */ + public static native void socketStopListening(int port); + /** Connects a TCP socket to host:port (timeoutMillis 0 = blocking). 0 = failure. */ public static native long socketConnect(String host, int port, int timeoutMillis); @@ -676,7 +682,7 @@ public static native boolean verifyData(String algorithm, byte[] publicKeyX509, /** Closes and frees the socket peer. */ public static native void socketClose(long socket); - /** Last WinSock error code for the socket, or -1. */ + /** Last POSIX error code for the socket, or -1. */ public static native int socketErrorCode(long socket); /** True while the socket peer is open. */ diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxSocket.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxSocket.java index 6b9ebc1e738..8790d3868a4 100644 --- a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxSocket.java +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxSocket.java @@ -27,13 +27,19 @@ import java.io.OutputStream; /** - * Thin Java wrapper over a native WinSock TCP socket peer. Holds the native + * Thin Java wrapper over a native POSIX TCP socket peer. Holds the native * handle and exposes both the raw read/write the Codename One socket SPI needs * and blocking {@link InputStream}/{@link OutputStream} adapters the port's * WebSocket client runs over. */ public final class LinuxSocket { - private long handle; + private volatile long handle; + private volatile int errorCode = -1; + + /** Takes ownership of an accepted socket token. */ + LinuxSocket(long acceptedHandle) { + handle = acceptedHandle; + } /** Connects to host:port; {@link #isConnected()} reports the outcome. */ public LinuxSocket(String host, int port, int timeoutMillis) { @@ -57,6 +63,7 @@ public byte[] readChunk() { byte[] buffer = new byte[avail > 0 ? avail : 8192]; int read = LinuxNative.socketRead(handle, buffer, 0, buffer.length); if (read < 0) { + close(); return null; } if (read == buffer.length) { @@ -69,21 +76,24 @@ public byte[] readChunk() { public void write(byte[] data, int offset, int length) { if (handle != 0) { - LinuxNative.socketWrite(handle, data, offset, length); + if (LinuxNative.socketWrite(handle, data, offset, length) < length) { + close(); + } } } public int getErrorCode() { - return handle == 0 ? -1 : LinuxNative.socketErrorCode(handle); + return handle == 0 ? errorCode : LinuxNative.socketErrorCode(handle); } public String getErrorMessage() { int code = getErrorCode(); - return code <= 0 ? null : "WinSock error " + code; + return code <= 0 ? null : "Socket error " + code; } public void close() { if (handle != 0) { + errorCode = LinuxNative.socketErrorCode(handle); LinuxNative.socketClose(handle); handle = 0; } @@ -99,6 +109,11 @@ public OutputStream getOutputStream() { /** Blocking input stream backed by socketRead, with a small carry buffer. */ private final class SocketInput extends InputStream { + @Override + public void close() { + LinuxSocket.this.close(); + } + private byte[] buffer; private int pos; private int len; @@ -135,6 +150,7 @@ private boolean fill() throws IOException { byte[] chunk = new byte[8192]; int n = LinuxNative.socketRead(handle, chunk, 0, chunk.length); if (n < 0) { + LinuxSocket.this.close(); return false; } if (n == 0) { @@ -149,6 +165,11 @@ private boolean fill() throws IOException { } private final class SocketOutput extends OutputStream { + @Override + public void close() { + LinuxSocket.this.close(); + } + @Override public void write(int b) throws IOException { write(new byte[] { (byte) b }, 0, 1); @@ -159,7 +180,8 @@ public void write(byte[] b, int off, int length) throws IOException { if (handle == 0) { throw new IOException("socket closed"); } - if (LinuxNative.socketWrite(handle, b, off, length) < 0) { + if (LinuxNative.socketWrite(handle, b, off, length) < length) { + LinuxSocket.this.close(); throw new IOException("socket write failed: " + getErrorMessage()); } } diff --git a/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxTextInput.java b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxTextInput.java new file mode 100644 index 00000000000..e9906b38c94 --- /dev/null +++ b/Ports/LinuxPort/src/com/codename1/impl/linux/LinuxTextInput.java @@ -0,0 +1,285 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.linux; + +import com.codename1.ui.Component; +import com.codename1.ui.Display; +import com.codename1.ui.TextInputClient; +import com.codename1.ui.TextInputConfig; +import com.codename1.ui.TextInputState; +import com.codename1.ui.TopLevelContainer; +import com.codename1.ui.events.FocusListener; + +/** GTK input-method binding. Native events carry a session and sequence, and run only on the EDT. */ +final class LinuxTextInput { + private Binding current; + private int nextSession; + + private static final class Binding { + final int id; + final TextInputClient client; + final TextInputConfig config; + final Component owner; + int sequence; + FocusListener focusListener; + final Composition composition; + + Binding(int id, TextInputClient client, TextInputConfig config, Component owner) { + this.id = id; + this.client = client; + this.config = config; + this.owner = owner; + this.composition = new Composition(client); + } + } + + Object start(TextInputClient client, TextInputConfig config) { + if (client == null) { + throw new IllegalArgumentException("Text input client is required"); + } + stop(current); + if (nextSession == Integer.MAX_VALUE) { + throw new IllegalStateException("Text input session space exhausted"); + } + TopLevelContainer top = Display.getInstance().getCurrentTopLevel(); + Component owner = client instanceof Component ? (Component) client + : top == null ? null : top.getFocused(); + if (owner == null && top != null) { + owner = top.asContainer(); + } + final Binding binding = new Binding(++nextSession, client, + config == null ? new TextInputConfig() : config, owner); + int slot = owner == null ? LinuxImplementation.MAIN_WINDOW_SLOT + : LinuxWindowManager.slotForComponent(owner); + if (!startNative(binding.id, slot, binding.config.getConstraint(), + binding.config.isAutoCorrect(), binding.config.isAutoCapitalize())) { + return null; + } + current = binding; + if (owner != null) { + binding.focusListener = new FocusListener() { + public void focusGained(Component component) { + } + public void focusLost(Component component) { + stop(binding); + } + }; + owner.addFocusListener(binding.focusListener); + } + client.inputFocusGained(); + update(binding, client.getEditingState()); + return binding; + } + + void update(Object handle, TextInputState state) { + if (handle == null || handle != current || state == null) { + return; + } + Binding binding = current; + int[] rect = binding.client.getCaretRect(); + if (rect == null || rect.length < 4) { + rect = new int[] {0, 0, 1, 1}; + } + updateNative(binding.id, binding.sequence, state.getText(), state.getSelectionEnd(), + rect[0], rect[1], Math.max(1, rect[2]), Math.max(1, rect[3])); + } + + void stopCurrent() { + stop(current); + } + + void stop(Object handle) { + if (handle == null || handle != current) { + return; + } + Binding binding = current; + current = null; + if (binding.owner != null && binding.focusListener != null) { + binding.owner.removeFocusListener(binding.focusListener); + } + stopNative(binding.id); + binding.composition.cancel(); + binding.client.inputFocusLost(); + } + + /** Called by the GTK pump. No document or component state is read on that thread. */ + void drain() { + while (true) { + final String event = pollNative(); + if (event == null) { + return; + } + Display.getInstance().callSerially(new Runnable() { + public void run() { + dispatch(event); + } + }); + } + } + + private void dispatch(String event) { + // Five numeric fields followed by arbitrary UTF-8 text, including delimiters/newlines. + int[] fields = new int[5]; + int start = 0; + for (int i = 0; i < fields.length; i++) { + int end = event.indexOf(';', start); + if (end < 0) { + return; + } + try { + fields[i] = Integer.parseInt(event.substring(start, end)); + } catch (NumberFormatException invalid) { + return; + } + start = end + 1; + } + Binding binding = current; + if (binding == null || fields[0] != binding.id || fields[1] <= binding.sequence) { + return; + } + binding.sequence = fields[1]; + TextInputClient client = binding.client; + String text = event.substring(start); + switch (fields[2]) { + case 0: + binding.composition.commit(text); + break; + case 1: + binding.composition.preedit(text, fields[3]); + break; + case 2: + binding.composition.cancel(); + break; + case 3: + client.onKeyCommand(fields[3], fields[4]); + break; + case 4: + if (binding.config.isMultiline()) { + client.commitText("\n"); + } else { + client.onEditorAction(binding.config.getActionType()); + } + break; + case 5: + TextInputState state = client.getEditingState(); + int[] deletion = surroundingDeletion(state, fields[3], fields[4]); + if (deletion != null) { + client.deleteSurroundingText(deletion[0], deletion[1]); + } + break; + case 6: + stop(binding); + return; + default: + return; + } + if (current == binding) { + update(binding, client.getEditingState()); + } + } + + /** Owns rollback state until an explicit GTK commit, independently of signal ordering. */ + static final class Composition { + private final TextInputClient client; + private TextInputState before; + private String selected; + + Composition(TextInputClient client) { + this.client = client; + } + + void preedit(String text, int cursor) { + if (before == null) { + if (text.length() == 0) { + return; + } + before = client.getEditingState(); + selected = client.getTextRange(Math.min(before.getSelectionStart(), before.getSelectionEnd()), + Math.max(before.getSelectionStart(), before.getSelectionEnd())); + } + client.setComposingText(text, moveCodePoints(text, 0, cursor)); + } + + void commit(String text) { + client.commitText(text); + before = null; + selected = null; + } + + void cancel() { + if (before == null) { + return; + } + TextInputState original = before; + String replacement = selected; + before = null; + selected = null; + if (client instanceof TextInputClient.CompositionCancellation) { + ((TextInputClient.CompositionCancellation) client).cancelComposing(); + } else { + client.setComposingText(replacement, replacement.length()); + client.finishComposing(); + client.setSelectionRange(original.getSelectionStart(), original.getSelectionEnd()); + } + } + } + + static int[] surroundingDeletion(TextInputState state, int offset, int count) { + if (offset > 0 || count < 0 || (long) offset + count < 0) { + return null; + } + int caret = Math.max(0, Math.min(state.getText().length(), state.getSelectionEnd())); + int from = moveCodePoints(state.getText(), caret, offset); + int to = moveCodePoints(state.getText(), caret, offset + count); + return new int[] {caret - from, to - caret}; + } + + /** GTK counts Unicode characters; the public input API counts UTF-16 units. */ + static int moveCodePoints(String text, int offset, int count) { + int cursor = Math.max(0, Math.min(text.length(), offset)); + while (count > 0 && cursor < text.length()) { + char first = text.charAt(cursor++); + if (Character.isHighSurrogate(first) && cursor < text.length() + && Character.isLowSurrogate(text.charAt(cursor))) { + cursor++; + } + count--; + } + while (count < 0 && cursor > 0) { + char last = text.charAt(--cursor); + if (Character.isLowSurrogate(last) && cursor > 0 + && Character.isHighSurrogate(text.charAt(cursor - 1))) { + cursor--; + } + count++; + } + return cursor; + } + + private static native boolean startNative(int session, int slot, int constraint, + boolean autoCorrect, boolean autoCapitalize); + private static native void updateNative(int session, int sequence, String text, int caret, + int x, int y, int width, int height); + private static native void stopNative(int session); + private static native String pollNative(); +} diff --git a/docs/developer-guide/MCP-Headless-API.asciidoc b/docs/developer-guide/MCP-Headless-API.asciidoc index e0b1f80ea3e..b8e638d5723 100644 --- a/docs/developer-guide/MCP-Headless-API.asciidoc +++ b/docs/developer-guide/MCP-Headless-API.asciidoc @@ -38,6 +38,10 @@ The stdio transport is the standard MCP local transport, exchanging newline deli The socket transport binds the loopback interface, and it binds the loopback interface of whatever machine the application is running on. In the simulator that's your own, so an agent connects to `127.0.0.1` and there's nothing else to arrange. On a phone the port belongs to the phone, and reaching it needs a forward. +The native Linux port supports the portable socket transport through an IPv4 listener bound to `127.0.0.1`. Stopping the listener wakes a waiting listener, and closing a connection wakes its blocked input/output operations. This capability doesn't enable wildcard-bound server sockets or change the release-build guard. Native Linux builds still require the explicit, controlled-development opt-in described below. + +For a Linux application running in a container, run the MCP client in that same container or network namespace. Publishing a container port doesn't make a listener bound to its internal loopback address reachable from the host. A local headless test can use Xvfb; when its launcher runs in Docker, use `--init` so the launcher's readiness signals are delivered normally. Native window-manager behavior still needs a desktop session. + On Android, `adb forward tcp:8765 tcp:8765` maps a port on the development machine onto the same port inside the device, so an agent connects to `127.0.0.1:8765` as before. Remove it with `adb forward --remove tcp:8765` when the session ends. The `cn1:android-on-device-debugging` goal already installs and launches a debuggable build, and its `waitForAttach=false` option is the one to use when the point of the session is the agent rather than a breakpoint, since the application then boots straight into a drivable state instead of blocking for a debugger. On iOS the same setting isn't a convenience but a requirement, because `ios.onDeviceDebug.waitForAttach` defers the callback that boots the application until a debugger attaches -- so a starter in the application's own code never runs, and there is nothing listening on the port to reach. The native iOS simulator shares the host's network stack, so the application's loopback port is the host's loopback port and no forwarding applies. A physical iPhone doesn't: the port sits on the device's own loopback, reachable only over the USB multiplexing channel, and `iproxy` from libimobiledevice is the usual relay. Codename One ships no goal for it, so an agent-driven session against a real iPhone depends on external tooling in a way the Android one doesn't. diff --git a/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc b/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc index 9e3ff072f93..76f750abbc8 100644 --- a/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc +++ b/docs/developer-guide/Rich-Text-And-Code-Editing.asciidoc @@ -89,6 +89,32 @@ autocorrection and automatic capitalization. The immutable https://www.codenameone.com/javadoc/com/codename1/ui/TextInputState.html[`TextInputState`] returns the authoritative text, selection, and composing range to the port after each edit. +The native Linux port binds this contract to GTK's +https://docs.gtk.org/gtk3/class.IMContext.html[`GtkIMContext`]. The document stays in the lightweight +canvas while GTK handles composed text and input-method candidate UI. Navigation, deletion, +clipboard, and undo shortcuts are delivered as semantic commands. Native `TextField`/`TextArea` +editing takes over the input session when one of those fields starts native editing. A dialog's +initially focused editor can bind before its native window is shown. Temporary native window focus +loss cancels an unfinished composition and pauses input without discarding the component's session; +returning to that window resumes it. A GTK text composition ending without a commit restores the original +selection instead of finalizing a deletion. Pure editors implement +`TextInputClient.CompositionCancellation` to restore rich attributes and undo state as well as text. +Custom clients can implement the same optional contract; the fallback restores text and selection +through their existing composition methods. Explicit commits work whether GTK ends the composition before +or after the commit callback. + +Linux queues native input onto the EDT with a session identifier and edit sequence. Callbacks from a +closed session are discarded. The port supplies surrounding text only after Java has acknowledged +all queued edits; an older state update can't overwrite the latest context. Password and sensitive +fields don't expose surrounding text to the input method. Autocorrect enables spelling and completion +hints; password, sensitive and non-predictive fields suppress them. Numeric password fields request +GTK's PIN purpose. Explicit capitalization constraints take precedence over the boolean fallback. +GTK's character-based +composition and deletion offsets are converted to UTF-16. Deletion requests for a disconnected range +that doesn't touch the caret are declined because `deleteSurroundingText` can't represent them. +Input-method availability depends on the GTK modules installed on the user's system. Local GTK +composition tests don't certify every installed input-method engine or a complete language-specific editor. + All offsets in this contract are UTF-16 indices, matching Java `String`, Android `Editable`, and Apple string indices. The document canonicalizes CRLF and bare CR line endings to LF before it updates the caret, undo history, formatting runs, or platform state. Port authors must deliver composition updates diff --git a/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewInputTest.java b/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewInputTest.java index 3f76bc4bd06..d145bc5954f 100644 --- a/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewInputTest.java +++ b/maven/core-unittests/src/test/java/com/codename1/ui/editor/EditorViewInputTest.java @@ -147,4 +147,49 @@ void aSecondCommitFollowsTheFirstRatherThanReplacingIt() { f.commitText("cd"); assertEquals("abcd", f.getText()); } + @FormTest + void cancelCompositionRestoresSelectionStylesAndRedoWithoutAnUndoEntry() { + RichView v = new RichView(new NoopHost()); + Form f = new Form("cancel", new BorderLayout()); + f.add(BorderLayout.CENTER, v); + f.show(); + flushSerialCalls(); + v.setText("abc def"); + v.setSelectionRange(0, 3); + v.toggleBold(); + v.setSelectionRange(7, 7); + v.commitText("!"); + v.performUndo(); + v.setSelectionRange(3, 0); + v.setComposingText(KANA, KANA.length()); + v.setComposingText("", 0); + v.cancelComposing(); + assertEquals("abc def", v.getText()); + assertEquals(0, v.getSelectionStart()); + assertEquals(3, v.getSelectionEnd()); + assertTrue(v.getInlineStyles().styleAt(0).isBold()); + assertFalse(v.getInlineStyles().styleAt(4).isBold()); + assertEquals(-1, v.getEditingState().getComposingStart()); + v.performRedo(); + assertEquals("abc def!", v.getText(), "cancellation must preserve redo"); + v.performUndo(); + v.performUndo(); + assertEquals("abc def", v.getText()); + assertFalse(v.getInlineStyles().styleAt(0).isBold(), "no cancellation undo entry precedes bold"); + } + + @FormTest + void cancelWithoutCompositionDoesNotChangeTextAndCommittedTextSurvivesEnd() { + EditorView v = show("original"); + v.cancelComposing(); + assertEquals("original", v.getText()); + v.setSelectionRange(0, 8); + v.setComposingText(KANA, 0); + v.commitText(KANJI); + v.cancelComposing(); + assertEquals(KANJI, v.getText()); + v.performUndo(); + assertEquals("original", v.getText()); + } + } diff --git a/maven/linux/src/test/java/com/codename1/impl/linux/LinuxSocketMetadataTest.java b/maven/linux/src/test/java/com/codename1/impl/linux/LinuxSocketMetadataTest.java new file mode 100644 index 00000000000..ce0b135934b --- /dev/null +++ b/maven/linux/src/test/java/com/codename1/impl/linux/LinuxSocketMetadataTest.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if + * you need additional information or have any questions. + */ +package com.codename1.impl.linux; + +import java.io.IOException; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class LinuxSocketMetadataTest { + @Test + void loopbackCapabilityDoesNotEnableWildcardOrReleaseDebugging() throws Exception { + java.lang.reflect.Field singleton = LinuxImplementation.class.getDeclaredField("INSTANCE"); + singleton.setAccessible(true); + Object previous = singleton.get(null); + try { + LinuxImplementation port = new LinuxImplementation(); + assertTrue(port.isLoopbackServerSocketAvailable()); + assertTrue(port.isTextInputSupported()); + assertFalse(port.isServerSocketAvailable()); + assertFalse(port.isDebuggableBuild()); + for (Object invalid : new Object[] {null, "not a socket"}) { + port.disconnectSocket(invalid); + port.writeToSocketStream(invalid, new byte[0]); + assertFalse(port.isSocketConnected(invalid)); + assertEquals(0, port.getSocketAvailableInput(invalid)); + assertNull(port.readFromSocketStream(invalid)); + assertNull(port.getSocketErrorMessage(invalid)); + assertEquals(-1, port.getSocketErrorCode(invalid)); + } + } finally { + singleton.set(null, previous); + } + } + + @Test + void emptySocketAndStreamsRemainSafeAfterRepeatedClose() throws Exception { + LinuxSocket socket = new LinuxSocket(0); + assertFalse(socket.isConnected()); + assertEquals(0, socket.available()); + assertNull(socket.readChunk()); + assertEquals(-1, socket.getErrorCode()); + assertNull(socket.getErrorMessage()); + assertEquals(-1, socket.getInputStream().read()); + socket.getInputStream().close(); + socket.getOutputStream().close(); + socket.close(); + try { + socket.getOutputStream().write(1); + fail("closed output must reject a write"); + } catch (IOException expected) { + assertEquals("socket closed", expected.getMessage()); + } + } +} diff --git a/maven/linux/src/test/java/com/codename1/impl/linux/LinuxTextInputTest.java b/maven/linux/src/test/java/com/codename1/impl/linux/LinuxTextInputTest.java new file mode 100644 index 00000000000..11b7e2c828e --- /dev/null +++ b/maven/linux/src/test/java/com/codename1/impl/linux/LinuxTextInputTest.java @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2026, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.impl.linux; + +import com.codename1.ui.TextInputState; +import com.codename1.ui.TextInputClient; +import com.codename1.ui.TextInputConfig; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class LinuxTextInputTest { + @Test + void gtkCharacterOffsetsBecomeUtf16WithoutSplittingSurrogatePairs() { + String text = "A\ud83d\ude00e\u0301Z"; + assertEquals(3, LinuxTextInput.moveCodePoints(text, 0, 2)); + assertEquals(1, LinuxTextInput.moveCodePoints(text, 3, -1)); + assertEquals(5, LinuxTextInput.moveCodePoints(text, 3, 2)); + assertEquals(3, LinuxTextInput.moveCodePoints(text, 5, -2)); + } + + @Test + void surroundingRequestsClampToTheAvailableSnapshot() { + String text = "A\ud83d\ude00B"; + assertEquals(0, LinuxTextInput.moveCodePoints(text, 4, Integer.MIN_VALUE)); + assertEquals(4, LinuxTextInput.moveCodePoints(text, 0, Integer.MAX_VALUE)); + assertEquals(0, LinuxTextInput.moveCodePoints("", 0, 1)); + } + + @Test + void oversizedDeletionBeforeCaretCannotEraseTextAfterIt() { + TextInputState state = new TextInputState("A\ud83d\ude00B", 3, 3); + assertArrayEquals(new int[] {2, 0}, LinuxTextInput.surroundingDeletion(state, -1, 1)); + assertArrayEquals(new int[] {3, 0}, LinuxTextInput.surroundingDeletion(state, -100, 100)); + assertArrayEquals(new int[] {2, 1}, LinuxTextInput.surroundingDeletion(state, -1, 2)); + assertNull(LinuxTextInput.surroundingDeletion(state, 1, 1)); + } + + @Test + void absentOrStaleHandlesCannotInvokeNativeStateChanges() { + LinuxTextInput input = new LinuxTextInput(); + input.stop(null); + input.stop(new Object()); + input.update(new Object(), new TextInputState("ignored", 0, 0)); + input.update(null, null); + assertThrows(IllegalArgumentException.class, () -> input.start(null, null)); + } + private static class Client implements TextInputClient { + String text = "original"; + int start = 0, end = 8, markedStart = -1, markedEnd = -1; + int finished; + public TextInputState getEditingState() { return new TextInputState(text, start, end, markedStart, markedEnd); } + public String getTextRange(int a, int b) { return text.substring(a, b); } + public void setComposingText(String value, int caret) { + int a = markedStart < 0 ? Math.min(start, end) : markedStart; + int b = markedStart < 0 ? Math.max(start, end) : markedEnd; + text = text.substring(0, a) + value + text.substring(b); + markedStart = a; + markedEnd = a + value.length(); + start = end = a + caret; + } + public void commitText(String value) { setComposingText(value, value.length()); markedStart = markedEnd = -1; } + public void finishComposing() { finished++; markedStart = markedEnd = -1; } + public void setSelectionRange(int a, int b) { start = a; end = b; } + public int getTextLength() { return text.length(); } + public int[] getCaretRect() { return new int[]{0, 0, 1, 1}; } + public int[] rectForOffset(int offset) { return getCaretRect(); } + public int[] selectionRects(int a, int b) { return new int[0]; } + public int offsetAtPoint(int x, int y) { return end; } + public TextInputConfig getConfig() { return new TextInputConfig(); } + public void deleteSurroundingText(int a, int b) { } + public void onKeyCommand(int c, int m) { } + public void onEditorAction(int a) { } + public void inputFocusGained() { } + public void inputFocusLost() { } + public void replaceRange(int a, int b, String value) { } + } + + @Test + void canceledEmptyPreeditRestoresOriginalSelectedText() { + Client client = new Client(); + client.setSelectionRange(8, 0); + LinuxTextInput.Composition composition = new LinuxTextInput.Composition(client); + composition.preedit("replacement", 3); + composition.preedit("", 0); + composition.cancel(); + composition.cancel(); + assertEquals("original", client.text); + assertEquals(8, client.start); + assertEquals(0, client.end); + assertEquals(1, client.finished); + } + + @Test + void commitWorksBeforeOrAfterGtkPreeditEnd() { + for (boolean endFirst : new boolean[]{false, true}) { + Client client = new Client(); + LinuxTextInput.Composition composition = new LinuxTextInput.Composition(client); + composition.preedit("marked", 6); + composition.preedit("", 0); + if (endFirst) { composition.cancel(); } + composition.commit("committed"); + composition.preedit("", 0); + composition.cancel(); + composition.commit("!"); + assertEquals("committed!", client.text); + } + } + + @Test + void richClientsReceiveCancellationInsteadOfTextOnlyRollback() { + class RichClient extends Client implements TextInputClient.CompositionCancellation { + int cancellations; + public void cancelComposing() { cancellations++; } + } + RichClient client = new RichClient(); + LinuxTextInput.Composition composition = new LinuxTextInput.Composition(client); + composition.preedit("", 0); + composition.cancel(); + assertEquals(0, client.cancellations); + composition.preedit("marked", 6); + composition.cancel(); + assertEquals(1, client.cancellations); + assertEquals(0, client.finished); + } + +} diff --git a/maven/linux/src/test/native/LinuxSocketTest.c b/maven/linux/src/test/native/LinuxSocketTest.c new file mode 100644 index 00000000000..e59db815a5b --- /dev/null +++ b/maven/linux/src/test/native/LinuxSocketTest.c @@ -0,0 +1,259 @@ +/* + * 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. + */ +/* Local POSIX regression test. Includes the production bridge with only the + * Java ABI/GC hooks stubbed; run the application separately to test ParparVM GC. */ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#define CN1_LINUX_H +#define CODENAME_ONE_THREAD_STATE void* threadStateData +typedef void* JAVA_OBJECT; +typedef int64_t JAVA_LONG; +typedef int JAVA_INT; +typedef int JAVA_BOOLEAN; +#define JAVA_VOID void +#define JAVA_NULL NULL +#define JAVA_TRUE 1 +#define JAVA_FALSE 0 +#define CN1_YIELD_THREAD do { if (threadStateData) { atomic_store((atomic_int*) threadStateData, 1); } } while (0) +#define CN1_RESUME_THREAD ((void) 0) +typedef struct { int length; unsigned char data[]; } TestArray; +#define CN1_ARRAY_DATA(a) (((TestArray*) (a))->data) +#define CN1_ARRAY_LENGTH(a) (((TestArray*) (a))->length) +#include "../../../../../Ports/LinuxPort/nativeSources/cn1_linux_socket.c" +JAVA_OBJECT newStringFromCString(void* state, const char* s) { return strdup(s); } +const char* stringToUTF8(void* state, JAVA_OBJECT s) { return (const char*) s; } +#define ACCEPT com_codename1_impl_linux_LinuxNative_socketListenLoopback___int_R_long +#define STOP com_codename1_impl_linux_LinuxNative_socketStopListening___int +#define CONNECT com_codename1_impl_linux_LinuxNative_socketConnect___java_lang_String_int_int_R_long +#define READ com_codename1_impl_linux_LinuxNative_socketRead___long_byte_1ARRAY_int_int_R_int +#define WRITE com_codename1_impl_linux_LinuxNative_socketWrite___long_byte_1ARRAY_int_int_R_int +#define CLOSE com_codename1_impl_linux_LinuxNative_socketClose___long +#define CONNECTED com_codename1_impl_linux_LinuxNative_socketConnected___long_R_boolean +#define ERROR com_codename1_impl_linux_LinuxNative_socketErrorCode___long_R_int + +static volatile sig_atomic_t receivedSignals; +static void interruption(int signalNumber) { (void) signalNumber; receivedSignals++; } + +typedef struct { int port; JAVA_LONG token; TestArray* array; int result; atomic_int parked; } Job; +static void* accepting(void* p) { Job* j = p; j->token = ACCEPT(&j->parked, j->port); return NULL; } +static void* reading(void* p) { Job* j = p; j->result = READ(&j->parked, j->token, j->array, 0, j->array->length); return NULL; } +static void* writing(void* p) { Job* j = p; j->result = WRITE(&j->parked, j->token, j->array, 0, j->array->length); return NULL; } +static void join(pthread_t t) { + struct timespec end; + clock_gettime(CLOCK_REALTIME, &end); + end.tv_sec += 4; + assert(pthread_timedjoin_np(t, NULL, &end) == 0); +} +static TestArray* array(int size) { + TestArray* a = calloc(1, sizeof(TestArray) + size); + assert(a); + a->length = size; + return a; +} +static struct sockaddr_in address(int port, uint32_t ip) { + struct sockaddr_in a; + memset(&a, 0, sizeof(a)); + a.sin_family = AF_INET; + a.sin_port = htons(port); + a.sin_addr.s_addr = htonl(ip); + return a; +} +static int freePort(void) { + struct sockaddr_in a = address(0, INADDR_LOOPBACK); + socklen_t size = sizeof(a); + int fd = socket(AF_INET, SOCK_STREAM, 0); + assert(bind(fd, (struct sockaddr*) &a, sizeof(a)) == 0); + assert(getsockname(fd, (struct sockaddr*) &a, &size) == 0); + close(fd); + return ntohs(a.sin_port); +} +static void waitParked(Job* job) { + int i; + for (i = 0; i < 2000; i++) { + if (atomic_load(&job->parked)) { return; } + usleep(1000); + } + assert(!"native operation did not reach its blocking/GC boundary"); +} +static int rawConnect(int port, uint32_t ip) { + struct sockaddr_in a = address(port, ip); + struct timeval timeout = {3, 0}; + int fd = socket(AF_INET, SOCK_STREAM, 0); + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + if (connect(fd, (struct sockaddr*) &a, sizeof(a)) != 0) { close(fd); return -1; } + return fd; +} +static int pair(Job* j) { + pthread_t t; + int fd; + atomic_store(&j->parked, 0); + assert(pthread_create(&t, NULL, accepting, j) == 0); + waitParked(j); + /* A wildcard listener would also accept this second loopback address. */ + assert(rawConnect(j->port, INADDR_LOOPBACK + 1) < 0); + fd = rawConnect(j->port, INADDR_LOOPBACK); + assert(fd >= 0); + join(t); + assert(j->token > 0 && CONNECTED(NULL, j->token)); + return fd; +} +static int descriptorCount(void) { + DIR* d = opendir("/proc/self/fd"); + int count = 0; + assert(d); + while (readdir(d)) { count++; } + closedir(d); + return count; +} +int main(void) { + int initial = descriptorCount(), i; + Job j = {0}; + TestArray* a = array(32); + pthread_t thread; + signal(SIGPIPE, SIG_DFL); + assert(ACCEPT(NULL, -1) == 0 && ACCEPT(NULL, 65536) == 0); + assert(!CONNECTED(NULL, 0) && ERROR(NULL, 0) == -1); + CLOSE(NULL, 0); + /* Bind failure reports a null token without allocating a listener. */ + { + int occupied = socket(AF_INET, SOCK_STREAM, 0); + struct sockaddr_in addr; + j.port = freePort(); addr = address(j.port, INADDR_ANY); + assert(bind(occupied, (struct sockaddr*) &addr, sizeof(addr)) == 0); + assert(listen(occupied, 4) == 0); + assert(ACCEPT(NULL, j.port) == 0); + close(occupied); + } + /* Stop cancels a blocked accept and permits immediate restart. */ + j.port = freePort(); + for (i = 0; i < 40; i++) { + j.token = -1; + atomic_store(&j.parked, 0); + assert(pthread_create(&thread, NULL, accepting, &j) == 0); + waitParked(&j); + STOP(NULL, j.port); + join(thread); + assert(j.token == 0); + assert(rawConnect(j.port, INADDR_LOOPBACK) < 0); + } + /* Closing a peer wakes blocked reads. A stale token cannot affect its replacement. */ + for (i = 0; i < 60; i++) { + int fd = pair(&j); + JAVA_LONG old = j.token; + j.array = a; + atomic_store(&j.parked, 0); + assert(pthread_create(&thread, NULL, reading, &j) == 0); + waitParked(&j); + CLOSE(NULL, old); CLOSE(NULL, old); + join(thread); + assert(j.result == -1 && !CONNECTED(NULL, old)); + close(fd); + fd = pair(&j); + assert(j.token != old); + CLOSE(NULL, old); + assert(READ(NULL, old, a, 0, 1) == -1); + memcpy(a->data, "abcdef", 6); + assert(WRITE(NULL, j.token, a, 2, 4) == 4); + assert(recv(fd, a->data, 4, MSG_WAITALL) == 4); + assert(memcmp(a->data, "cdef", 4) == 0); + assert(WRITE(NULL, j.token, a, 32, 1) == -1); + assert(READ(NULL, j.token, a, -1, 1) == -1); + assert(WRITE(NULL, j.token, a, 0, 0) == 0); + assert(READ(NULL, j.token, a, 0, 0) == 0); + STOP(NULL, j.port); /* accepted connection survives listener shutdown */ + assert(send(fd, "ping", 4, MSG_NOSIGNAL) == 4); + assert(READ(NULL, j.token, a, 1, 4) == 4); + assert(memcmp(a->data + 1, "ping", 4) == 0); + close(fd); + assert(READ(NULL, j.token, a, 0, 1) == -1); + CLOSE(NULL, j.token); + } + /* An interrupted blocking read resumes without dropping the connection. */ + { + struct sigaction action; + int fd = pair(&j); + memset(&action, 0, sizeof(action)); + action.sa_handler = interruption; + sigemptyset(&action.sa_mask); + assert(sigaction(SIGUSR1, &action, NULL) == 0); + j.array = a; + atomic_store(&j.parked, 0); + assert(pthread_create(&thread, NULL, reading, &j) == 0); + waitParked(&j); + for (i = 0; i < 5; i++) { + usleep(10000); + assert(pthread_kill(thread, SIGUSR1) == 0); + } + assert(send(fd, "again", 5, MSG_NOSIGNAL) == 5); + join(thread); + assert(receivedSignals > 0 && j.result == 5 && memcmp(a->data, "again", 5) == 0); + CLOSE(NULL, j.token); close(fd); STOP(NULL, j.port); + } + /* Large blocked sends are cancelled; SIGPIPE remains suppressed after reset. */ + { + int fd = pair(&j); + TestArray* large = array(16 * 1024 * 1024); + j.array = large; + atomic_store(&j.parked, 0); + assert(pthread_create(&thread, NULL, writing, &j) == 0); + waitParked(&j); + usleep(30000); + CLOSE(NULL, j.token); + join(thread); + assert(j.result < large->length); + close(fd); free(large); STOP(NULL, j.port); + fd = pair(&j); + { + struct linger reset = {1, 0}; + setsockopt(fd, SOL_SOCKET, SO_LINGER, &reset, sizeof(reset)); + } + close(fd); usleep(10000); + assert(WRITE(NULL, j.token, a, 0, 32) < 32); + assert(WRITE(NULL, j.token, a, 0, 32) < 32); + CLOSE(NULL, j.token); STOP(NULL, j.port); + } + /* Existing outbound clients still use the same native bridge. */ + { + int listener = socket(AF_INET, SOCK_STREAM, 0), fd; + struct sockaddr_in addr; + j.port = freePort(); addr = address(j.port, INADDR_LOOPBACK); + assert(bind(listener, (struct sockaddr*) &addr, sizeof(addr)) == 0); + assert(listen(listener, 4) == 0); + j.token = CONNECT(NULL, "127.0.0.1", j.port, 0); + assert(j.token > 0); + fd = accept(listener, NULL, NULL); assert(fd >= 0); + assert(send(fd, "client", 6, MSG_NOSIGNAL) == 6); + assert(READ(NULL, j.token, a, 0, 6) == 6); + assert(memcmp(a->data, "client", 6) == 0); + CLOSE(NULL, j.token); close(fd); close(listener); + } + free(a); + assert(descriptorCount() == initial); + puts("PASS: loopback binding, conflict, 40 accept cancellations/restarts, 60 read-close/stale-token cycles, interrupted read, blocked write, SIGPIPE, bounds, outbound client, descriptor cleanup"); + return 0; +} diff --git a/maven/linux/src/test/native/LinuxTextInputTest.c b/maven/linux/src/test/native/LinuxTextInputTest.c new file mode 100644 index 00000000000..cfbec1fae0f --- /dev/null +++ b/maven/linux/src/test/native/LinuxTextInputTest.c @@ -0,0 +1,226 @@ +/* + * 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. + */ +/* Local GTK input-method integration. Java ABI hooks are stubbed; GTK is real. */ +#include +#include +#include +#define CN1_LINUX_GFX_H +#define CN1_MAIN_WINDOW_SLOT (-1) +#define CODENAME_ONE_THREAD_STATE void* threadStateData +typedef void* JAVA_OBJECT; +typedef int JAVA_INT; +typedef int JAVA_BOOLEAN; +#define JAVA_VOID void +#define JAVA_NULL NULL +#define JAVA_TRUE 1 +#define JAVA_FALSE 0 +static GtkWidget *testWindow, *testCanvas; +GtkWidget* cn1LinuxWindowWidget(void) { return testWindow; } +GtkWidget* cn1LinuxDesktopWidget(int slot) { return slot == 4 ? testWindow : NULL; } +GtkWidget* cn1LinuxTextCanvas(int slot) { return slot == -1 || slot == 4 ? testCanvas : NULL; } +void cn1LinuxRunOnMainAndWait(void (*fn)(void*), void* argument) { fn(argument); } +const char* stringToUTF8(void* state, JAVA_OBJECT text) { return text; } +JAVA_OBJECT newStringFromUtf8Len(void* state, const char* text, int length) { return g_strndup(text, length); } +#include "../../../../../Ports/LinuxPort/nativeSources/cn1_linux_text_input.c" +#define START com_codename1_impl_linux_LinuxTextInput_startNative___int_int_int_boolean_boolean_R_boolean +#define UPDATE com_codename1_impl_linux_LinuxTextInput_updateNative___int_int_java_lang_String_int_int_int_int_int +#define STOP com_codename1_impl_linux_LinuxTextInput_stopNative___int +#define POLL com_codename1_impl_linux_LinuxTextInput_pollNative___R_java_lang_String +static void clearEvents(void) { char* event; while ((event = POLL(NULL))) { g_free(event); } } +static void key(int slot, guint value, GdkModifierType state, GdkEventType type) { + GdkEventKey event; + memset(&event, 0, sizeof(event)); + event.type = type; + event.window = gtk_widget_get_window(testCanvas); + event.keyval = value; + event.state = state; + event.send_event = TRUE; + cn1LinuxTextInputFilter(slot, &event); +} +static char* expectKind(int kind, const char* suffix) { + char* event = POLL(NULL); + int id, seq, actual, value, modifiers, consumed = 0; + assert(event); + assert(sscanf(event, "%d;%d;%d;%d;%d;%n", &id, &seq, &actual, &value, &modifiers, &consumed) == 5); + assert(actual == kind); + if (suffix) { assert(strcmp(event + consumed, suffix) == 0); } + return event; +} +int main(int argc, char** argv) { + char* event; + GdkEventFocus focus; + gtk_init(&argc, &argv); + testWindow = gtk_window_new(GTK_WINDOW_TOPLEVEL); + testCanvas = gtk_drawing_area_new(); + gtk_container_add(GTK_CONTAINER(testWindow), testCanvas); + /* Focus starts before Window.show() maps its peer. */ + assert(!gtk_widget_get_realized(testCanvas)); + assert(START(NULL, 1, -1, 0, 1, 1)); + assert(gtk_widget_get_realized(testCanvas)); + assert(!gtk_widget_get_visible(testWindow)); + STOP(NULL, 1); + clearEvents(); + gtk_widget_show_all(testWindow); + while (gtk_events_pending()) { gtk_main_iteration(); } + assert(START(NULL, 1, -1, 0, 1, 1)); + UPDATE(NULL, 1, 0, "A😀B", 3, 10, 20, 2, 18); + assert(cn1TextInput.caretBytes == 5); + assert(cn1TextSurrounding(cn1TextInput.im, NULL)); + key(-1, GDK_KEY_a, 0, GDK_KEY_PRESS); + event = expectKind(0, "a"); g_free(event); + assert(!cn1TextSurrounding(cn1TextInput.im, NULL)); + UPDATE(NULL, 1, 0, "stale", 0, 0, 0, 1, 1); + assert(!cn1TextInput.surroundingValid); + UPDATE(NULL, 1, cn1TextInput.sequence, "A😀aB", 4, 10, 20, 2, 18); + assert(cn1TextInput.surroundingValid && cn1TextInput.caretBytes == 6); + assert(!cn1TextDelete(cn1TextInput.im, 1, 1, NULL)); + assert(cn1TextDelete(cn1TextInput.im, -1, 1, NULL)); + event = expectKind(5, ""); assert(strstr(event, ";5;-1;1;")); g_free(event); + key(-1, GDK_KEY_z, GDK_CONTROL_MASK | GDK_SHIFT_MASK, GDK_KEY_PRESS); + event = expectKind(3, ""); assert(strstr(event, ";3;17;3;")); g_free(event); + key(-1, GDK_KEY_Left, GDK_SHIFT_MASK, GDK_KEY_PRESS); + event = expectKind(3, ""); assert(strstr(event, ";3;1;1;")); g_free(event); + key(-1, GDK_KEY_Tab, 0, GDK_KEY_PRESS); + event = expectKind(3, ""); assert(strstr(event, ";3;18;0;")); g_free(event); + key(-1, GDK_KEY_Return, 0, GDK_KEY_PRESS); + event = expectKind(4, ""); g_free(event); + /* The real GtkIMContextSimple dead-key path must yield a composed commit. */ + key(-1, GDK_KEY_dead_acute, 0, GDK_KEY_PRESS); + key(-1, GDK_KEY_e, 0, GDK_KEY_PRESS); + int composed = 0, preedit = 0; + while ((event = POLL(NULL))) { + int id, seq, kind, value, mods, consumed = 0; + assert(sscanf(event, "%d;%d;%d;%d;%d;%n", &id, &seq, &kind, &value, &mods, &consumed) == 5); + if (kind == 1) { preedit++; } + if (kind == 0 && strcmp(event + consumed, "é") == 0) { composed++; } + g_free(event); + } + assert(composed == 1 && preedit > 0); + /* Actual GTK cancellation must end the preedit without a commit. */ + for (int mode = 0; mode < 2; mode++) { + key(-1, GDK_KEY_Multi_key, 0, GDK_KEY_PRESS); + key(-1, GDK_KEY_apostrophe, 0, GDK_KEY_PRESS); + assert(cn1TextInput.preediting); + clearEvents(); + if (mode == 0) { + key(-1, GDK_KEY_Escape, 0, GDK_KEY_PRESS); + } else { + memset(&focus, 0, sizeof(focus)); + cn1TextFocus(testWindow, &focus, NULL); + focus.in = TRUE; + cn1TextFocus(testWindow, &focus, NULL); + } + int ended = 0; + while ((event = POLL(NULL))) { + int id, seq, kind; + assert(sscanf(event, "%d;%d;%d;", &id, &seq, &kind) == 3); + if (kind == 0) { fprintf(stderr, "cancel mode %d event: %s\n", mode, event); } + assert(kind != 0); + if (kind == 2) { ended++; } + g_free(event); + } + assert(ended > 0 && !cn1TextInput.preediting); + } + assert(START(NULL, 2, 4, 0x10000 | 0x80000, 1, 1)); + GtkInputHints hints; + GtkInputPurpose purpose; + g_object_get(cn1TextInput.im, "input-hints", &hints, "input-purpose", &purpose, NULL); + assert((hints & GTK_INPUT_HINT_NO_SPELLCHECK) && purpose == GTK_INPUT_PURPOSE_PASSWORD); + UPDATE(NULL, 2, 0, "secret", 6, 0, 0, 1, 1); + assert(!cn1TextSurrounding(cn1TextInput.im, NULL)); + STOP(NULL, 1); assert(cn1TextInput.session == 2); + key(-1, GDK_KEY_x, 0, GDK_KEY_PRESS); assert(!POLL(NULL)); + key(4, GDK_KEY_x, 0, GDK_KEY_PRESS); event = expectKind(0, "x"); g_free(event); + memset(&focus, 0, sizeof(focus)); focus.in = FALSE; + cn1TextFocus(testWindow, &focus, NULL); + assert(!POLL(NULL)); /* Window deactivation does not end the client session. */ + key(4, GDK_KEY_x, 0, GDK_KEY_PRESS); assert(!POLL(NULL)); + focus.in = TRUE; + cn1TextFocus(testWindow, &focus, NULL); + assert(!POLL(NULL)); + key(4, GDK_KEY_y, 0, GDK_KEY_PRESS); event = expectKind(0, "y"); g_free(event); + /* SENSITIVE withholds context and requests no spelling/completion, but keeps normal IME purpose/composition. */ + assert(START(NULL, 3, -1, 0x40000, 1, 1)); + clearEvents(); + g_object_get(cn1TextInput.im, "input-hints", &hints, "input-purpose", &purpose, NULL); + assert(purpose == GTK_INPUT_PURPOSE_FREE_FORM); + assert((hints & GTK_INPUT_HINT_NO_SPELLCHECK) && !(hints & GTK_INPUT_HINT_SPELLCHECK)); + assert(!(hints & GTK_INPUT_HINT_WORD_COMPLETION)); + UPDATE(NULL, 3, 0, "private identifier", 3, 0, 0, 1, 1); + assert(!cn1TextSurrounding(cn1TextInput.im, NULL)); + key(-1, GDK_KEY_dead_acute, 0, GDK_KEY_PRESS); + key(-1, GDK_KEY_e, 0, GDK_KEY_PRESS); + composed = 0; + while ((event = POLL(NULL))) { + int id, seq, kind, value, mods, consumed = 0; + assert(sscanf(event, "%d;%d;%d;%d;%d;%n", &id, &seq, &kind, &value, &mods, &consumed) == 5); + if (kind == 0 && strcmp(event + consumed, "é") == 0) { composed++; } + g_free(event); + } + assert(composed == 1); + assert(START(NULL, 4, -1, 1 | 0x40000 | 0x80000, 1, 1)); + g_object_get(cn1TextInput.im, "input-hints", &hints, "input-purpose", &purpose, NULL); + assert(purpose == GTK_INPUT_PURPOSE_EMAIL); + assert(!(hints & GTK_INPUT_HINT_WORD_COMPLETION)); + /* Explicit capitalization takes precedence over the boolean fallback. + * Privacy and non-predictive constraints must not discard explicit case hints. */ + const int capsConstraints[] = {0x100000, 0x200000, 0x800000, + 0x800000 | 0x100000 | 0x200000, 0x100000 | 0x80000, + 0x100000 | 0x40000, 0, 0}; + const GtkInputHints capsExpected[] = {GTK_INPUT_HINT_UPPERCASE_WORDS, + GTK_INPUT_HINT_UPPERCASE_SENTENCES, GTK_INPUT_HINT_UPPERCASE_CHARS, + GTK_INPUT_HINT_UPPERCASE_CHARS, GTK_INPUT_HINT_UPPERCASE_WORDS, + GTK_INPUT_HINT_UPPERCASE_WORDS, GTK_INPUT_HINT_UPPERCASE_SENTENCES, 0}; + const GtkInputHints capsMask = GTK_INPUT_HINT_UPPERCASE_WORDS + | GTK_INPUT_HINT_UPPERCASE_SENTENCES | GTK_INPUT_HINT_UPPERCASE_CHARS; + for (int i = 0; i < 8; i++) { + /* Explicit flags also apply when autoCapitalize is false. */ + assert(START(NULL, 5 + i, -1, capsConstraints[i], 1, i == 6)); + g_object_get(cn1TextInput.im, "input-hints", &hints, NULL); + assert((hints & capsMask) == capsExpected[i]); + } + assert(START(NULL, 13, -1, 0x100000, 1, 1)); + g_object_get(cn1TextInput.im, "input-hints", &hints, NULL); + assert((hints & capsMask) == GTK_INPUT_HINT_UPPERCASE_WORDS); + assert(START(NULL, 14, -1, 0, 1, 0)); + g_object_get(cn1TextInput.im, "input-hints", &hints, NULL); + assert((hints & GTK_INPUT_HINT_SPELLCHECK) && (hints & GTK_INPUT_HINT_WORD_COMPLETION)); + assert(!(hints & GTK_INPUT_HINT_NO_SPELLCHECK)); + const int privateConstraints[] = {0, 0x80000, 0x40000, 0x10000, 2 | 0x10000}; + for (int i = 0; i < 5; i++) { + assert(START(NULL, 15 + i, -1, privateConstraints[i], i != 0, 1)); + g_object_get(cn1TextInput.im, "input-hints", &hints, "input-purpose", &purpose, NULL); + assert(hints & GTK_INPUT_HINT_NO_SPELLCHECK); + assert(!(hints & (GTK_INPUT_HINT_SPELLCHECK | GTK_INPUT_HINT_WORD_COMPLETION))); + if (i == 3) { assert(purpose == GTK_INPUT_PURPOSE_PASSWORD); } + if (i == 4) { assert(purpose == GTK_INPUT_PURPOSE_PIN); } + } + for (int i = 20; i < 120; i++) { + assert(START(NULL, i, -1, 0, 1, 1)); + STOP(NULL, i); STOP(NULL, i); + assert(!cn1TextInput.im && !cn1TextInput.session); + } + clearEvents(); gtk_widget_destroy(testWindow); + puts("PASS: real GTK commit/dead-key composition, UTF-16 caret, stale-state rejection, delete-surrounding, navigation/shortcuts, owner-window routing, privacy/completion/PIN/capitalization hints, composition cancellation, focus loss, 100 session replacements/stops"); + return 0; +} diff --git a/maven/linux/src/test/native/run-linux-socket-tests.sh b/maven/linux/src/test/native/run-linux-socket-tests.sh new file mode 100755 index 00000000000..13150ff3788 --- /dev/null +++ b/maven/linux/src/test/native/run-linux-socket-tests.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Local only. Compile the actual POSIX bridge with a minimal Java ABI shim. +set -euo pipefail +if [[ -n "${CI:-}" ]]; then echo 'Native tests are local only.' >&2; exit 1; fi +if [[ "$(uname -s)" != Linux ]]; then echo 'Run on Linux.' >&2; exit 1; fi +cd "${BASH_SOURCE[0]%/*}" +cn1_socket_test_dir=$(mktemp -d "${TMPDIR:-/tmp}/cn1-linux-socket-tests.XXXXXXXX") +trap 'rm -rf "$cn1_socket_test_dir"' EXIT +"${CC:-cc}" -std=c11 -O1 -g -Wall -Wextra -Werror -Wno-unused-parameter \ + -fsanitize=address,undefined -fno-omit-frame-pointer -pthread \ + LinuxSocketTest.c -o "$cn1_socket_test_dir/socket-tests" +ASAN_OPTIONS=detect_leaks=1 "$cn1_socket_test_dir/socket-tests" diff --git a/maven/linux/src/test/native/run-linux-text-input-tests.sh b/maven/linux/src/test/native/run-linux-text-input-tests.sh new file mode 100755 index 00000000000..c6f8b6e77e0 --- /dev/null +++ b/maven/linux/src/test/native/run-linux-text-input-tests.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail +if [[ -n "${CI:-}" ]]; then echo 'Native tests are local only.' >&2; exit 1; fi +if [[ "$(uname -s)" != Linux ]]; then echo 'Run on Linux with GTK 3 and Xvfb.' >&2; exit 1; fi +cd "${BASH_SOURCE[0]%/*}" +cn1_input_test_dir=$(mktemp -d "${TMPDIR:-/tmp}/cn1-linux-text-input.XXXXXXXX") +trap 'rm -rf "$cn1_input_test_dir"' EXIT +"${CC:-cc}" -std=c11 -O1 -g -Wall -Wextra -Werror -Wno-unused-parameter \ + -fsanitize=address,undefined -fno-omit-frame-pointer \ + $(pkg-config --cflags gtk+-3.0) LinuxTextInputTest.c \ + $(pkg-config --libs gtk+-3.0) -o "$cn1_input_test_dir/input-tests" +# GTK/fontconfig maintain process-wide allocations. Explicit input lifecycle is +# checked above; leak detection belongs in a non-GTK peer allocation test. +GTK_IM_MODULE=simple ASAN_OPTIONS=detect_leaks=0 xvfb-run -a "$cn1_input_test_dir/input-tests"