Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 26 additions & 12 deletions android/BUILD_ANDROID.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,32 @@ assemble the debug APK is committed under `android/`.
4. Select the **debug** build variant and click **Run ▶** to install on a device
or emulator.

### Packaging clangd for Android

1. Build clangd for AArch64/ARM using the Android NDK r26b toolchain (`clangd`
target from `clang-tools-extra`).
2. Strip the binary (`llvm-strip`) and copy it into `android/runtime/clangd`.
3. The `ClangdRuntimeBridge` Kotlin helper copies this binary into the app's
private storage at startup and marks it executable.
4. Hook the binary into a transport of your choice:
- JNI shim that passes file descriptors into clangd's stdio loop, or
- A lightweight gRPC sidecar that proxies JSON-RPC requests.
5. The `LanguageServerClient` consumes either transport via the shared
`LanguageServerTransport` interface so UI components remain unchanged.
### Packaging clangd for Android (required)

The Android app will not start language services unless a real clangd binary is
packaged. Run the checked-in helper script to stage it before assembling:

```bash
# From the repository root with ANDROID_NDK_HOME pointing at NDK r26b+
./android/runtime/clangd/build-clangd-android.sh
```

Under the hood the script configures CMake for `arm64-v8a`, builds clangd from
`clang-tools-extra`, strips symbols, and copies the binary into
`android/runtime/clangd`. Android Studio packages that file as an asset and the
Kotlin runtime marks it executable inside the sandbox via `ClangdRuntimeBridge`.
Gradle tasks that produce APKs will fail fast if `android/runtime/clangd/clangd`
is missing or empty so you never ship a placeholder.

Teams that prefer a manual flow can follow the same steps:

1. Configure CMake with `-DLLVM_ENABLE_PROJECTS=clang;clang-tools-extra` and set
`-DLLVM_TARGETS_TO_BUILD=AArch64;ARM` using the Android toolchain file.
2. Build clangd with `ninja clangd` and strip symbols via `llvm-strip`.
3. Drop the resulting `clangd` binary into `android/runtime/clangd`.

`LanguageServerClient` consumes either a JNI-based stdio bridge or a gRPC shim
implementing `LanguageServerTransport`, so UI code remains transport-agnostic.

## Command line build

Expand Down
24 changes: 24 additions & 0 deletions android/android-studio/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,30 @@ android {
excludes += ['/META-INF/{AL2.0,LGPL2.1}']
}
}

sourceSets {
main {
// Package the clangd runtime asset directly from the checked-in staging directory.
assets.srcDirs += ['../../runtime/clangd']
}
}
}

// Fail fast if the clangd runtime is missing to prevent shipping a non-functional language server.
tasks.register('verifyClangdRuntime') {
doLast {
def clangdPath = project.rootDir.toPath().resolve('../runtime/clangd/clangd').normalize().toFile()
if (!clangdPath.exists() || clangdPath.length() < 1024) {
throw new GradleException("Packaged clangd binary is missing or empty. Run build-clangd-android.sh first.")
}
if (!clangdPath.canExecute()) {
throw new GradleException("Packaged clangd binary is not executable. Re-run build-clangd-android.sh.")
}
}
}

tasks.matching { it.name.startsWith('assemble') || it.name.startsWith('bundle') }.configureEach {
dependsOn tasks.named('verifyClangdRuntime')
}

