/* * ElBridge.java — Android Java companion to el_android.c. * * All public methods are static. The C JNI layer calls these to create views, * set properties, and manage the widget tree. Views are identified by integer * slot indices matching the C-side handle values. * * Threading: every method that touches a View dispatches to the UI thread * using Activity.runOnUiThread(Runnable) and blocks with a CountDownLatch * until the UI thread completes the operation. This mirrors the AppKit * dispatch_sync(main_queue, ^{}) pattern in el_appkit.m. * * Callbacks: Java sets listeners on views that call back into C via: * nativeOnClick(int slot) * nativeOnChange(int slot, String text) * nativeOnSubmit(int slot, String text) * These are declared native and implemented in el_android.c. * * Usage (in your Activity.onCreate): * System.loadLibrary("elruntime"); * ElBridge.init(this); * * The native library calls __native_init() which calls nativeRegisterActivity * via the C side; alternatively call ElBridge.init(this) directly from Java. * * Compile requirements: * Android minSdkVersion 21 (Lollipop) or higher. * No third-party dependencies — uses only android.* framework classes. * For image loading from arbitrary file paths, BitmapFactory is used. * To replace with Glide/Picasso, edit createImageView only. */ package com.neuron.el; import android.app.Activity; import android.content.Context; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.graphics.Color; import android.graphics.Typeface; import android.graphics.drawable.GradientDrawable; import android.os.Handler; import android.os.Looper; import android.text.Editable; import android.text.InputType; import android.text.TextWatcher; import android.view.Gravity; import android.view.View; import android.view.ViewGroup; import android.widget.Button; import android.widget.EditText; import android.widget.FrameLayout; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.ScrollView; import android.widget.TextView; import java.util.concurrent.CountDownLatch; public class ElBridge { /* ── Native callbacks (implemented in el_android.c) ─────────────────── */ public static native void nativeOnClick(int slot); public static native void nativeOnChange(int slot, String text); public static native void nativeOnSubmit(int slot, String text); public static native void nativeRegisterActivity(Activity activity); /* ── State ───────────────────────────────────────────────────────────── */ private static final int MAX_SLOTS = 4096; private static Activity sActivity; private static Handler sUiHandler; private static View[] sViews = new View[MAX_SLOTS]; private static int sNextSlot = 1; /* slot 0 reserved / null */ /* ── Init ────────────────────────────────────────────────────────────── */ /** * Must be called from the Activity before any widget operations. * Typically called from Activity.onCreate after System.loadLibrary. */ public static void init(Activity activity) { sActivity = activity; sUiHandler = new Handler(Looper.getMainLooper()); nativeRegisterActivity(activity); } /* ── Slot management ─────────────────────────────────────────────────── */ private static int allocSlot(View v) { /* Find a free slot starting from sNextSlot, wrap around. */ for (int i = 0; i < MAX_SLOTS - 1; i++) { int idx = ((sNextSlot - 1 + i) % (MAX_SLOTS - 1)) + 1; if (sViews[idx] == null) { sViews[idx] = v; sNextSlot = (idx % (MAX_SLOTS - 1)) + 1; return idx; } } android.util.Log.e("ElBridge", "allocSlot: slot table full"); return -1; } private static View getView(int slot) { if (slot <= 0 || slot >= MAX_SLOTS) return null; return sViews[slot]; } /* ── UI-thread dispatch helper ───────────────────────────────────────── */ /* * Dispatch r on the UI thread and block until it completes. * Safe to call from the UI thread itself (runs inline without posting). */ private static void runSync(final Runnable r) { if (Looper.myLooper() == Looper.getMainLooper()) { r.run(); } else { final CountDownLatch latch = new CountDownLatch(1); sUiHandler.post(new Runnable() { @Override public void run() { try { r.run(); } finally { latch.countDown(); } } }); try { latch.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } /* ── Integer slot returning runSync helper ───────────────────────────── */ private interface IntSupplier { int get(); } private static int runSyncInt(final IntSupplier s) { final int[] result = { -1 }; runSync(new Runnable() { @Override public void run() { result[0] = s.get(); } }); return result[0]; } /* ── Context accessor ────────────────────────────────────────────────── */ private static Context ctx() { return sActivity; } /* ── View creation ───────────────────────────────────────────────────── */ /** * Create a LinearLayout. * @param orientation 1=VERTICAL, 0=HORIZONTAL * @param spacing gap between children in dp; applied as bottom/right margin */ public static int createLinearLayout(final int orientation, final int spacing) { return runSyncInt(new IntSupplier() { @Override public int get() { LinearLayout ll = new LinearLayout(ctx()); ll.setOrientation(orientation == 1 ? LinearLayout.VERTICAL : LinearLayout.HORIZONTAL); ll.setLayoutParams(new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); /* Spacing is stored so addChild can apply margins. */ ll.setTag(R_TAG_SPACING, spacing); return allocSlot(ll); } }); } /** Create a FrameLayout (ZStack equivalent). */ public static int createFrameLayout() { return runSyncInt(new IntSupplier() { @Override public int get() { FrameLayout fl = new FrameLayout(ctx()); fl.setLayoutParams(new FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); return allocSlot(fl); } }); } /** Create a ScrollView. */ public static int createScrollView() { return runSyncInt(new IntSupplier() { @Override public int get() { ScrollView sv = new ScrollView(ctx()); sv.setLayoutParams(new ScrollView.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); sv.setFillViewport(true); return allocSlot(sv); } }); } /** Create a TextView with initial text. */ public static int createTextView(final String text) { return runSyncInt(new IntSupplier() { @Override public int get() { TextView tv = new TextView(ctx()); tv.setText(text != null ? text : ""); tv.setLayoutParams(new LinearLayout.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); return allocSlot(tv); } }); } /** Create a Button with a label. */ public static int createButton(final String label) { return runSyncInt(new IntSupplier() { @Override public int get() { Button btn = new Button(ctx()); btn.setText(label != null ? label : ""); btn.setLayoutParams(new LinearLayout.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); return allocSlot(btn); } }); } /** * Create an EditText. * @param placeholder hint text * @param singleLine true = single-line text field; false = multi-line text area */ public static int createEditText(final String placeholder, final boolean singleLine) { return runSyncInt(new IntSupplier() { @Override public int get() { EditText et = new EditText(ctx()); et.setHint(placeholder != null ? placeholder : ""); if (singleLine) { et.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS); et.setMaxLines(1); et.setSingleLine(true); } else { et.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE); et.setMinLines(3); et.setSingleLine(false); et.setGravity(Gravity.TOP | Gravity.START); } et.setLayoutParams(new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); return allocSlot(et); } }); } /** * Create an ImageView, loading from a file path via BitmapFactory. * If path is null/empty the ImageView is created with no image. */ public static int createImageView(final String path) { return runSyncInt(new IntSupplier() { @Override public int get() { ImageView iv = new ImageView(ctx()); iv.setScaleType(ImageView.ScaleType.FIT_CENTER); iv.setAdjustViewBounds(true); if (path != null && !path.isEmpty()) { Bitmap bmp = BitmapFactory.decodeFile(path); if (bmp != null) { iv.setImageBitmap(bmp); } else { android.util.Log.w("ElBridge", "createImageView: failed to decode " + path); } } iv.setLayoutParams(new LinearLayout.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)); return allocSlot(iv); } }); } /* ── Window operations ───────────────────────────────────────────────── */ /** Set the Activity's content view to the view at slot. */ public static void setContentView(final int slot) { runSync(new Runnable() { @Override public void run() { View v = getView(slot); if (v != null && sActivity != null) { sActivity.setContentView(v); } } }); } /** Set the Activity title. */ public static void setTitle(final String title) { runSync(new Runnable() { @Override public void run() { if (sActivity != null) { sActivity.setTitle(title != null ? title : ""); } } }); } /* ── Tree operations ─────────────────────────────────────────────────── */ /** * Add child view to parent view. * LinearLayout: child added as arranged child with spacing margin. * ScrollView: child replaces current document view. * FrameLayout / other ViewGroup: plain addView. */ public static void addChild(final int parentSlot, final int childSlot) { runSync(new Runnable() { @Override public void run() { View parent = getView(parentSlot); View child = getView(childSlot); if (parent == null || child == null) return; /* Remove child from existing parent first. */ if (child.getParent() instanceof ViewGroup) { ((ViewGroup) child.getParent()).removeView(child); } if (parent instanceof LinearLayout) { LinearLayout ll = (LinearLayout) parent; Object tag = ll.getTag(R_TAG_SPACING); int spacing = (tag instanceof Integer) ? (Integer) tag : 0; LinearLayout.LayoutParams lp; Object existingLp = child.getLayoutParams(); if (existingLp instanceof LinearLayout.LayoutParams) { lp = (LinearLayout.LayoutParams) existingLp; } else { lp = new LinearLayout.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); } /* Apply spacing as margin on the leading/top edge (after first child). */ if (ll.getChildCount() > 0 && spacing > 0) { int px = dpToPx(spacing); if (ll.getOrientation() == LinearLayout.VERTICAL) { lp.topMargin = px; } else { lp.leftMargin = px; } } child.setLayoutParams(lp); ll.addView(child); } else if (parent instanceof ScrollView) { ScrollView sv = (ScrollView) parent; sv.removeAllViews(); sv.addView(child); } else if (parent instanceof ViewGroup) { ((ViewGroup) parent).addView(child); } } }); } /** Remove child from its parent. */ public static void removeChild(final int parentSlot, final int childSlot) { runSync(new Runnable() { @Override public void run() { View parent = getView(parentSlot); View child = getView(childSlot); if (parent instanceof ViewGroup && child != null) { ((ViewGroup) parent).removeView(child); } } }); } /** Remove the view from its parent and release the slot. */ public static void destroyView(final int slot) { runSync(new Runnable() { @Override public void run() { View v = getView(slot); if (v == null) return; if (v.getParent() instanceof ViewGroup) { ((ViewGroup) v.getParent()).removeView(v); } sViews[slot] = null; } }); } /* ── Property setters ────────────────────────────────────────────────── */ public static void setText(final int slot, final String text) { runSync(new Runnable() { @Override public void run() { View v = getView(slot); String s = text != null ? text : ""; if (v instanceof EditText) { ((EditText) v).setText(s); } else if (v instanceof Button) { ((Button) v).setText(s); } else if (v instanceof TextView) { ((TextView) v).setText(s); } } }); } public static String getText(final int slot) { final String[] result = { "" }; runSync(new Runnable() { @Override public void run() { View v = getView(slot); if (v instanceof TextView) { CharSequence cs = ((TextView) v).getText(); result[0] = cs != null ? cs.toString() : ""; } } }); return result[0]; } /** Set foreground text color. Components in [0,1]. */ public static void setTextColor(final int slot, final float r, final float g, final float b, final float a) { runSync(new Runnable() { @Override public void run() { View v = getView(slot); if (v instanceof TextView) { ((TextView) v).setTextColor(floatToArgb(r, g, b, a)); } } }); } /** Set background color using a GradientDrawable so corner radius is preserved. */ public static void setBackgroundColor(final int slot, final float r, final float g, final float b, final float a) { runSync(new Runnable() { @Override public void run() { View v = getView(slot); if (v == null) return; ensureGradientBackground(v); GradientDrawable gd = (GradientDrawable) v.getBackground(); gd.setColor(floatToArgb(r, g, b, a)); } }); } /** * Set font family and size. * family: "system" or null → system default; otherwise tries to load by name. * bold: if true uses Typeface.BOLD. */ public static void setFont(final int slot, final String family, final int sizeSp, final boolean bold) { runSync(new Runnable() { @Override public void run() { View v = getView(slot); if (!(v instanceof TextView)) return; TextView tv = (TextView) v; Typeface tf; if (family != null && !family.isEmpty() && !family.equals("system")) { Typeface base = Typeface.create(family, bold ? Typeface.BOLD : Typeface.NORMAL); tf = (base != null) ? base : Typeface.defaultFromStyle(bold ? Typeface.BOLD : Typeface.NORMAL); } else { tf = Typeface.defaultFromStyle(bold ? Typeface.BOLD : Typeface.NORMAL); } tv.setTypeface(tf); tv.setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, sizeSp); } }); } /** Set padding in dp. */ public static void setPadding(final int slot, final int top, final int right, final int bottom, final int left) { runSync(new Runnable() { @Override public void run() { View v = getView(slot); if (v != null) { v.setPadding(dpToPx(left), dpToPx(top), dpToPx(right), dpToPx(bottom)); } } }); } /** Set explicit width in dp. Passes MATCH_PARENT for negative values. */ public static void setWidth(final int slot, final int widthDp) { runSync(new Runnable() { @Override public void run() { View v = getView(slot); if (v == null) return; ViewGroup.LayoutParams lp = v.getLayoutParams(); if (lp == null) lp = new ViewGroup.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); lp.width = widthDp < 0 ? ViewGroup.LayoutParams.MATCH_PARENT : dpToPx(widthDp); v.setLayoutParams(lp); } }); } /** Set explicit height in dp. */ public static void setHeight(final int slot, final int heightDp) { runSync(new Runnable() { @Override public void run() { View v = getView(slot); if (v == null) return; ViewGroup.LayoutParams lp = v.getLayoutParams(); if (lp == null) lp = new ViewGroup.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT); lp.height = heightDp < 0 ? ViewGroup.LayoutParams.MATCH_PARENT : dpToPx(heightDp); v.setLayoutParams(lp); } }); } /** * Set flex weight on a child of a LinearLayout. * flex > 0 → weight = flex, width/height = 0dp (expand). * flex == 0 → weight = 0, wrap_content (shrink to content). */ public static void setFlex(final int slot, final int flex) { runSync(new Runnable() { @Override public void run() { View v = getView(slot); if (v == null) return; ViewGroup.LayoutParams lp = v.getLayoutParams(); if (lp instanceof LinearLayout.LayoutParams) { LinearLayout.LayoutParams llp = (LinearLayout.LayoutParams) lp; if (flex > 0) { llp.weight = (float) flex; /* Determine orientation from parent to set 0dp on the right axis. */ if (v.getParent() instanceof LinearLayout) { LinearLayout parent = (LinearLayout) v.getParent(); if (parent.getOrientation() == LinearLayout.VERTICAL) { llp.height = 0; } else { llp.width = 0; } } } else { llp.weight = 0f; } v.setLayoutParams(llp); } } }); } /** Set corner radius in dp using a GradientDrawable background. */ public static void setCornerRadius(final int slot, final float radiusDp) { runSync(new Runnable() { @Override public void run() { View v = getView(slot); if (v == null) return; ensureGradientBackground(v); GradientDrawable gd = (GradientDrawable) v.getBackground(); gd.setCornerRadius(dpToPxF(radiusDp)); } }); } public static void setEnabled(final int slot, final boolean enabled) { runSync(new Runnable() { @Override public void run() { View v = getView(slot); if (v != null) v.setEnabled(enabled); } }); } /** * Show or hide a view. * @param visible true = VISIBLE, false = GONE (matches AppKit setHidden semantics) */ public static void setVisibility(final int slot, final boolean visible) { runSync(new Runnable() { @Override public void run() { View v = getView(slot); if (v != null) v.setVisibility(visible ? View.VISIBLE : View.GONE); } }); } /* ── Event listener registration ─────────────────────────────────────── */ /** Register an OnClickListener that calls back into C nativeOnClick. */ public static void setOnClickListener(final int slot) { runSync(new Runnable() { @Override public void run() { View v = getView(slot); if (v == null) return; final int capturedSlot = slot; v.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { nativeOnClick(capturedSlot); } }); } }); } /** * Register a TextWatcher on an EditText that calls back nativeOnChange * for every text change. */ public static void setOnChangeListener(final int slot) { runSync(new Runnable() { @Override public void run() { View v = getView(slot); if (!(v instanceof EditText)) return; final int capturedSlot = slot; ((EditText) v).addTextChangedListener(new TextWatcher() { @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} @Override public void onTextChanged(CharSequence s, int start, int before, int count) {} @Override public void afterTextChanged(Editable s) { nativeOnChange(capturedSlot, s != null ? s.toString() : ""); } }); } }); } /** * Register an OnEditorActionListener on a single-line EditText that calls * nativeOnSubmit when the user presses the action/enter key. */ public static void setOnSubmitListener(final int slot) { runSync(new Runnable() { @Override public void run() { View v = getView(slot); if (!(v instanceof EditText)) return; final int capturedSlot = slot; ((EditText) v).setOnEditorActionListener( new TextView.OnEditorActionListener() { @Override public boolean onEditorAction(TextView tv, int actionId, android.view.KeyEvent event) { nativeOnSubmit(capturedSlot, tv.getText() != null ? tv.getText().toString() : ""); return true; } }); } }); } /* ── Internal helpers ─────────────────────────────────────────────────── */ /* * Tag key used to stash the spacing value on LinearLayouts so addChild * can apply the correct margin between children. * We use a stable integer resource-id-like value; because we do not have * a resources file here we use View.generateViewId() lazily. */ private static int sSpacingTagKey = 0; private static int R_TAG_SPACING; static { R_TAG_SPACING = View.generateViewId(); } /** Convert dp to pixels using the Activity's display metrics. */ private static int dpToPx(float dp) { if (sActivity == null) return (int) dp; float density = sActivity.getResources().getDisplayMetrics().density; return Math.round(dp * density); } private static float dpToPxF(float dp) { if (sActivity == null) return dp; float density = sActivity.getResources().getDisplayMetrics().density; return dp * density; } /** Convert RGBA float components (0–1) to an Android ARGB int. */ private static int floatToArgb(float r, float g, float b, float a) { int ai = Math.round(a * 255f); int ri = Math.round(r * 255f); int gi = Math.round(g * 255f); int bi = Math.round(b * 255f); return Color.argb(ai, ri, gi, bi); } /** * Ensure the view has a GradientDrawable background so that both color * and corner radius can be set independently. If the current background * is already a GradientDrawable it is reused; otherwise a new transparent * one is installed. */ private static void ensureGradientBackground(View v) { if (!(v.getBackground() instanceof GradientDrawable)) { GradientDrawable gd = new GradientDrawable(); gd.setColor(Color.TRANSPARENT); v.setBackground(gd); } } }