mirror of
https://github.com/sartoopjj/thefeed.git
synced 2026-05-19 04:44:35 +03:00
feat: add Android client with service integration, UI, and build configuration
This commit is contained in:
@@ -92,8 +92,52 @@ jobs:
|
||||
name: binaries-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: build/
|
||||
|
||||
release:
|
||||
android-apk:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Stage Android client binary into assets
|
||||
run: |
|
||||
test -f artifacts/thefeed-client-android-arm64
|
||||
mkdir -p android/app/src/main/assets
|
||||
cp artifacts/thefeed-client-android-arm64 android/app/src/main/assets/thefeed-client
|
||||
chmod +x android/app/src/main/assets/thefeed-client
|
||||
|
||||
- name: Set up Java
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
- name: Set up Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- name: Generate wrapper and build debug APK
|
||||
working-directory: android
|
||||
run: |
|
||||
gradle wrapper --gradle-version 8.10.2
|
||||
./gradlew --no-daemon assembleDebug
|
||||
|
||||
- name: Rename APK
|
||||
run: |
|
||||
cp android/app/build/outputs/apk/debug/app-debug.apk artifacts/thefeed-android-arm64.apk
|
||||
|
||||
- name: Upload Android APK artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: thefeed-android-apk
|
||||
path: artifacts/thefeed-android-arm64.apk
|
||||
|
||||
release:
|
||||
needs: [build, android-apk]
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -108,3 +152,4 @@ jobs:
|
||||
with:
|
||||
files: artifacts/*
|
||||
generate_release_notes: true
|
||||
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||
|
||||
@@ -178,6 +178,45 @@ chmod +x thefeed-client
|
||||
# Open in browser: http://127.0.0.1:8080
|
||||
```
|
||||
|
||||
#### Android (Native APK Wrapper)
|
||||
|
||||
> download it from the latest release assets: `thefeed-android-arm64.apk`
|
||||
|
||||
|
||||
You can build or download a native Android app that:
|
||||
- runs thefeed client binary in a foreground/background service
|
||||
- opens the local web UI inside an in-app WebView
|
||||
|
||||
Project path:
|
||||
- `android/`
|
||||
|
||||
Build steps:
|
||||
|
||||
```bash
|
||||
# 1) Build Android binary from project root
|
||||
make build-android-arm64
|
||||
|
||||
# 2) Copy binary into Android app assets (required filename)
|
||||
cp build/thefeed-client-android-arm64 android/app/src/main/assets/thefeed-client
|
||||
|
||||
# 3) Build debug APK
|
||||
cd android
|
||||
gradle wrapper --gradle-version 8.10.2
|
||||
./gradlew assembleDebug
|
||||
```
|
||||
|
||||
APK output:
|
||||
|
||||
```bash
|
||||
android/app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
Install on device:
|
||||
|
||||
```bash
|
||||
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
|
||||
```
|
||||
|
||||
### Web UI
|
||||
|
||||
The browser-based UI has:
|
||||
@@ -202,6 +241,20 @@ make fmt # Format code
|
||||
make clean # Remove build artifacts
|
||||
```
|
||||
|
||||
## Releases (GitHub Actions)
|
||||
|
||||
Pushing a tag that starts with `v` triggers CI build + GitHub Release.
|
||||
|
||||
- Stable release tag example: `v1.4.0`
|
||||
- Pre-release tag examples: `v1.4.0-rc1`, `v1.4.0-beta.2`
|
||||
|
||||
Rule:
|
||||
- If tag contains `-`, release is marked as **pre-release** automatically.
|
||||
|
||||
Release assets include:
|
||||
- Server/client binaries for all current target platforms
|
||||
- Native Android wrapper APK: `thefeed-android-arm64.apk`
|
||||
|
||||
## DNS Records Setup
|
||||
|
||||
You need **two DNS records** on your domain. Suppose your server IP is `203.0.113.10` and you want to use `example.com`:
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.gradle/
|
||||
local.properties
|
||||
build/
|
||||
app/build/
|
||||
@@ -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'
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
# Intentionally minimal. The app mostly wraps a local process and WebView.
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user