dependencies {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@ import com.arduino.ide.mobile.project.TabStateRepository
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayoutMediator
import com.arduino.ide.mobile.lsp.DemoLanguageServerTransport
import com.arduino.ide.mobile.lsp.ClangdRuntimeBridge
import com.arduino.ide.mobile.lsp.LanguageServerClient
import com.arduino.ide.mobile.lsp.LanguageServerStatus
import com.arduino.ide.mobile.lsp.RuntimeLanguageServerTransport
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import java.util.regex.Pattern

Expand All @@ -42,6 +45,10 @@ class MainActivity : AppCompatActivity() {
private lateinit var adapter: EditorTabAdapter
private lateinit var project: SketchProject
private lateinit var tabStateRepository: TabStateRepository
private lateinit var languageServerClient: LanguageServerClient
private lateinit var activeFileUri: String
private var statusJob: Job? = null
private var diagnosticJob: Job? = null

private val searchManager = SearchManager()
private val docs = mapOf(
Expand Down Expand Up @@ -70,42 +77,18 @@ class MainActivity : AppCompatActivity() {
tabStateRepository = TabStateRepository(this)
project = SketchProject.demoProject(this)
binding.codePath.text = project.basePath.absolutePath
val codeListing = project.files.firstOrNull()?.content.orEmpty()
val activeFile = project.files.firstOrNull()
activeFileUri = activeFile?.path?.let { java.io.File(it).toURI().toString() }
?: "file://${project.basePath.absolutePath}/Sketch.ino"
val codeListing = activeFile?.content.orEmpty()

setupTabs(project)
setupSearchControls()
val languageServerClient = LanguageServerClient(DemoLanguageServerTransport())
lifecycleScope.launch {
languageServerClient.start(sessionId = "demo-session", rootUri = "file:///blink")
languageServerClient.openDocument("file:///blink/Blink.ino", "cpp", codeListing)

val completions = languageServerClient.requestCompletions(
uri = "file:///blink/Blink.ino",
line = 5,
character = 6
)
binding.completionList.text = completions.joinToString("\n") { item ->
buildString {
append(item.label)
item.detail?.let { append(" — ").append(it) }
item.autoImportText?.let { append(" (auto-import: ").append(it).append(")") }
}
}

val hover = languageServerClient.requestHover(
uri = "file:///blink/Blink.ino",
line = 5,
character = 6
)
binding.hoverText.text = hover?.contents ?: getString(R.string.status_connected)
}

languageServerClient = LanguageServerClient(
RuntimeLanguageServerTransport(ClangdRuntimeBridge(this))
)
lifecycleScope.launch {
languageServerClient.diagnostics.collect { diagnostic ->
binding.diagnosticMessage.text = diagnostic.message
val hint = diagnostic.recoveryHint
binding.diagnosticHint.text = hint ?: getString(R.string.status_connected)
}
attachLanguageServer(activeFile, codeListing)
}

binding.serialMonitorLog.text = """
Expand Down Expand Up @@ -244,6 +227,82 @@ class MainActivity : AppCompatActivity() {
}
}

private suspend fun attachLanguageServer(activeFile: SketchFile?, codeListing: String) {
if (activeFile == null) return
observeStatus(languageServerClient)
val status = languageServerClient.start(sessionId = "mobile-session", rootUri = project.basePath.toURI().toString())
if (status is LanguageServerStatus.Error) {
showLspError(status)
return
}
languageServerClient.openDocument(activeFileUri, "cpp", codeListing)
observeDiagnostics(languageServerClient)
refreshEditorInsights(activeFile)
}

private fun observeStatus(client: LanguageServerClient) {
statusJob?.cancel()
statusJob = lifecycleScope.launch {
client.status.collect { status ->
when (status) {
is LanguageServerStatus.Ready -> {
binding.statusText.text = getString(R.string.status_connected)
binding.statusChip.text = getString(R.string.status_label)
}
is LanguageServerStatus.Error -> {
binding.statusText.text = status.message
binding.diagnosticHint.text = status.recoveryHint ?: getString(R.string.status_label)
Snackbar.make(binding.root, status.message, Snackbar.LENGTH_LONG).show()
}
LanguageServerStatus.Idle -> {
binding.statusText.text = getString(R.string.status_label)
}
}
}
}
}

private fun observeDiagnostics(client: LanguageServerClient) {
diagnosticJob?.cancel()
diagnosticJob = lifecycleScope.launch {
client.diagnostics.collect { diagnostic ->
binding.diagnosticMessage.text = diagnostic.message
val hint = diagnostic.recoveryHint
binding.diagnosticHint.text = hint ?: getString(R.string.status_connected)
}
}
}

private fun showLspError(status: LanguageServerStatus.Error) {
val message = status.recoveryHint ?: getString(R.string.status_connected)
Snackbar.make(binding.root, message, Snackbar.LENGTH_LONG).show()
binding.hoverText.text = getString(R.string.status_label)
}

private suspend fun refreshEditorInsights(activeFile: SketchFile) {
val line = activeFile.content.lines().size.coerceAtLeast(1)
val character = activeFile.content.lines().lastOrNull()?.length?.coerceAtLeast(0) ?: 0
val completions = languageServerClient.requestCompletions(
uri = activeFileUri,
line = line,
character = character
)
binding.completionList.text = completions.joinToString("\n") { item ->
buildString {
append(item.label)
item.detail?.let { append(" — ").append(it) }
item.autoImportText?.let { append(" (auto-import: ").append(it).append(")") }
}
}

val hover = languageServerClient.requestHover(
uri = activeFileUri,
line = line,
character = character
)
binding.hoverText.text = hover?.contents ?: getString(R.string.status_connected)
}

private fun updateBreadcrumb(file: SketchFile, line: Int) {
val function = DocumentSymbolHelper.contextForCursor(file.content, line)
val breadcrumb = buildString {
Expand All @@ -260,14 +319,29 @@ class MainActivity : AppCompatActivity() {

private fun maybeShowHelp(file: SketchFile, line: Int) {
val function = DocumentSymbolHelper.contextForCursor(file.content, line)
val symbol = docs.keys.firstOrNull { file.content.contains(it) && function?.contains(it) == true }
val description = symbol?.let { docs[it] }
if (description != null) {
MaterialAlertDialogBuilder(this)
.setTitle(getString(R.string.help_dialog_title))
.setMessage(description)
.setPositiveButton(android.R.string.ok, null)
.show()
val lineText = file.content.lines().getOrNull(line - 1)
val character = lineText?.indexOfFirst { !it.isWhitespace() }?.coerceAtLeast(0) ?: 0
lifecycleScope.launch {
val hover = if (::languageServerClient.isInitialized) {
languageServerClient.requestHover(activeFileUri, line, character)
} else null
Comment on lines +324 to +327

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Guard hover requests when language server not ready

The new maybeShowHelp path launches a hover request unconditionally; when clangd fails to start (e.g., packaged asset missing/non-executable) or is still initializing, RuntimeLanguageServerTransport.send immediately returns without emitting a response, so requestHover suspends forever and the coroutine never reaches the fallback docs dialog. Users who move the cursor before the LSP is ready will see the help UI hang silently and leak coroutines. Consider skipping the LSP call until status is Ready or timing out to fall back to the local docs.

Useful? React with 👍 / 👎.

if (hover != null) {
MaterialAlertDialogBuilder(this@MainActivity)
.setTitle(getString(R.string.help_dialog_title))
.setMessage(hover.contents)
.setPositiveButton(android.R.string.ok, null)
.show()
} else {
val symbol = docs.keys.firstOrNull { file.content.contains(it) && function?.contains(it) == true }
val description = symbol?.let { docs[it] }
if (description != null) {
MaterialAlertDialogBuilder(this@MainActivity)
.setTitle(getString(R.string.help_dialog_title))
.setMessage(description)
.setPositiveButton(android.R.string.ok, null)
.show()
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,28 @@ class ClangdRuntimeBridge(private val context: Context) {
suspend fun installClangd(binaryName: String = "clangd"): File = withContext(Dispatchers.IO) {
val targetDir = File(context.filesDir, "lsp-runtime").apply { mkdirs() }
val target = File(targetDir, binaryName)
if (!target.exists()) {
// In a real build we would stream the asset to disk. For now leave a placeholder so
// callers can reference the path without shipping a real binary in CI.
target.writeText("placeholder clangd binary; replace during packaging")
target.setExecutable(true)

// Always replace the on-disk copy to guarantee the packaged asset is used and to avoid
// silently running with an empty placeholder.
if (target.exists()) {
target.delete()
}

val packaged = context.assets.list("")?.contains(binaryName) == true
if (!packaged) {
throw IllegalStateException("Packaged clangd asset '$binaryName' is missing; run build-clangd-android.sh")
}

context.assets.open(binaryName).use { input ->
target.outputStream().use { output ->
input.copyTo(output)
}
}

if (!target.setExecutable(true, false)) {
throw IllegalStateException("Failed to mark clangd executable")
}

target
}
}
Loading
Loading