Skip to content

Feature/android support - #41

Merged
heoblitz merged 70 commits into
mainfrom
feature/android-support
Nov 12, 2026
Merged

heoblitz merged 70 commits into
mainfrom
feature/android-support

Conversation

@heoblitz

@heoblitz heoblitz commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

Merge feature/android-support branch

close #17

heoblitz and others added 30 commits October 12, 2026 11:28
refactor: organize iOS SDK under SDK/iOS
Cherry-picked from feature/android-poc (50f232d) without its Android/
preview SDK, which SDK/Android/ replaces. The Mac app connects to one
ADB-forwarded Unix socket named by script/android-connect, checks that
the socket and its directory belong to the user, and rejects a hello
from any app other than the expected one.
SDK/Android is the Gradle root for necto-core (plain JVM: models, transport,
runtime and the shared plugins), necto-android (the ADB listener, identity,
DataStore, process metrics, UI Control), necto-okhttp and a sample that ports
ExampleApp. The package root and Maven group are im.toss.necto.

In a debuggable build the app binds an abstract Unix socket under a random
name, writes the name to no_backup/necto-endpoint and accepts only adbd's
uid, so no network port is opened and no permission is declared. Every
inbound frame, handshake and request is bounded so a hostile peer only loses
its own session.

The panels are generated from the built iOS ones; only the Control and
Preferences manifests are overridden, and the build fails when their
contracts drift.
android-poc becomes android-sample and test-android-poc becomes
test-android-sample, both for im.toss.necto.sample. The end-to-end check now
covers every shipped panel; the artifact check covers the SDK's permissions,
the packaged panels and the debuggable flag. android-connect drops the PoC's
injector hook, and docs/verification.md describes the Android checks.
JSON numbers are doubles, which hold every integer only below 2^53. A
larger Long was listed as an Int, so the panel parsed it into a rounded
number and saving the row unchanged stored that number instead:
9007199254740993 became 9007199254740992.

Such a Long is now named Long, which the panel leaves read-only, and a
write that reaches the plugin anyway is refused. An integer at 2^53 or
beyond is refused too, since the number that arrived may already have
been rounded. This follows the iOS fix for containers in #21.
A new Android SDK Build & Tests job runs ./gradlew build in SDK/Android,
which compiles every module and the sample and runs the JVM and
Robolectric tests, then script/test-android-artifacts on the outputs. It
runs on ubuntu-24.04 with Temurin JDK 17 and installs the SDK platform
and build tools the build and the artifact check expect.
The cherry-picked test called withTestWebsiteDataStore, a helper that
lives in android-poc's WebViewTestSupport.swift and was not carried
over, so NectoAppTests did not compile. It now passes .nonPersistent()
like the other tests in the file.
script/test-android-sample and script/test-android-security pass over USB
on a Galaxy S25+ (Android 17), so the note that the USB path was
unverified no longer holds.
The limit comes from android-poc. A larger write does not just fail: the
inbound frame closes the session, the request fails with PROVIDER_FAILED
and Necto reconnects within a second, as checked on a Galaxy S25+.
Review asked to keep this change to the SDK and the Mac connection. The
Events, Network, Performance, Files, Preferences and UI Control plugins,
necto-okhttp and the sample screens that exercise them leave here and
return in a change of their own.

The sample keeps plugin-sample, so the bridge still has a real round trip,
and the Android checks drive it instead of the shipped panels.
Review asked for ADB forwarding and Android connections to live in Swift,
in the running Necto, beside the iOS connections. The connection centre
now finds adb in the Android SDK, starts its server when none runs, and
every probe interval lists USB devices and real emulators, asks their
running apps for a Necto socket through run-as, and forwards a private
socket to each one it finds.

The launcher script, the environment variables and the Android-only mode
are gone. Each app on a device gets its own forward and connection, where
the old guard allowed one per device. Wireless ADB stays refused, and the
names read from the device are checked before they are used.
Each app writes its own endpoint file, so a debuggable app could write a
second line naming another package and have Necto treat its socket as
that app's. The device now prints a file only when it holds exactly one
name, the Mac splits on \n alone with whole-string checks, and a
package listed twice is dropped.
From an independent review of the ADB connection:

