Repository navigation
Feature/android support - #41
Merged
Merged
Conversation
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.
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
Merge main into Android support
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Merge feature/android-support branch
close #17