Skip to content
Open
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
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Ignore build outputs
.gradle
/local.properties
/.idea
/build
**/build
*.iml
/.cxx
17 changes: 17 additions & 0 deletions PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
Title: Scaffold: LoadAtom Neutron Editor (proot rootfs downloader)

Summary: Adds an Android Studio scaffold for LoadAtom Neutron Editor. The app downloads a minimal Alpine rootfs and a proot binary on first run, then launches a shell in a proot container. Terminal-only UI for v0.1.

Files added: Android project (app/), MainActivity, RootfsInstaller (download/extract), README, .gitignore.

Important: The scaffold uses placeholder URLs for the rootfs zip and proot binaries in app/src/main/java/com/nehtechnine/loadatom/RootfsInstaller.kt. You must host valid artifacts and update those constants before the app can run.

TODOs:
- Host rootfs/proot binaries and replace placeholder URLs.
- Add checksum verification for downloads.
- Integrate a proper pty-based terminal emulator for full interactive experience.
- Add multi-ABI proot binaries and fallback selection.

Testing: Build in Android Studio, run on arm64/armv7 device/emulator, allow first-run download.

License: MIT
30 changes: 26 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,29 @@
# .github
# LoadAtom Neutron Editor — scaffold

These are the [default community health files](https://help.github.com/en/articles/creating-a-default-community-health-file-for-your-organization) for the MicrosoftDocs organization on GitHub.
This scaffold creates an Android app that downloads a Linux rootfs and a proot binary on first run and launches a shell inside it.

# Contributing
Important notes (you must complete these before the app will work):

Microsoft projects adopt the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.
1. Provide a rootfs zip and proot binaries
- The scaffold references placeholder URLs in app/src/main/java/com/nehtechnine/loadatom/RootfsInstaller.kt:
- ROOTFS_ZIP_URL
- PROOT_URL_ARM64
- PROOT_URL_ARM7
- Host a zip file containing a minimal rootfs (for example, Alpine minirootfs packed as a zip). The zip should contain the filesystem root entries (bin, lib, etc.).
- Provide statically-linked proot binaries for the target ABIs and point PROOT_URL_* at them.

2. APK size and bandwidth
- By choosing "download on first run" the APK stays small. The first run will download the rootfs and proot binaries (~tens of MB).

3. Terminal UI
- The scaffold implements a very simple terminal UI (TextView + EditText). For a better terminal experience, integrate an Android terminal emulator view (eg. Jackpal's Android-Terminal-Emulator) or Termux's terminal view.

4. Build
- Open the project in Android Studio (recommended) and build. Ensure your SDK/NDK versions match the Gradle plugin.

5. Improvements / TODOs
- Add checksum verification for downloaded artifacts.
- Support multiple ABI proot downloads and extraction.
- Use a proper pty-based terminal for interactive programs (vim, nano, ssh).

License: MIT
32 changes: 32 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
apply plugin: 'com.android.application'

android {
compileSdkVersion 33
defaultConfig {
applicationId "com.nehtechnine.loadatom"
minSdkVersion 24
targetSdkVersion 33
versionCode 1
versionName "0.1"

ndk {
abiFilters "arm64-v8a", "armeabi-v7a"
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_11
targetCompatibility JavaVersion.VERSION_11
}
}

dependencies {
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1'
}
20 changes: 20 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.nehtechnine.loadatom">

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

<application
android:allowBackup="true"
android:label="LoadAtom Neutron Editor"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true">
<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
115 changes: 115 additions & 0 deletions app/src/main/java/com/nehtechnine/loadatom/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package com.nehtechnine.loadatom

import android.os.Bundle
import android.widget.Button
import android.widget.EditText
import android.widget.ScrollView
import android.widget.TextView
import androidx.appcompat.app.AppCompatActivity
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.io.*

class MainActivity : AppCompatActivity() {
private lateinit var output: TextView
private lateinit var input: EditText
private lateinit var sendBtn: Button
private lateinit var scroll: ScrollView

private var process: Process? = null
private var processWriter: BufferedWriter? = null

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)

output = findViewById(R.id.output)
input = findViewById(R.id.input)
sendBtn = findViewById(R.id.sendBtn)
scroll = findViewById(R.id.scroll)

appendOutput("Preparing rootfs and proot (first run may download ~30-100MB)...\n")

GlobalScope.launch(Dispatchers.IO) {
try {
RootfsInstaller.ensureRootfs(applicationContext)
appendOutput("Rootfs ready. Launching shell...\n")
launchProotShell()
} catch (e: Exception) {
appendOutput("Error during setup: ${'$'}{e.message}\n")
}
}

sendBtn.setOnClickListener {
val text = input.text.toString()
input.setText("")
sendToProcess(text + "\n")
}
}

private fun appendOutput(s: String) {
runOnUiThread {
output.append(s)
scroll.post { scroll.fullScroll(ScrollView.FOCUS_DOWN) }
}
}

private fun launchProotShell() {
val filesDir = filesDir
val proot = File(filesDir, "proot")
val rootfs = File(filesDir, "rootfs")
if (!proot.exists() || !rootfs.exists()) {
appendOutput("Required files missing.\n")
return
}
proot.setExecutable(true)

val pb = ProcessBuilder(proot.absolutePath, "-S", rootfs.absolutePath, "/bin/sh")
pb.redirectErrorStream(true)
process = pb.start()

processWriter = BufferedWriter(OutputStreamWriter(process!!.outputStream))

// Read output
Thread {
val reader = BufferedReader(InputStreamReader(process!!.inputStream))
var line: String?
try {
while (reader.readLine().also { line = it } != null) {
appendOutput(line + "\n")
}
} catch (e: IOException) {
appendOutput("Process output read error: ${'$'}{e.message}\n")
}
}.start()

// Wait for process
Thread {
try {
val rc = process!!.waitFor()
appendOutput("Shell exited (rc=${'$'}rc)\n")
} catch (e: InterruptedException) {
appendOutput("Process was interrupted\n")
}
}.start()
}

private fun sendToProcess(s: String) {
GlobalScope.launch(Dispatchers.IO) {
try {
processWriter?.apply {
write(s)
flush()
} ?: run { appendOutput("Shell not ready.\n") }
} catch (e: IOException) {
appendOutput("Failed to send input: ${'$'}{e.message}\n")
}
}
}

override fun onDestroy() {
super.onDestroy()
process?.destroy()
}
}
88 changes: 88 additions & 0 deletions app/src/main/java/com/nehtechnine/loadatom/RootfsInstaller.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package com.nehtechnine.loadatom

import android.content.Context
import android.util.Log
import java.io.*
import java.net.HttpURLConnection
import java.net.URL
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream

object RootfsInstaller {
private const val TAG = "RootfsInstaller"
// IMPORTANT: Replace these URLs with a hosted rootfs zip and a proot binary for each ABI.
// The scaffold uses placeholders. You'll need to host or point to real files.
private const val ROOTFS_ZIP_URL = "https://example.com/alpine-rootfs-arm64.zip"
private const val PROOT_URL_ARM64 = "https://example.com/proot-arm64"
private const val PROOT_URL_ARM7 = "https://example.com/proot-arm7"

fun ensureRootfs(ctx: Context) {
val filesDir = ctx.filesDir
val rootfsDir = File(filesDir, "rootfs")
val prootFile = File(filesDir, "proot")

if (!rootfsDir.exists()) {
// Download and extract rootfs zip
val zipFile = File(filesDir, "rootfs.zip")
downloadFile(ROOTFS_ZIP_URL, zipFile)
unzip(zipFile, rootfsDir)
zipFile.delete()
}

if (!prootFile.exists()) {
val abi = android.os.Build.SUPPORTED_ABIS.firstOrNull() ?: "arm64-v8a"
val url = when {
abi.contains("arm64") -> PROOT_URL_ARM64
abi.contains("arm") -> PROOT_URL_ARM7
else -> PROOT_URL_ARM64
}
downloadFile(url, prootFile)
prootFile.setExecutable(true)
}
}

private fun downloadFile(urlStr: String, outFile: File) {
Log.i(TAG, "Downloading $urlStr -> ${outFile.absolutePath}")
val url = URL(urlStr)
val conn = url.openConnection() as HttpURLConnection
conn.connectTimeout = 15000
conn.readTimeout = 15000
conn.requestMethod = "GET"
conn.doInput = true
conn.connect()
if (conn.responseCode != HttpURLConnection.HTTP_OK) {
throw IOException("Server returned HTTP ${conn.responseCode} ${conn.responseMessage}")
}
val input = conn.inputStream
val output = FileOutputStream(outFile)
input.copyTo(output)
output.flush()
output.close()
input.close()
conn.disconnect()
}

private fun unzip(zipFile: File, targetDir: File) {
val buffer = ByteArray(4096)
ZipInputStream(FileInputStream(zipFile)).use { zis ->
var ze: ZipEntry? = zis.nextEntry
while (ze != null) {
val fileName = ze.name
val newFile = File(targetDir, fileName)
if (ze.isDirectory) {
newFile.mkdirs()
} else {
newFile.parentFile?.mkdirs()
FileOutputStream(newFile).use { fos ->
var len: Int
while (zis.read(buffer).also { len = it } > 0) {
fos.write(buffer, 0, len)
}
}
}
ze = zis.nextEntry
}
zis.closeEntry()
}
}
}
37 changes: 37 additions & 0 deletions app/src/main/res/layout/activity_main.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/scroll"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fillViewport="true">

<LinearLayout
android:orientation="vertical"
android:padding="8dp"
android:layout_width="match_parent"
android:layout_height="wrap_content">

<TextView
android:id="@+id/output"
android:layout_width="match_parent"
android:layout_height="400dp"
android:background="#000000"
android:textColor="#00FF00"
android:textIsSelectable="true"
android:padding="8dp" />

<EditText
android:id="@+id/input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="Type command" />

<Button
android:id="@+id/sendBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Send" />

</LinearLayout>

</ScrollView>
16 changes: 16 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Top-level build.gradle (Groovy)
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:7.4.2'
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
2 changes: 2 additions & 0 deletions settings.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
include ':app'
rootProject.name = 'LoadAtomNeutronEditor'
4 changes: 4 additions & 0 deletions weather-dashboard/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules
dist
.env
.DS_Store
Loading