- adb starts a server only for localhost, and refuses for 127.0.0.1 as
  for a remote host, so after a reboot without Android Studio discovery
  never began. Necto now asks localhost and checks every exit status.
- A failed command read as an empty answer, so a device that went offline
  for a moment lost its apps. Failures now keep what was found, and a
  scan counts only when its end marker arrives.
- A restarted adb server forgets every forward while apps keep their
  names. The forwards adb holds are now checked each interval.
- The startup sweep removed the forwards of another Necto running beside
  this one. Socket directories now carry the pid, and only a dead run's
  are swept: its sockets and then the directory, matched by exact path.
The panel Content-Security-Policy applies to every plugin panel,
including existing iOS plugins, so its compatibility is reviewed in a
separate change. The scheme handler, its WebView wiring, the network
policy test and the manifest docs return to what main has. The stricter
allowedOrigins format check stays.
The Android manifest accepted origins the Mac refuses, such as
https://example.com/path, so a plugin could ship a panel that never
loads. Kotlin now applies the same rules as Swift's isValidOrigin, and
ModelTests runs the same accept and reject cases.
The scan ran in one shell whose final echo masked a failed pm or ps, so
a failed query could pass for an empty scan. pm and ps now run on their
own through adb(), which checks each exit status, and Swift parses and
intersects their output. Each app's endpoint file is read with its own
run-as: a failure skips that app, and get-state then confirms the device
still answers, so a device that left is not read as one without apps.
The run-as calls run side by side, about 0.2 s for 26 apps on a phone
against 1.5 s one after another.
An app started from the Finder does not inherit the shell's variables, so
an SDK outside the usual places was never found. Settings > General now
has an Android SDK field: empty keeps automatic detection, and a chosen
folder is the only place looked at. The hint says whether adb was found.
Changing it moves Android discovery to the new adb without a relaunch;
every adb talks to the same server, so forwards stay, and a folder
without adb lets the Android apps go.
feat(android): add the Android SDK under SDK/Android
The Events, Network, Performance, Files, Preferences and UI Control
plugins, necto-okhttp and the sample screens that exercise them, as they
were before they moved out of the SDK change.
A symlink under a deleted folder led deleteRecursively into its target,
which can lie outside the configured root. Links are now removed as
links.
Closing the body after a failed read still reported the request as
completed. The read error now ends the record as FAILED with that error.
Writing 2147483648 to an Int key stored it as a Long, and the app then
threw ClassCastException reading it as an Int. A value outside the Int
range is now refused before the key is touched.
A gesture cancelled between down and up left the view pressed, and a tap
held that way turned into a long press.
The method, URL and header names went into the command unescaped, so a
quote in a URL the app requested ended the argument, and pasting the
command ran what followed.
A default argument is evaluated before the function body, so a build that
is not debuggable still created every plugin: UI Control registered its
activity callbacks and Files created the external directories. The
defaults are now built after the check.
Test counts and coverage match the fixes. The emulator and USB runs were
repeated over the ADB connection. preferences.set joins files.write under
the 64 KiB inbound limit.
easyhooon and others added 29 commits October 29, 2026 23:48
After the cable is pulled, the phone can keep the old connection open, and
the runtime handled one session to the end before accepting another, so the
Mac's probes after a replug waited in the backlog until the app restarted.

