feat: add Android client with service integration, UI, and build configuration

This commit is contained in:
Sarto
2026-03-31 17:50:05 +03:30
parent b89fc93829
commit 96779ea4d1
16 changed files with 489 additions and 1 deletions
+4
View File
@@ -0,0 +1,4 @@
.gradle/
local.properties
build/
app/build/
+42
View File
@@ -0,0 +1,42 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'com.thefeed.android'
compileSdk 35
defaultConfig {
applicationId 'com.thefeed.android'
minSdk 26
targetSdk 35
versionCode 1
versionName '1.0.0'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.15.0'
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.activity:activity-ktx:1.10.1'
}
+1
View File
@@ -0,0 +1 @@
# Intentionally minimal. The app mostly wraps a local process and WebView.
+32
View File
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:allowBackup="true"
android:icon="@android:drawable/sym_def_app_icon"
android:label="@string/app_name"
android:roundIcon="@android:drawable/sym_def_app_icon"
android:supportsRtl="true"
android:theme="@style/Theme.Thefeed">
<service
android:name=".ThefeedService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
+12
View File
@@ -0,0 +1,12 @@
Place the Android client binary in this folder before building:
Filename required:
- thefeed-client
How to produce it from project root:
- make build-android-arm64
- cp build/thefeed-client-android-arm64 android/app/src/main/assets/thefeed-client
The app copies this file to internal storage, marks it executable, and runs it as:
- --data-dir <app files dir>/thefeeddata
- --port 8080
@@ -0,0 +1,80 @@
package com.thefeed.android
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Button
import android.widget.TextView
import androidx.activity.ComponentActivity
class MainActivity : ComponentActivity() {
private lateinit var webView: WebView
private lateinit var txtStatus: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
webView = findViewById(R.id.webView)
txtStatus = findViewById(R.id.txtStatus)
findViewById<Button>(R.id.btnStart).setOnClickListener {
startThefeedService()
txtStatus.text = "Service started. Opening local UI..."
loadLocalWeb()
}
findViewById<Button>(R.id.btnStop).setOnClickListener {
stopService(Intent(this, ThefeedService::class.java))
txtStatus.text = "Service stopped"
}
findViewById<Button>(R.id.btnReload).setOnClickListener {
loadLocalWeb()
}
configureWebView()
// Start in background by default so WebView can connect on first launch.
startThefeedService()
loadLocalWeb()
}
private fun startThefeedService() {
val intent = Intent(this, ThefeedService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(intent)
} else {
startService(intent)
}
}
private fun configureWebView() {
webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
txtStatus.text = "Connected to local UI"
}
}
with(webView.settings) {
javaScriptEnabled = true
domStorageEnabled = true
cacheMode = WebSettings.LOAD_DEFAULT
allowFileAccess = false
allowContentAccess = false
}
}
private fun loadLocalWeb() {
txtStatus.text = "Loading http://127.0.0.1:8080 ..."
webView.postDelayed({ webView.loadUrl("http://127.0.0.1:8080") }, 500)
}
override fun onDestroy() {
webView.destroy()
super.onDestroy()
}
}
@@ -0,0 +1,119 @@
package com.thefeed.android
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import java.io.File
import java.io.FileOutputStream
class ThefeedService : Service() {
private var process: Process? = null
override fun onCreate() {
super.onCreate()
createNotificationChannel()
startForeground(NOTIFICATION_ID, buildNotification("Starting local service..."))
startClientProcess()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return START_STICKY
}
override fun onDestroy() {
process?.destroy()
process = null
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
private fun startClientProcess() {
try {
val bin = ensureBinary()
val dataDir = File(filesDir, "thefeeddata")
if (!dataDir.exists()) {
dataDir.mkdirs()
}
val pb = ProcessBuilder(
bin.absolutePath,
"--data-dir", dataDir.absolutePath,
"--port", "8080"
)
pb.redirectErrorStream(true)
process = pb.start()
val outputReader = process?.inputStream?.bufferedReader()
Thread {
try {
while (true) {
val line = outputReader?.readLine() ?: break
updateForegroundNotification(line)
}
} catch (_: Exception) {
}
}.start()
updateForegroundNotification("Running on http://127.0.0.1:8080")
} catch (e: Exception) {
updateForegroundNotification("Failed: ${e.message}")
stopSelf()
}
}
private fun ensureBinary(): File {
val target = File(filesDir, "thefeed-client")
if (target.exists() && target.length() > 0L && target.canExecute()) {
return target
}
assets.open("thefeed-client").use { input ->
FileOutputStream(target).use { out ->
input.copyTo(out)
}
}
if (!target.setExecutable(true, true)) {
throw IllegalStateException("Could not set executable bit on bundled binary")
}
return target
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"thefeed background service",
NotificationManager.IMPORTANCE_LOW
)
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
}
private fun buildNotification(message: String): Notification {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("thefeed")
.setContentText(message)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setOngoing(true)
.build()
}
private fun updateForegroundNotification(message: String) {
val manager = getSystemService(NotificationManager::class.java)
manager.notify(NOTIFICATION_ID, buildNotification(message))
}
companion object {
private const val CHANNEL_ID = "thefeed_service"
private const val NOTIFICATION_ID = 1201
}
}
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@color/bg">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="10dp"
android:gravity="center_vertical"
android:background="@color/bgPanel">
<Button
android:id="@+id/btnStart"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/start_service" />
<Button
android:id="@+id/btnStop"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:text="@string/stop_service" />
<Button
android:id="@+id/btnReload"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:text="@string/reload_webview" />
</LinearLayout>
<TextView
android:id="@+id/txtStatus"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="10dp"
android:textColor="@color/text"
android:text="@string/webview_loading" />
<WebView
android:id="@+id/webView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
</LinearLayout>
@@ -0,0 +1,6 @@
<resources>
<color name="bg">#0B1020</color>
<color name="bgPanel">#132247</color>
<color name="accent">#F59E0B</color>
<color name="text">#EDF2FF</color>
</resources>
@@ -0,0 +1,7 @@
<resources>
<string name="app_name">thefeed</string>
<string name="webview_loading">Loading local web UI...</string>
<string name="start_service">Start</string>
<string name="stop_service">Stop</string>
<string name="reload_webview">Reload</string>
</resources>
@@ -0,0 +1,8 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="Theme.Thefeed" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="colorPrimary">@color/bgPanel</item>
<item name="colorPrimaryVariant">@color/bgPanel</item>
<item name="colorOnPrimary">@color/text</item>
<item name="android:statusBarColor" tools:targetApi="l">@color/bg</item>
</style>
</resources>
+4
View File
@@ -0,0 +1,4 @@
plugins {
id 'com.android.application' version '8.7.3' apply false
id 'org.jetbrains.kotlin.android' version '2.0.21' apply false
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "thefeed-android"
include ':app'