diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c91ed9e..bee9fd9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,19 @@ on: workflow_dispatch: inputs: gomtm_swarm_aar_url: - description: Optional direct download URL for a gomtm swarm AAR + description: GitHub API asset URL for gomtm-swarm-android.aar + required: false + type: string + gomtm_swarm_metadata_url: + description: GitHub API asset URL for gomtm-swarm-android.json + required: false + type: string + gomtm_swarm_aar_version: + description: Version string for the pinned gomtm swarm AAR + required: false + type: string + gomtm_swarm_aar_sha256: + description: SHA256 for the pinned gomtm swarm AAR required: false type: string @@ -14,7 +26,12 @@ permissions: env: GOMTM_SWARM_AAR_URL: ${{ inputs.gomtm_swarm_aar_url || vars.GOMTM_SWARM_AAR_URL }} - GOMTM_SWARM_AAR_NAME: ${{ vars.GOMTM_SWARM_AAR_NAME || 'gomtm-swarm-android.aar' }} + GOMTM_SWARM_METADATA_URL: ${{ inputs.gomtm_swarm_metadata_url || vars.GOMTM_SWARM_METADATA_URL }} + GOMTM_SWARM_AAR_VERSION: ${{ inputs.gomtm_swarm_aar_version || vars.GOMTM_SWARM_AAR_VERSION }} + GOMTM_SWARM_AAR_SHA256: ${{ inputs.gomtm_swarm_aar_sha256 || vars.GOMTM_SWARM_AAR_SHA256 }} + GOMTM_SWARM_AAR_NAME: gomtm-swarm-android.aar + GH_FALLBACK_TOKEN: ${{ github.token }} + GOMTM_RELEASE_TOKEN: ${{ secrets.GOMTM_GH_PAT }} jobs: android: @@ -24,19 +41,37 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Prepare optional gomtm swarm AAR + - name: Validate gomtm swarm AAR contract + shell: bash + run: | + test -n "${GOMTM_SWARM_AAR_URL}" || { echo "Missing GOMTM_SWARM_AAR_URL"; exit 1; } + test -n "${GOMTM_SWARM_METADATA_URL}" || { echo "Missing GOMTM_SWARM_METADATA_URL"; exit 1; } + test -n "${GOMTM_SWARM_AAR_VERSION}" || { echo "Missing GOMTM_SWARM_AAR_VERSION"; exit 1; } + test -n "${GOMTM_SWARM_AAR_SHA256}" || { echo "Missing GOMTM_SWARM_AAR_SHA256"; exit 1; } + + - name: Download pinned gomtm swarm AAR and metadata shell: bash run: | - mkdir -p app/libs - if [ -z "${GOMTM_SWARM_AAR_URL}" ]; then - echo "No gomtm swarm AAR configured; building the public host shell without a bound runtime." - exit 0 - fi - - target="app/libs/${GOMTM_SWARM_AAR_NAME:-gomtm-swarm-android.aar}" - echo "Downloading optional gomtm swarm AAR to ${target}" - curl -fL --retry 3 --retry-all-errors "${GOMTM_SWARM_AAR_URL}" -o "${target}" - ls -lh app/libs + mkdir -p app/libs dist + auth_token="${GOMTM_RELEASE_TOKEN:-${GH_FALLBACK_TOKEN}}" + curl -fsSL -H "Authorization: Bearer ${auth_token}" -H "Accept: application/octet-stream" "${GOMTM_SWARM_AAR_URL}" -o "app/libs/${GOMTM_SWARM_AAR_NAME}" + curl -fsSL -H "Authorization: Bearer ${auth_token}" -H "Accept: application/octet-stream" "${GOMTM_SWARM_METADATA_URL}" -o "dist/gomtm-swarm-android.json" + echo "${GOMTM_SWARM_AAR_SHA256} app/libs/${GOMTM_SWARM_AAR_NAME}" | sha256sum --check --strict --status + python - <<'PY' + import json + import os + from pathlib import Path + meta = json.loads(Path('dist/gomtm-swarm-android.json').read_text()) + expected_version = os.environ['GOMTM_SWARM_AAR_VERSION'] + expected_sha = os.environ['GOMTM_SWARM_AAR_SHA256'] + if meta.get('artifact') != 'gomtm-swarm-android.aar': + raise SystemExit(f"unexpected artifact: {meta.get('artifact')}") + if meta.get('version') != expected_version: + raise SystemExit(f"metadata version mismatch: {meta.get('version')} != {expected_version}") + if meta.get('sha256') != expected_sha: + raise SystemExit(f"metadata sha256 mismatch: {meta.get('sha256')} != {expected_sha}") + PY + ls -lh app/libs dist - name: Set up Java 17 uses: actions/setup-java@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e89f1b9..9e2d7ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,19 @@ on: workflow_dispatch: inputs: gomtm_swarm_aar_url: - description: Optional direct download URL for a gomtm swarm AAR + description: GitHub API asset URL for gomtm-swarm-android.aar + required: false + type: string + gomtm_swarm_metadata_url: + description: GitHub API asset URL for gomtm-swarm-android.json + required: false + type: string + gomtm_swarm_aar_version: + description: Version string for the pinned gomtm swarm AAR + required: false + type: string + gomtm_swarm_aar_sha256: + description: SHA256 for the pinned gomtm swarm AAR required: false type: string @@ -15,9 +27,14 @@ permissions: contents: write env: - RELEASE_TAG: v0.2.${{ github.run_number }} + RELEASE_TAG: v0.3.${{ github.run_number }} GOMTM_SWARM_AAR_URL: ${{ inputs.gomtm_swarm_aar_url || vars.GOMTM_SWARM_AAR_URL }} - GOMTM_SWARM_AAR_NAME: ${{ vars.GOMTM_SWARM_AAR_NAME || 'gomtm-swarm-android.aar' }} + GOMTM_SWARM_METADATA_URL: ${{ inputs.gomtm_swarm_metadata_url || vars.GOMTM_SWARM_METADATA_URL }} + GOMTM_SWARM_AAR_VERSION: ${{ inputs.gomtm_swarm_aar_version || vars.GOMTM_SWARM_AAR_VERSION }} + GOMTM_SWARM_AAR_SHA256: ${{ inputs.gomtm_swarm_aar_sha256 || vars.GOMTM_SWARM_AAR_SHA256 }} + GOMTM_SWARM_AAR_NAME: gomtm-swarm-android.aar + GH_FALLBACK_TOKEN: ${{ github.token }} + GOMTM_RELEASE_TOKEN: ${{ secrets.GOMTM_GH_PAT }} jobs: release: @@ -27,19 +44,46 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Prepare optional gomtm swarm AAR + - name: Validate gomtm swarm AAR contract shell: bash run: | - mkdir -p app/libs - if [ -z "${GOMTM_SWARM_AAR_URL}" ]; then - echo "No gomtm swarm AAR configured; releasing the public host shell without a bound runtime." - exit 0 - fi + test -n "${GOMTM_SWARM_AAR_URL}" || { echo "Missing GOMTM_SWARM_AAR_URL"; exit 1; } + test -n "${GOMTM_SWARM_METADATA_URL}" || { echo "Missing GOMTM_SWARM_METADATA_URL"; exit 1; } + test -n "${GOMTM_SWARM_AAR_VERSION}" || { echo "Missing GOMTM_SWARM_AAR_VERSION"; exit 1; } + test -n "${GOMTM_SWARM_AAR_SHA256}" || { echo "Missing GOMTM_SWARM_AAR_SHA256"; exit 1; } - target="app/libs/${GOMTM_SWARM_AAR_NAME:-gomtm-swarm-android.aar}" - echo "Downloading optional gomtm swarm AAR to ${target}" - curl -fL --retry 3 --retry-all-errors "${GOMTM_SWARM_AAR_URL}" -o "${target}" - ls -lh app/libs + - name: Download pinned gomtm swarm AAR and metadata + shell: bash + run: | + mkdir -p app/libs dist + auth_token="${GOMTM_RELEASE_TOKEN:-${GH_FALLBACK_TOKEN}}" + curl -fsSL -H "Authorization: Bearer ${auth_token}" -H "Accept: application/octet-stream" "${GOMTM_SWARM_AAR_URL}" -o "app/libs/${GOMTM_SWARM_AAR_NAME}" + curl -fsSL -H "Authorization: Bearer ${auth_token}" -H "Accept: application/octet-stream" "${GOMTM_SWARM_METADATA_URL}" -o "dist/gomtm-swarm-android.json" + echo "${GOMTM_SWARM_AAR_SHA256} app/libs/${GOMTM_SWARM_AAR_NAME}" | sha256sum --check --strict --status + python - <<'PY' + import json + import os + from pathlib import Path + meta = json.loads(Path('dist/gomtm-swarm-android.json').read_text()) + expected_version = os.environ['GOMTM_SWARM_AAR_VERSION'] + expected_sha = os.environ['GOMTM_SWARM_AAR_SHA256'] + if meta.get('artifact') != 'gomtm-swarm-android.aar': + raise SystemExit(f"unexpected artifact: {meta.get('artifact')}") + if meta.get('version') != expected_version: + raise SystemExit(f"metadata version mismatch: {meta.get('version')} != {expected_version}") + if meta.get('sha256') != expected_sha: + raise SystemExit(f"metadata sha256 mismatch: {meta.get('sha256')} != {expected_sha}") + provenance = { + 'gomtm_swarm_aar_url': os.environ['GOMTM_SWARM_AAR_URL'], + 'gomtm_swarm_aar_version': expected_version, + 'gomtm_swarm_aar_sha256': expected_sha, + 'gomtm_swarm_metadata_url': os.environ['GOMTM_SWARM_METADATA_URL'], + 'release_tag': os.environ['RELEASE_TAG'], + 'git_commit': os.environ['GITHUB_SHA'], + } + Path('dist/gomtm-android-provenance.json').write_text(json.dumps(provenance, indent=2) + '\n') + PY + ls -lh app/libs dist - name: Set up Java 17 uses: actions/setup-java@v4 @@ -68,10 +112,13 @@ jobs: tag_name: ${{ env.RELEASE_TAG }} name: ${{ env.RELEASE_TAG }} body: | - Public gomtm-android release. + gomtm-android release built from a pinned gomtm swarm AAR. - Integration mode: - - with configured gomtm swarm AAR: bound runtime host - - without configured gomtm swarm AAR: public host shell only - files: app/build/outputs/apk/release/app-release-unsigned.apk + gomtm AAR provenance: + - version: ${{ env.GOMTM_SWARM_AAR_VERSION }} + - url: ${{ env.GOMTM_SWARM_AAR_URL }} + - sha256: ${{ env.GOMTM_SWARM_AAR_SHA256 }} + files: | + app/build/outputs/apk/release/app-release-unsigned.apk + dist/gomtm-android-provenance.json fail_on_unmatched_files: true diff --git a/README.md b/README.md index e22fe74..63cb6d1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # gomtm-android -Public Android app host repository for gomtm. +Public Android swarm node app for gomtm. ## Current source of truth @@ -9,35 +9,25 @@ This repository is the long-term public Android app host for gomtm. The active architecture is: - `gomtm` remains the swarm kernel / Android AAR producer -- `gomtm-android` is the public Android app host, CI, release, and APK distribution repo +- `gomtm-android` is the public Android node app host, CI, release, and APK distribution repo - swarm-first comes before worker orchestration, group control, or reward automation -- legacy `apps/android/` in the monorepo is migration material only, not the future app host - -## What is implemented in this repo today - -This repo now ships a **swarm-first host shell** instead of a generic HelloWorld placeholder. - -That means: - -- the app can build and release publicly even when no gomtm swarm AAR is present -- the app contains a runtime probe screen that detects whether a gomtm bridge is actually bound -- the app uses reflection-based probing so it does not fake a compile-time dependency on an AAR that upstream does not yet publish stably -- GitHub Actions can optionally download a prebuilt gomtm swarm AAR into `app/libs/` before building +- legacy monorepo `apps/android/` is migration material only, not the future app host ## What this repo is responsible for - Android UI and product shell -- public pull-request workflow +- starting and stopping a real gomtm swarm node runtime +- showing runtime state, peer id, bootstrap address, logs, and discovered peers - GitHub Actions CI and release automation - APK publication -- optional consumption of a gomtm-produced swarm AAR +- consuming a **published, pinned** `gomtm-swarm-android.aar` ## What this repo is not responsible for - re-implementing libp2p or swarm logic in Kotlin - reviving or continuing development in monorepo `apps/android/` -- pretending that a stable public gomtm AAR feed already exists -- pulling reward-automation business logic into the Android host shell +- consuming an unpinned `latest` AAR +- pulling reward-automation business logic into the Android node host ## Validation policy @@ -45,18 +35,20 @@ That means: - use pull requests for functional changes - do not treat local Android builds as the acceptance path for this phase -## Optional AAR consumption - -If CI or a maintainer provides a gomtm swarm AAR, it should be placed into `app/libs/`. +## Runtime contract -The host app then probes for known bridge classes at runtime and reports: +The app consumes a published `gomtm-swarm-android.aar` and drives the current node runtime surface: -- whether a bridge was detected -- which bridge class was found -- whether the lifecycle surface looks like `node`, `worker-legacy`, or `probe-only` -- runtime state, peer id, bootstrap address, last error, and drained logs when exposed by the bridge +- `startNode(baseDir, config)` +- `stopNode()` +- `getState()` +- `getPeerID()` +- `getBootstrapAddr()` +- `getLastError()` +- `getDiscoveredPeers()` +- `drainLogs()` -If no AAR is present, the app still builds and clearly reports that it is running as a **public host shell only**. +The UI is expected to show real runtime data, not a host-shell placeholder. ## Related docs diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2b0db40..a56e9b7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.gomtm.android" minSdk = libs.versions.minSdk.get().toInt() targetSdk = libs.versions.targetSdk.get().toInt() - versionCode = 2 - versionName = "0.2.0" + versionCode = 3 + versionName = "0.3.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -42,7 +42,7 @@ android { } dependencies { - implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar")))) + implementation(files("libs/gomtm-swarm-android.aar")) implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) @@ -50,6 +50,7 @@ dependencies { implementation(libs.androidx.constraintlayout) testImplementation(libs.junit) + testImplementation("org.json:json:20240303") androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.espresso.core) } diff --git a/app/libs/README.md b/app/libs/README.md index e6d0364..435fc46 100644 --- a/app/libs/README.md +++ b/app/libs/README.md @@ -1,8 +1,4 @@ -Place optional prebuilt gomtm swarm AAR artifacts here. +GitHub Actions downloads the pinned `gomtm-swarm-android.aar` into this directory before Gradle runs. -Expected direction: -- `gomtm` produces the Android swarm AAR -- `gomtm-android` consumes it from `app/libs/` during CI or release builds - -This directory is intentionally kept in-repo so GitHub Actions can download the AAR here before Gradle runs. -Do not commit secrets or unrelated binaries. +This repo does not treat local or ad-hoc AAR files as the public source of truth. +The public truth is the published gomtm Android swarm AAR release asset plus its pinned SHA256. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bec6c55..0f040fc 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,6 +1,9 @@ + + + (R.id.startButton).setOnClickListener { + saveInputs() + runAction { + swarmRuntime.start(this, currentConfig()) + } + } + findViewById(R.id.stopButton).setOnClickListener { + runAction { + swarmRuntime.stop() + } + } findViewById(R.id.refreshButton).setOnClickListener { + saveInputs() + actionError = null render() } render() } + private fun runAction(action: () -> Unit) { + actionError = null + try { + action() + } catch (error: Exception) { + actionError = error.message ?: error.javaClass.simpleName + } + render() + } + + private fun currentConfig(): SwarmNodeConfig { + val bootstrap = inputText(R.id.bootstrapInput).ifBlank { SwarmNodeConfig.DEFAULT_BOOTSTRAP } + val nodeName = inputText(R.id.nodeNameInput).ifBlank { SwarmNodeConfig.defaultNodeName() } + return SwarmNodeConfig( + bootstrapAddress = bootstrap, + nodeName = nodeName, + autoReconnect = true, + ) + } + private fun render() { val status = swarmRuntime.probe() val logs = swarmRuntime.drainLogs() + val uiModel = SwarmUiModel.from(status = status, actionError = actionError) - setText(R.id.summaryValue, if (status.aarDetected) { - getString(R.string.status_summary_bound) - } else { - getString(R.string.status_summary_unbound) - }) - setText(R.id.modeValue, status.integrationMode) - setText(R.id.bridgeValue, status.bridgeClassName ?: getString(R.string.value_not_available)) - setText(R.id.lifecycleValue, status.lifecycleSurface) + setText(R.id.bridgeValue, status.bridgeClassName.ifBlank { getString(R.string.value_not_available) }) setText(R.id.stateValue, status.state) - setText(R.id.peerValue, status.peerId ?: getString(R.string.value_not_available)) - setText(R.id.bootstrapValue, status.bootstrapAddress ?: getString(R.string.value_not_available)) - setText(R.id.errorValue, status.lastError ?: getString(R.string.value_none)) + setText(R.id.peerValue, status.peerId.ifBlank { getString(R.string.value_not_available) }) + setText(R.id.bootstrapValue, uiModel.connectedBootstrap) + setText(R.id.errorValue, uiModel.errorText) + setText(R.id.peersValue, formatPeers(status.discoveredPeers)) setText(R.id.logsValue, logs.ifBlank { getString(R.string.value_no_logs_yet) }) } + private fun formatPeers(peers: List): String { + if (peers.isEmpty()) { + return getString(R.string.value_no_peers_yet) + } + return peers.joinToString(separator = "\n\n") { peer -> + buildString { + append(peer.name ?: peer.peerId) + append("\npeer_id=") + append(peer.peerId) + append("\nstate=") + append(peer.state) + append("\nlast_seen_at=") + append(peer.lastSeenAt.ifBlank { "unknown" }) + } + } + } + + private fun restoreInputs() { + val prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE) + setInput( + R.id.bootstrapInput, + prefs.getString(KEY_BOOTSTRAP, SwarmNodeConfig.DEFAULT_BOOTSTRAP).orEmpty(), + ) + setInput( + R.id.nodeNameInput, + prefs.getString(KEY_NODE_NAME, SwarmNodeConfig.defaultNodeName()).orEmpty(), + ) + } + + private fun saveInputs() { + getSharedPreferences(PREFS_NAME, MODE_PRIVATE) + .edit() + .putString(KEY_BOOTSTRAP, inputText(R.id.bootstrapInput)) + .putString(KEY_NODE_NAME, inputText(R.id.nodeNameInput)) + .apply() + } + + private fun inputText(viewId: Int): String = findViewById(viewId).text.toString().trim() + private fun setText(viewId: Int, value: String) { findViewById(viewId).text = value } + + private fun setInput(viewId: Int, value: String) { + findViewById(viewId).setText(value) + } + + companion object { + private const val PREFS_NAME = "gomtm-android" + private const val KEY_BOOTSTRAP = "bootstrap" + private const val KEY_NODE_NAME = "node_name" + } } diff --git a/app/src/main/java/com/gomtm/android/swarm/DiscoveredPeer.kt b/app/src/main/java/com/gomtm/android/swarm/DiscoveredPeer.kt new file mode 100644 index 0000000..0370dc0 --- /dev/null +++ b/app/src/main/java/com/gomtm/android/swarm/DiscoveredPeer.kt @@ -0,0 +1,43 @@ +package com.gomtm.android.swarm + +import org.json.JSONObject + +data class DiscoveredPeer( + val peerId: String, + val name: String?, + val state: String, + val discoveredInCurrentSession: Boolean, + val isBootstrap: Boolean, + val lastSeenAt: String, +) { + companion object { + fun parseSnapshot(raw: String): List { + if (raw.isBlank()) { + return emptyList() + } + return runCatching { + val root = JSONObject(raw) + val peers = root.optJSONArray("peers") + if (peers == null) { + emptyList() + } else { + buildList(peers.length()) { + for (index in 0 until peers.length()) { + val item = peers.optJSONObject(index) ?: continue + add( + DiscoveredPeer( + peerId = item.optString("peer_id"), + name = item.optString("name").ifBlank { null }, + state = item.optString("state", "unknown"), + discoveredInCurrentSession = item.optBoolean("discovered_in_current_session", false), + isBootstrap = item.optBoolean("is_bootstrap", false), + lastSeenAt = item.optString("last_seen_at"), + ), + ) + } + } + } + }.getOrDefault(emptyList()) + } + } +} diff --git a/app/src/main/java/com/gomtm/android/swarm/SwarmNodeConfig.kt b/app/src/main/java/com/gomtm/android/swarm/SwarmNodeConfig.kt new file mode 100644 index 0000000..50f1fb4 --- /dev/null +++ b/app/src/main/java/com/gomtm/android/swarm/SwarmNodeConfig.kt @@ -0,0 +1,28 @@ +package com.gomtm.android.swarm + +import android.os.Build +import java.util.Locale + +data class SwarmNodeConfig( + val bootstrapAddress: String, + val nodeName: String, + val autoReconnect: Boolean = true, +) { + companion object { + const val DEFAULT_BOOTSTRAP = "/ip4/103.73.161.132/tcp/4101/p2p/12D3KooWMJXTpws7wFvNskw8hWxRNtnsK8ctVHskUBeZdZzJ895n" + + fun defaultNodeName(deviceName: String = Build.MODEL ?: "Android"): String { + val normalized = + deviceName + .trim() + .lowercase(Locale.US) + .replace(Regex("[^a-z0-9]+"), "-") + .trim('-') + return if (normalized.isBlank()) { + "android-node" + } else { + "android-$normalized" + } + } + } +} diff --git a/app/src/main/java/com/gomtm/android/swarm/SwarmRuntime.kt b/app/src/main/java/com/gomtm/android/swarm/SwarmRuntime.kt index 41c1402..8596784 100644 --- a/app/src/main/java/com/gomtm/android/swarm/SwarmRuntime.kt +++ b/app/src/main/java/com/gomtm/android/swarm/SwarmRuntime.kt @@ -1,64 +1,102 @@ package com.gomtm.android.swarm +import android.content.Context +import java.io.File +import java.lang.reflect.InvocationTargetException + class SwarmRuntime( - private val bridgeClassNames: List = DEFAULT_BRIDGE_CLASS_NAMES, + private val bridgeClassName: String = DEFAULT_BRIDGE_CLASS_NAME, + private val configClassName: String = DEFAULT_CONFIG_CLASS_NAME, private val classLoader: ClassLoader = SwarmRuntime::class.java.classLoader - ?: ClassLoader.getSystemClassLoader() + ?: ClassLoader.getSystemClassLoader(), ) { + fun start(context: Context, config: SwarmNodeConfig) { + val bridge = resolveBridgeClass() + val configClass = Class.forName(configClassName, true, classLoader) + val configInstance = configClass.getDeclaredConstructor().newInstance() + setStringProperty(configClass, configInstance, "setBootstrapAddr", config.bootstrapAddress) + setStringProperty(configClass, configInstance, "setNodeName", config.nodeName) + setBooleanProperty(configClass, configInstance, "setAutoReconnect", config.autoReconnect) + invokeStatic( + bridge = bridge, + methodName = "startNode", + args = arrayOf(runtimeBaseDir(context), configInstance), + parameterTypes = arrayOf(String::class.java, configClass), + ) + } + + fun stop() { + invokeStatic( + bridge = resolveBridgeClass(), + methodName = "stopNode", + args = emptyArray(), + parameterTypes = emptyArray(), + ) + } + + fun drainLogs(): String = invokeString("drainLogs") + fun probe(): SwarmStatus { - val bridge = resolveBridgeClass() ?: return SwarmStatus.missing() + val bridge = try { + resolveBridgeClass() + } catch (error: ReflectiveOperationException) { + return SwarmStatus.missing("gomtm swarm bridge unavailable: ${error.message ?: error.javaClass.simpleName}") + } + val rawDiscoveredPeers = invokeString("getDiscoveredPeers") return SwarmStatus( - integrationMode = "aar-bound", - aarDetected = true, bridgeClassName = bridge.name, - lifecycleSurface = detectLifecycleSurface(bridge), - state = callString(bridge, "GetState") ?: "Unknown", - peerId = callString(bridge, "GetPeerID"), - bootstrapAddress = callString(bridge, "GetBootstrapAddr"), - lastError = callString(bridge, "GetLastError") + state = invokeString("getState").ifBlank { "Unknown" }, + peerId = invokeString("getPeerID"), + bootstrapAddress = invokeString("getBootstrapAddr"), + lastError = invokeString("getLastError"), + discoveredPeers = DiscoveredPeer.parseSnapshot(rawDiscoveredPeers), + rawDiscoveredPeers = rawDiscoveredPeers, ) } - fun drainLogs(): String { - val bridge = resolveBridgeClass() ?: return "" - return callString(bridge, "DrainLogs")?.trim().orEmpty() + internal fun resolveBridgeClass(): Class<*> = Class.forName(bridgeClassName, true, classLoader) + + private fun runtimeBaseDir(context: Context): String { + val baseDir = File(context.filesDir, "gomtm") + baseDir.mkdirs() + return baseDir.absolutePath } - internal fun resolveBridgeClass(): Class<*>? { - for (candidate in bridgeClassNames) { - try { - return Class.forName(candidate, false, classLoader) - } catch (_: ClassNotFoundException) { - continue - } + private fun invokeString(methodName: String): String { + return try { + (invokeStatic(resolveBridgeClass(), methodName, emptyArray(), emptyArray()) as? String).orEmpty() + } catch (_: ReflectiveOperationException) { + "" } - return null } - private fun detectLifecycleSurface(bridge: Class<*>): String { - val methodNames = bridge.methods.map { it.name }.toSet() - return when { - methodNames.containsAll(setOf("StartNode", "StopNode")) -> "node" - methodNames.containsAll(setOf("StartWorker", "StopWorker")) -> "worker-legacy" - else -> "probe-only" + private fun invokeStatic( + bridge: Class<*>, + methodName: String, + args: Array, + parameterTypes: Array>, + ): Any? { + try { + val method = bridge.getMethod(methodName, *parameterTypes) + return method.invoke(null, *args) + } catch (error: InvocationTargetException) { + val cause = error.targetException ?: error.cause ?: error + throw IllegalStateException(cause.message ?: cause.javaClass.simpleName, cause) } } - private fun callString(target: Class<*>, methodName: String): String? { - return try { - val method = target.getMethod(methodName) - (method.invoke(null) as? String)?.takeIf { it.isNotBlank() } - } catch (_: ReflectiveOperationException) { - null - } + private fun setStringProperty(targetClass: Class<*>, target: Any, methodName: String, value: String) { + targetClass.getMethod(methodName, String::class.java).invoke(target, value) + } + + private fun setBooleanProperty(targetClass: Class<*>, target: Any, methodName: String, value: Boolean) { + val booleanType = Boolean::class.javaPrimitiveType ?: Boolean::class.javaObjectType + targetClass.getMethod(methodName, booleanType).invoke(target, value) } companion object { - internal val DEFAULT_BRIDGE_CLASS_NAMES = listOf( - "io.nekohasekai.p2pandroid.P2pandroid", - "com.gomtm.swarm.GomtmSwarm", - "com.gomtm.android.swarm.GomtmSwarm" - ) + internal const val DEFAULT_BRIDGE_CLASS_NAME = "io.nekohasekai.p2pandroid.P2pandroid" + internal const val DEFAULT_CONFIG_CLASS_NAME = "io.nekohasekai.p2pandroid.Config" } } diff --git a/app/src/main/java/com/gomtm/android/swarm/SwarmStatus.kt b/app/src/main/java/com/gomtm/android/swarm/SwarmStatus.kt index 59566d0..1fd2284 100644 --- a/app/src/main/java/com/gomtm/android/swarm/SwarmStatus.kt +++ b/app/src/main/java/com/gomtm/android/swarm/SwarmStatus.kt @@ -1,25 +1,23 @@ package com.gomtm.android.swarm data class SwarmStatus( - val integrationMode: String, - val aarDetected: Boolean, - val bridgeClassName: String?, - val lifecycleSurface: String, + val bridgeClassName: String, val state: String, - val peerId: String?, - val bootstrapAddress: String?, - val lastError: String? + val peerId: String, + val bootstrapAddress: String, + val lastError: String, + val discoveredPeers: List, + val rawDiscoveredPeers: String, ) { companion object { - fun missing(): SwarmStatus = SwarmStatus( - integrationMode = "host-shell", - aarDetected = false, - bridgeClassName = null, - lifecycleSurface = "unbound", - state = "AAR not bound", - peerId = null, - bootstrapAddress = null, - lastError = null + fun missing(message: String): SwarmStatus = SwarmStatus( + bridgeClassName = "", + state = "Error", + peerId = "", + bootstrapAddress = "", + lastError = message, + discoveredPeers = emptyList(), + rawDiscoveredPeers = "", ) } } diff --git a/app/src/main/java/com/gomtm/android/swarm/SwarmUiModel.kt b/app/src/main/java/com/gomtm/android/swarm/SwarmUiModel.kt new file mode 100644 index 0000000..cbc7cfa --- /dev/null +++ b/app/src/main/java/com/gomtm/android/swarm/SwarmUiModel.kt @@ -0,0 +1,19 @@ +package com.gomtm.android.swarm + +data class SwarmUiModel( + val connectedBootstrap: String, + val errorText: String, +) { + companion object { + const val VALUE_NOT_AVAILABLE = "Not available" + const val VALUE_NONE = "None" + + fun from(status: SwarmStatus, actionError: String?): SwarmUiModel { + return SwarmUiModel( + connectedBootstrap = status.bootstrapAddress.ifBlank { VALUE_NOT_AVAILABLE }, + errorText = actionError?.takeIf { it.isNotBlank() } + ?: status.lastError.ifBlank { VALUE_NONE }, + ) + } + } +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 289c201..08b39d9 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -2,19 +2,18 @@ + android:fillViewport="true" + android:padding="16dp"> + android:orientation="vertical"> @@ -22,150 +21,161 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginTop="8dp" - android:text="@string/swarm_shell_subtitle" - android:textColor="@color/text_primary" + android:text="@string/swarm_runtime_subtitle" android:textSize="16sp" /> - + android:layout_marginTop="20dp" + android:text="@string/bootstrap_input_label" + android:textStyle="bold" /> - + android:hint="@string/bootstrap_input_hint" + android:inputType="textNoSuggestions" + android:maxLines="3" + android:minLines="2" /> - + - + - + - + android:layout_weight="1" + android:text="@string/start_button" /> - + android:layout_marginStart="12dp" + android:layout_weight="1" + android:text="@string/stop_button" /> - + android:layout_marginStart="12dp" + android:layout_weight="1" + android:text="@string/refresh_button" /> + - + - + - + - + - + - + - + - + - + - + - + - + - - + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2eeb322..ef6eebe 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,20 +1,23 @@ gomtm-android - Public Android app host for the gomtm swarm runtime. - Refresh runtime probe - Swarm runtime probe - Summary - Integration mode - Detected bridge class - Lifecycle surface + Real Android swarm node host for gomtm. + Bootstrap address + /ip4/.../tcp/.../p2p/... + Node name + android-my-device + Start node + Stop node + Refresh + Swarm runtime + Bridge class Runtime state Peer ID - Bootstrap address + Connected bootstrap + Discovered peers Last error Recent logs - gomtm swarm bridge detected. This build can interrogate the bound runtime. - No gomtm swarm AAR is currently bound. This APK is acting as the public host shell only. Not available None No runtime logs yet. + No peers discovered in the current session yet. diff --git a/app/src/test/java/com/gomtm/android/swarm/SwarmRuntimeTest.kt b/app/src/test/java/com/gomtm/android/swarm/SwarmRuntimeTest.kt index ea60b06..4798a07 100644 --- a/app/src/test/java/com/gomtm/android/swarm/SwarmRuntimeTest.kt +++ b/app/src/test/java/com/gomtm/android/swarm/SwarmRuntimeTest.kt @@ -1,63 +1,89 @@ package com.gomtm.android.swarm import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test class SwarmRuntimeTest { @Test - fun exposesUnboundHostShellWhenBridgeIsMissing() { + fun exposesMissingBridgeAsErrorState() { val runtime = SwarmRuntime( - bridgeClassNames = listOf("does.not.exist.Bridge"), - classLoader = javaClass.classLoader ?: ClassLoader.getSystemClassLoader() + bridgeClassName = "does.not.exist.Bridge", + configClassName = FakeConfig::class.java.name, + classLoader = javaClass.classLoader ?: ClassLoader.getSystemClassLoader(), ) val status = runtime.probe() - assertEquals("host-shell", status.integrationMode) - assertFalse(status.aarDetected) - assertEquals("AAR not bound", status.state) + assertEquals("Error", status.state) + assertTrue(status.lastError.contains("gomtm swarm bridge unavailable")) } @Test - fun detectsLegacyWorkerLifecycleSurface() { + fun parsesCurrentNodeLifecycleSurface() { val runtime = SwarmRuntime( - bridgeClassNames = listOf(FakeLegacyBridge::class.java.name), - classLoader = FakeLegacyBridge::class.java.classLoader ?: ClassLoader.getSystemClassLoader() + bridgeClassName = FakeNodeBridge::class.java.name, + configClassName = FakeConfig::class.java.name, + classLoader = FakeNodeBridge::class.java.classLoader ?: ClassLoader.getSystemClassLoader(), ) val status = runtime.probe() - assertTrue(status.aarDetected) - assertEquals("worker-legacy", status.lifecycleSurface) - assertEquals("Running", status.state) + assertEquals("Registered", status.state) assertEquals("peer-123", status.peerId) assertEquals("/ip4/127.0.0.1/tcp/4101/p2p/test", status.bootstrapAddress) + assertEquals(1, status.discoveredPeers.size) + assertEquals("peer-b", status.discoveredPeers.single().peerId) } - class FakeLegacyBridge { + @Test + fun parsesDiscoveredPeersSnapshot() { + val json = + """ + {"schema_version":"v1","generated_at":"2026-03-27T10:00:00Z","peers":[{"peer_id":"peer-b","name":"android-b","state":"registered","discovered_in_current_session":true,"is_bootstrap":false,"last_seen_at":"2026-03-27T10:00:00Z"}]} + """.trimIndent() + + val peers = DiscoveredPeer.parseSnapshot(json) + + assertEquals(1, peers.size) + assertEquals("peer-b", peers.single().peerId) + assertEquals("android-b", peers.single().name) + } + + class FakeConfig { + fun setBootstrapAddr(@Suppress("UNUSED_PARAMETER") value: String) = Unit + fun setNodeName(@Suppress("UNUSED_PARAMETER") value: String) = Unit + fun setAutoReconnect(@Suppress("UNUSED_PARAMETER") value: Boolean) = Unit + } + + class FakeNodeBridge { companion object { @JvmStatic - fun StartWorker(baseDir: String, nodeName: String) = Unit + fun startNode(@Suppress("UNUSED_PARAMETER") baseDir: String, @Suppress("UNUSED_PARAMETER") config: FakeConfig) = Unit + + @JvmStatic + fun stopNode() = Unit @JvmStatic - fun StopWorker() = Unit + fun getState(): String = "Registered" @JvmStatic - fun GetState(): String = "Running" + fun getPeerID(): String = "peer-123" @JvmStatic - fun GetPeerID(): String = "peer-123" + fun getBootstrapAddr(): String = "/ip4/127.0.0.1/tcp/4101/p2p/test" @JvmStatic - fun GetBootstrapAddr(): String = "/ip4/127.0.0.1/tcp/4101/p2p/test" + fun getLastError(): String = "" @JvmStatic - fun GetLastError(): String = "" + fun getDiscoveredPeers(): String = + """ + {"schema_version":"v1","generated_at":"2026-03-27T10:00:00Z","peers":[{"peer_id":"peer-b","name":"android-b","state":"registered","discovered_in_current_session":true,"is_bootstrap":false,"last_seen_at":"2026-03-27T10:00:00Z"}]} + """.trimIndent() @JvmStatic - fun DrainLogs(): String = "bootstrap ok" + fun drainLogs(): String = "bootstrap ok" } } } diff --git a/app/src/test/java/com/gomtm/android/swarm/SwarmUiModelTest.kt b/app/src/test/java/com/gomtm/android/swarm/SwarmUiModelTest.kt new file mode 100644 index 0000000..e1f90e4 --- /dev/null +++ b/app/src/test/java/com/gomtm/android/swarm/SwarmUiModelTest.kt @@ -0,0 +1,63 @@ +package com.gomtm.android.swarm + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SwarmUiModelTest { + @Test + fun usesOnlyRealConnectedBootstrapValue() { + val status = SwarmStatus( + bridgeClassName = "bridge", + state = "Idle", + peerId = "", + bootstrapAddress = "", + lastError = "", + discoveredPeers = emptyList(), + rawDiscoveredPeers = "", + ) + + val model = SwarmUiModel.from(status = status, actionError = null) + + assertEquals(SwarmUiModel.VALUE_NOT_AVAILABLE, model.connectedBootstrap) + } + + @Test + fun preservesImmediateActionErrorOverRuntimeLastError() { + val status = SwarmStatus( + bridgeClassName = "bridge", + state = "Error", + peerId = "", + bootstrapAddress = "/ip4/127.0.0.1/tcp/4101/p2p/test", + lastError = "runtime error", + discoveredPeers = emptyList(), + rawDiscoveredPeers = "", + ) + + val model = SwarmUiModel.from(status = status, actionError = "reflection mismatch") + + assertEquals("reflection mismatch", model.errorText) + } + + @Test + fun fallsBackToRuntimeErrorThenNone() { + val runtimeErrorStatus = SwarmStatus( + bridgeClassName = "bridge", + state = "Error", + peerId = "", + bootstrapAddress = "", + lastError = "runtime error", + discoveredPeers = emptyList(), + rawDiscoveredPeers = "", + ) + assertEquals( + "runtime error", + SwarmUiModel.from(status = runtimeErrorStatus, actionError = null).errorText, + ) + + val cleanStatus = runtimeErrorStatus.copy(lastError = "") + assertEquals( + SwarmUiModel.VALUE_NONE, + SwarmUiModel.from(status = cleanStatus, actionError = null).errorText, + ) + } +} diff --git a/docs/roadmap.md b/docs/roadmap.md index 4396c25..b7dad92 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,71 +1,25 @@ # Roadmap -This document records the active direction for the public `gomtm-android` repository. +## Current milestone -## Direction reset +Deliver a public Android node app that can: -Older bootstrap-only wording is superseded. +- consume a published, pinned gomtm Android swarm AAR +- start a real gomtm swarm node runtime +- show peer id, bootstrap address, logs, and discovered peers +- ship through GitHub Actions with APK provenance recorded -The current source of truth is: +## Explicit non-goals for this milestone -- `gomtm-android` is the public Android app host -- `gomtm` remains the swarm kernel / Android AAR producer -- swarm-first is the correct order -- worker control, group control, and reward automation come later -- monorepo `apps/android/` is migration material only, not the future host repo +- rebuilding swarm logic in Kotlin +- extending monorepo `apps/android/` +- reward automation or group-control business logic +- local Android builds as the source of acceptance truth -## Phase 1: public swarm host shell +## Next milestone after swarm-first -Goals: +After the swarm node base is proven stable on real Android devices: -- keep the public Android app repository buildable and releasable via GitHub Actions -- expose a visible runtime shell for gomtm swarm integration -- support optional AAR consumption without requiring the AAR to exist in-repo -- make the app honest about bound vs unbound swarm runtime state - -Deliverables: - -- Android host screen for runtime state inspection -- reflection-based bridge wrapper so the app can compile with or without the AAR -- CI / release steps that can optionally download a swarm AAR into `app/libs/` - -## Phase 2: gomtm swarm AAR consumption - -Goals: - -- consume a versioned gomtm-produced Android AAR -- display real runtime information such as node state, peer id, bootstrap address, and logs -- keep the app shell repo free from duplicated swarm implementation - -Upstream `gomtm` still needs to settle: - -- stable Android bridge API -- downloadable AAR publication path -- artifact naming and compatibility contract - -## Phase 3: node lifecycle UX - -After Phase 2: - -- start / stop controls for the swarm node -- runtime configuration entry points -- connection diagnostics and structured logs -- bootstrap / relay onboarding - -## Phase 4: worker and control-plane surfaces - -Only after swarm substrate is stable: - -- worker metadata -- heartbeat / presence surfaces -- minimal control-plane views -- later group-control integration - -## Explicit non-goals for the current phase - -The current phase does **not**: - -- re-implement libp2p in Kotlin -- continue feature development in monorepo `apps/android/` -- promise that a public stable gomtm AAR feed already exists -- pull reward automation concerns into the Android host shell +- improve runtime persistence / lifecycle handling +- add better peer/session diagnostics +- extend worker control capabilities on top of the swarm base diff --git a/docs/swarm-runtime-integration.md b/docs/swarm-runtime-integration.md index 45fe383..d0f248d 100644 --- a/docs/swarm-runtime-integration.md +++ b/docs/swarm-runtime-integration.md @@ -1,44 +1,63 @@ # Swarm runtime integration -`gomtm-android` is being prepared to consume a gomtm-produced Android swarm AAR without pretending that the upstream publication contract is already finished. +`gomtm-android` consumes a **published, pinned** gomtm Android swarm AAR. ## Current approach -The app uses a **reflection-based integration seam**: +The public Android app host is no longer treated as a generic host shell. -- Gradle will include any optional `*.aar` dropped into `app/libs/` -- the Kotlin host shell does not compile against hard AAR symbols -- `SwarmRuntime` probes for known bridge class names at runtime -- if no bridge is present, the app still ships and clearly reports that it is running as a host shell only +Current contract: -## Why this is the right temporary shape +- GitHub Actions downloads a specific `gomtm-swarm-android.aar` +- GitHub Actions verifies its SHA256 before Gradle runs +- the app binds to the current gomtm node runtime surface +- the UI shows real runtime state, peer id, bootstrap address, logs, and discovered peers +- the APK release records which gomtm AAR version / URL / SHA256 it consumed -Right now, the public upstream `gomtm` releases do not expose a stable downloadable swarm AAR asset that this repo can treat as a guaranteed dependency. +## Upstream gomtm requirements -So the host repo should optimize for honesty: +`gomtm` must publish these release assets: -1. keep the public Android repo buildable -2. avoid inventing a second swarm implementation in Kotlin -3. avoid hard-wiring to a fake or private-only artifact path -4. preserve a narrow seam that a real gomtm AAR can plug into later +- `gomtm-swarm-android.aar` +- `gomtm-swarm-android.json` -## What upstream gomtm still needs to provide +The metadata file must at least expose: -Before this repo can become a real runtime consumer, upstream gomtm work still needs to settle: +- gomtm version +- gomtm commit +- gomtm source ref +- AAR sha256 +- upstream sing-box repo/ref/commit +- published timestamp -- artifact naming and versioning -- bridge API stability -- publication path for downloadable AAR artifacts -- compatibility rules between host APK versions and runtime AAR versions +## Downstream rules in this repo -## Current host shell contract +This repo must not: -Today the public app shell can safely expose: +- consume `latest` implicitly +- skip checksum validation +- release an APK without recording the gomtm AAR provenance it was built from -- whether a gomtm swarm bridge was detected -- which bridge class was found -- whether the lifecycle surface looks like `node`, `worker-legacy`, or `probe-only` -- current runtime state, peer id, bootstrap address, and last error if the bridge exports those methods -- drained runtime logs when available +This repo must: -This gives the repo a truthful and reviewable intermediate state instead of a fake finished integration. +1. pin `gomtm_swarm_aar_url` +2. pin `gomtm_swarm_aar_version` +3. pin `gomtm_swarm_aar_sha256` +4. download the AAR into `app/libs/` +5. validate the checksum +6. run Gradle only after the AAR contract is satisfied + +## UI contract + +The public app UI must expose: + +- start node +- stop node +- runtime state +- peer id +- bootstrap address +- discovered peers list +- last error +- recent logs + +No part of the UI should pretend that a node is running when the bound runtime is absent or misconfigured.