From db373755800dd1ae1097429934e27eb477dafd63 Mon Sep 17 00:00:00 2001 From: nehtechnine-hub Date: Mon, 8 Jun 2026 01:02:05 +0200 Subject: [PATCH 1/3] Scaffold Android app: download-on-first-run Alpine rootfs + proot; terminal UI --- .gitignore | 8 ++ README.md | 30 ++++- app/build.gradle | 32 +++++ app/src/main/AndroidManifest.xml | 20 +++ .../com/nehtechnine/loadatom/MainActivity.kt | 115 ++++++++++++++++++ .../nehtechnine/loadatom/RootfsInstaller.kt | 88 ++++++++++++++ app/src/main/res/layout/activity_main.xml | 37 ++++++ build.gradle | 16 +++ settings.gradle | 2 + 9 files changed, 344 insertions(+), 4 deletions(-) create mode 100644 .gitignore create mode 100644 app/build.gradle create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/com/nehtechnine/loadatom/MainActivity.kt create mode 100644 app/src/main/java/com/nehtechnine/loadatom/RootfsInstaller.kt create mode 100644 app/src/main/res/layout/activity_main.xml create mode 100644 build.gradle create mode 100644 settings.gradle diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..280854e --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Ignore build outputs +.gradle +/local.properties +/.idea +/build +**/build +*.iml +/.cxx diff --git a/README.md b/README.md index be9b00c..3375705 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..76d735a --- /dev/null +++ b/app/build.gradle @@ -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' +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..cf4b3e9 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/nehtechnine/loadatom/MainActivity.kt b/app/src/main/java/com/nehtechnine/loadatom/MainActivity.kt new file mode 100644 index 0000000..ff07b00 --- /dev/null +++ b/app/src/main/java/com/nehtechnine/loadatom/MainActivity.kt @@ -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() + } +} diff --git a/app/src/main/java/com/nehtechnine/loadatom/RootfsInstaller.kt b/app/src/main/java/com/nehtechnine/loadatom/RootfsInstaller.kt new file mode 100644 index 0000000..093a568 --- /dev/null +++ b/app/src/main/java/com/nehtechnine/loadatom/RootfsInstaller.kt @@ -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() + } + } +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..61c1fd4 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + {loading &&
Loading…
} + {error &&
Error: {error}
} + + + + ) +} diff --git a/weather-dashboard/src/main.jsx b/weather-dashboard/src/main.jsx new file mode 100644 index 0000000..e8f6991 --- /dev/null +++ b/weather-dashboard/src/main.jsx @@ -0,0 +1,6 @@ +import React from 'react' +import { createRoot } from 'react-dom/client' +import App from './App' +import './styles.css' + +createRoot(document.getElementById('root')).render() diff --git a/weather-dashboard/src/styles.css b/weather-dashboard/src/styles.css new file mode 100644 index 0000000..08215de --- /dev/null +++ b/weather-dashboard/src/styles.css @@ -0,0 +1,16 @@ +:root { --accent: #2563eb; --bg: #f9fafb; --card: #ffffff; } +body { font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; margin: 0; padding: 1.5rem; background: var(--bg); color: #111827; } +.container { max-width: 900px; margin: 0 auto; } +.controls { display:flex; gap:0.5rem; margin-bottom:1rem; } +.controls input { flex:1; padding:0.6rem; border-radius:6px; border:1px solid #e5e7eb; } +.controls button { padding:0.6rem 0.9rem; border-radius:6px; border:0; background:var(--accent); color:white; cursor:pointer; } +.card { background:var(--card); padding:1rem; border-radius:10px; box-shadow:0 1px 4px rgba(2,6,23,0.06); margin-bottom:1rem; display:flex; align-items:center; } +.row { display:flex; align-items:center; gap:1rem; width:100%; } +.title { font-weight:700; font-size:1.2rem; } +.desc { color:#374151; text-transform:capitalize; } +.temp { margin-left:auto; text-align:right; } +.big { font-size:1.8rem; font-weight:700; } +.forecast { display:flex; gap:0.5rem; flex-wrap:wrap; } +.forecast-item { background:white; padding:0.5rem 0.75rem; border-radius:8px; width:140px; text-align:center; box-shadow:0 1px 3px rgba(2,6,23,0.04); } +.error { color:#b91c1c; margin-bottom:0.5rem; } +.info { color:#374151; margin-bottom:0.5rem; } diff --git a/weather-dashboard/vite.config.js b/weather-dashboard/vite.config.js new file mode 100644 index 0000000..eca6e2f --- /dev/null +++ b/weather-dashboard/vite.config.js @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()] +})