Phase 1: minimal Kotlin Android app shows Hub /health

Scaffold the Android Gradle project under android/ mirroring the proven
internet_use_control toolchain: Gradle wrapper 8.11.1, AGP 8.7.3, Kotlin
2.0.21, compileSdk/targetSdk 35, Java/Kotlin 17, minSdk 29 (AGENTS.md §9).

One Activity calls GET http://192.168.20.13:8788/health off the main thread
and renders ok, service, version and every checks value plus the raw body.
INTERNET permission and usesCleartextTraffic are declared for the LAN Hub.
A failed or non-2xx call renders an explicit error line and never reports
success (AGENTS.md §10 rule 4).

applicationId au.audasmedia.hub.phone, versionName 0.1.0-phase1.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Paperclip
2026-10-14 14:46:35 +11:00
parent 43cc6c97aa
commit 8f954e36f4
12 changed files with 631 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "au.audasmedia.hub.phone"
compileSdk = 35
defaultConfig {
applicationId = "au.audasmedia.hub.phone"
// AGENTS.md §9: API 29 (Android 10) is the suggested minimum. The app
// targets API 35 but runs on Android 10 and up.
minSdk = 29
targetSdk = 35
versionCode = 1
versionName = "0.1.0-phase1"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
// Phase 1 keeps the dependency set empty on purpose: the health check uses
// HttpURLConnection (JDK/Android) and org.json (Android platform). No appcompat,
// no okhttp, no gson.
dependencies {
}

1
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1 @@
# Phase 1 keeps no custom ProGuard rules. The release build is not minified.

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Kotlin Phone App — Phase 1 (AGENTS.md §11 Phase 1).
This is the smallest possible proof that the Android build environment works
end to end: one Activity that calls the Hub `GET /health` and shows the reply.
INTERNET is required to reach the Hub. usesCleartextTraffic is true because
the Hub speaks plain HTTP on the LAN and Android blocks cleartext by default
on API 28+ (AGENTS.md §11 Phase 1, step 3).
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="false"
android:label="Hub Phone (Phase 1)"
android:supportsRtl="true"
android:theme="@android:style/Theme.Material.Light"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="Hub Phone (Phase 1)">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,152 @@
package au.audasmedia.hub.phone
import android.app.Activity
import android.graphics.Color
import android.graphics.Typeface
import android.os.Bundle
import android.util.TypedValue
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
/**
* Phase 1 proof screen.
*
* Calls the Hub `GET /health` off the main thread and renders the reply. A
* failed call shows an explicit error line and never reports success
* (AGENTS.md §10 rule 4).
*/
class MainActivity : Activity() {
private lateinit var statusView: TextView
private lateinit var resultView: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pad = dp(20)
val root = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(pad, pad, pad, pad)
setBackgroundColor(Color.parseColor("#f6f5f4"))
}
val title = TextView(this).apply {
text = "Kotlin Phone App — Phase 1"
setTextSize(TypedValue.COMPLEX_UNIT_SP, 22f)
setTypeface(typeface, Typeface.BOLD)
setTextColor(Color.parseColor("#171717"))
}
root.addView(title)
val subtitle = TextView(this).apply {
text = "Hub health check — no auth"
setTextSize(TypedValue.COMPLEX_UNIT_SP, 14f)
setTextColor(Color.parseColor("#6b6b6b"))
setPadding(0, dp(4), 0, dp(4))
}
root.addView(subtitle)
statusView = TextView(this).apply {
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
setTextColor(Color.parseColor("#0075de"))
text = "Calling $HEALTH_URL …"
setPadding(0, dp(12), 0, dp(12))
}
root.addView(statusView)
resultView = TextView(this).apply {
setTextSize(TypedValue.COMPLEX_UNIT_SP, 15f)
setTextColor(Color.parseColor("#171717"))
typeface = Typeface.MONOSPACE
}
root.addView(resultView)
setContentView(ScrollView(this).apply { addView(root) })
fetchHealth()
}
private fun fetchHealth() {
Thread {
try {
val conn = (URL(HEALTH_URL).openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
connectTimeout = 8000
readTimeout = 8000
setRequestProperty("Accept", "application/json")
}
val code = conn.responseCode
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
val body = stream?.bufferedReader()?.use { it.readText() } ?: ""
conn.disconnect()
runOnUiThread { render(code, body) }
} catch (e: Exception) {
val reason = "${e.javaClass.simpleName}: ${e.message}"
runOnUiThread { renderError(reason) }
}
}.start()
}
private fun render(code: Int, body: String) {
if (code !in 200..299) {
renderError("HTTP $code from $HEALTH_URL\n$body")
return
}
val json = try {
JSONObject(body)
} catch (e: Exception) {
renderError("Unparseable JSON from $HEALTH_URL\n$body")
return
}
if (!json.optBoolean("ok", false)) {
renderError("Hub reported ok=false\n$body")
return
}
val out = StringBuilder()
out.append("HTTP $code — HEALTHY\n\n")
out.append("ok : ${json.optBoolean("ok")}\n")
out.append("service : ${json.optString("service")}\n")
out.append("version : ${json.optString("version")}\n")
out.append("uptime_s : ${json.optLong("uptime_s")}\n")
out.append("\nchecks:\n")
val checks = json.optJSONObject("checks")
if (checks != null && checks.length() > 0) {
for (key in checks.keys()) {
out.append(" $key : ${checks.optString(key)}\n")
}
} else {
out.append(" (none)\n")
}
out.append("\nraw:\n$body")
statusView.text = "Hub reachable"
statusView.setTextColor(Color.parseColor("#1a7f37"))
resultView.setTextColor(Color.parseColor("#171717"))
resultView.text = out.toString()
}
private fun renderError(message: String) {
statusView.text = "ERROR — Hub call failed"
statusView.setTextColor(Color.parseColor("#c01c28"))
resultView.setTextColor(Color.parseColor("#c01c28"))
resultView.text = "$message\n\nURL: $HEALTH_URL"
}
private fun dp(value: Int): Int = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
value.toFloat(),
resources.displayMetrics,
).toInt()
companion object {
private const val HEALTH_URL = "http://192.168.20.13:8788/health"
}
}