Each accepted connection is now handled on its own, and it replaces the
current session only once its handshake is accepted. A connection that
never answers, or is refused, leaves a working session and its status alone.
fix(android): let a newer host replace a session that went quiet
A clickable parent passed the hit test wherever its clickable child was drawn,
so tapping the parent ran the child's click. A tap now needs the target to be
the innermost element there that takes taps, in Views and in Compose. A swipe
keeps the old rule, since a list scrolls when a drag starts on its rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Array editor parses each row as JSON, so `1.0` came back as 1 and a quoted
`"x"` as `x`. The Android side guessed which stored string each row stood for,
and no guess could tell those apart. A DataStore string set is now reported as
StringSet, and the panel sends its rows exactly as typed, so the SDK stores them
without matching. An iOS Array keeps the JSON editor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both were checked on the emulator and a Galaxy S25+ after the hit-test and string
set fixes, so the note that no plugin was repeated over the new connection no
longer holds for these two.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
feat(android): bring back the shipped plugins
The README, setup, architecture and verification docs covered iOS only. They now
name the Android SDK, its ADB connection and its checks, and the Android
verification notes record a device run with the plugins in their own modules.
Pairing simulators with iOS and emulators with Android read as if an Android
device were not supported.
The Gradle modules now mirror the Swift package products, and a plugin can be
told from the bridge by its name: necto-events-plugin, necto-network-plugin,
necto-files-plugin, necto-performance-plugin, necto-process-metric-plugin,
necto-ui-control-plugin, necto-datastore-preferences-plugin and
necto-okhttp-capture-plugin. necto-core, necto-android and necto-plugin-support
keep their names.
The setup guide sent Android readers to another page. It now has a section for
each platform, with the connection notes shared at the end, and the landing page
no longer describes Necto as iOS only.
The sentence is about connecting without a proxy or a certificate. The ways to
connect are listed in the setup guide.
feat(android): make plugins optional modules and publish through JitPack
The built panels lived inside the iOS source tree, and the Android build reached
into it to package them. A plugin now has one folder, SDK/Plugins/<Name>, with
the built panel in Panel, the Swift sources in iOS and the Gradle module in
Android, so both platforms package the same directory. SDK/iOS and SDK/Android
keep the bridge.

Swift targets point at the plugin folder and copy Panel, which is the default
directory NectoPluginPanel looks up. Gradle modules keep their names and only
map to the new directories.
…ship

Comparing packaged panel files with their source repeated what the Gradle task
does by copying them, and the consumer check already asserts which panels an app
ends up with. The debuggable check tested the sample's build type rather than
the SDK.
The install guide only showed how to run the iOS example and how to find a
missing iOS app. The Korean verification and harness pages lacked the Android
sections their English versions have.
The SDK and simulator jobs were iOS only but did not say so. They are now
iOS SDK Build & Tests and iOS Simulator Connection & CLI E2E.

Android Emulator Connection E2E boots an emulator, runs the sample and speaks
to the SDK through ADB the way the host does. The Necto host needs macOS and
hosted Mac runners cannot run an emulator, so script/android-sample gains
--sdk-only and script/test-android-security no longer requires a host.
JitPack builds a tag on its first request, so a broken tag showed up only when
an app added it. The release workflow now asks for the build once the release
is published and runs the selective consumer check against the artifacts.
The tutorial built its example plugin in Swift only. It now answers the same
contract in Kotlin, carries the same built panel as Java resources and registers
it with NectoAndroid.
Conflicts: the Android settings strings and main's reworded font hint both
landed in Localizable.strings, and the built Preferences panel changed on both
sides. The strings keep both, with the Android ones in the tone main moved the
Korean copy to. The panel is rebuilt from the merged sources.
refactor: keep each plugin's panel, iOS and Android code in one folder
The bug report template, the contributing and security notes and the plugin
pages named only USB devices, simulators and Swift packages. The architecture
page lists the Android bridge and the plugin folders.
The emulator job never booted: avdmanager created the AVD where the emulator did
not look, the emulator exited, and adb waited for a device until the job timed
out. Both now use ANDROID_AVD_HOME, and the wait is bounded.
composeTapRunsTheClickHandler passed in one variant and failed in the other of
the same CI run: the tap changed the state, but the text was still Count 0 after
sixty frames. The helper now sends snapshot apply notifications itself rather
than relying on the process-wide observer to do it under Robolectric.
Sending snapshot notifications from the frame helper did not stop
composeTapRunsTheClickHandler from seeing stale text, so that change is undone.

The tests never ended their activities, so compositions from earlier tests kept
running against Compose's process-wide UI dispatcher while Robolectric reset the
main looper between tests. Each test now closes its activity and lets the looper
finish.
chore(android): finish Android support before the main merge
macOS hands out ephemeral ports in order. The Android connection tests took one,
released it and had their center probe eight ports from it, while the probe
suite running beside them was given the next ports for its listeners. The
Android center connected to those listeners, the probe tests saw a connection
count step past the value they waited for, and timed out after three seconds.

The Android tests now take their ports below the ephemeral range. The Mac
package tests failed 2 of 12 full runs before this and 0 of 40 after.
@heoblitz
heoblitz merged commit 0d428ff into main Nov 12, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Proposal: Android SDK

2 participants