Add benchmark flavor and CI workflow to build/upload APK per commit

Introduces a `benchmark` product flavor that mirrors the `free` online
network stack but uses a distinct applicationId suffix (`.benchmark`) so
it installs side-by-side with a production build. A new GitHub Actions
workflow builds the benchmark APK on every push and uploads it as an
artifact, making per-commit changes installable and reviewable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015X6g3nwsWu6HbuwpA2ALu8
This commit is contained in:
Claude
2026-06-18 22:33:08 +00:00
parent 01b0fd5c19
commit 03b3788a69
4 changed files with 315 additions and 0 deletions
+54
View File
@@ -0,0 +1,54 @@
name: Benchmark APK
# Builds the `benchmark` flavor APK on every push so the changes in each commit
# can be downloaded, installed, and reviewed. The benchmark flavor uses a distinct
# applicationId suffix, so it installs side-by-side with a production build.
on:
push:
workflow_dispatch:
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout source
uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: 21
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Grant execute permission to Gradle wrapper
run: chmod +x ./gradlew
- name: Create keystore.properties
run: touch keystore.properties
- name: Build benchmark APK
run: ./gradlew assembleBenchmarkDebug --no-daemon --stacktrace
- name: Upload benchmark APK
uses: actions/upload-artifact@v4
with:
name: amber-benchmark-${{ github.sha }}
path: app/build/outputs/apk/benchmark/debug/app-benchmark-universal-debug.apk
retention-days: 14
if-no-files-found: error
+14
View File
@@ -141,6 +141,14 @@ android {
register("offline") {
dimension = "version"
}
// Online variant (same network stack as `free`) built and uploaded by CI on
// every push so changes can be installed and reviewed per commit. The distinct
// applicationId suffix lets it sit side-by-side with a production install.
register("benchmark") {
dimension = "version"
applicationIdSuffix = ".benchmark"
versionNameSuffix = "-BENCHMARK"
}
}
splits {
@@ -238,6 +246,12 @@ dependencies {
"freeImplementation"(libs.kmptor.runtime)
"freeImplementation"(libs.kmptor.resource.exec)
// benchmark mirrors the `free` online network stack
"benchmarkImplementation"(libs.okhttp)
"benchmarkImplementation"(libs.okhttpCoroutines)
"benchmarkImplementation"(libs.kmptor.runtime)
"benchmarkImplementation"(libs.kmptor.resource.exec)
// Load images from the web.
implementation(libs.coil.compose)
// view gifs
+68
View File
@@ -0,0 +1,68 @@
<?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" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<queries>
<package android:name="org.torproject.android"/>
</queries>
<application
android:name=".Amber">
<service
android:name=".service.ConnectivityService"
android:enabled="true"
android:foregroundServiceType="specialUse"
android:exported="false">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Run a foreground service to keep the connection to the relays active"
/>
</service>
<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>
<activity
android:name=".SignerActivity"
android:exported="true"
android:taskAffinity=""
android:theme="@style/Theme.NostrSigner.Modal">
<intent-filter>
<action android:name="com.greenart7c3.nostrsigner.MAIN_ACTIVITY" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nostrsigner" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nostrconnect" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,179 @@
package com.greenart7c3.nostrsigner.service
import android.Manifest
import android.annotation.SuppressLint
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationChannelGroupCompat
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.greenart7c3.nostrsigner.MainActivity
import com.greenart7c3.nostrsigner.R
import com.greenart7c3.nostrsigner.okhttp.HttpClientManager
import io.matthewnelson.kmp.tor.resource.exec.tor.ResourceLoaderTorExec
import io.matthewnelson.kmp.tor.runtime.Action.Companion.startDaemonAsync
import io.matthewnelson.kmp.tor.runtime.Action.Companion.stopDaemonAsync
import io.matthewnelson.kmp.tor.runtime.RuntimeEvent
import io.matthewnelson.kmp.tor.runtime.TorRuntime
import io.matthewnelson.kmp.tor.runtime.core.OnEvent
import io.matthewnelson.kmp.tor.runtime.core.config.TorOption
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
object TorManager {
private const val TAG = "TorManager"
private const val TOR_NOTIFICATION_ID = 3
private const val TOR_CHANNEL_ID = "TorChannel"
private const val TOR_CHANNEL_GROUP_ID = "TorGroup"
@Volatile private var appContext: Context? = null
@Volatile
private var torRuntime: TorRuntime? = null
@Volatile
private var portCollectorStarted = false
private val _isRunning = MutableStateFlow(false)
val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()
private val _socksPort = MutableStateFlow(0)
val socksPort: StateFlow<Int> = _socksPort.asStateFlow()
fun init(context: Context) {
if (appContext != null) return
appContext = context.applicationContext
createNotificationChannel()
}
private fun createNotificationChannel() {
val ctx = appContext ?: return
val group = NotificationChannelGroupCompat.Builder(TOR_CHANNEL_GROUP_ID)
.setName(ctx.getString(R.string.builtin_tor_title))
.setDescription(ctx.getString(R.string.tor_status_channel_description))
.build()
val channel = NotificationChannelCompat.Builder(TOR_CHANNEL_ID, NotificationManager.IMPORTANCE_LOW)
.setName(ctx.getString(R.string.builtin_tor_title))
.setDescription(ctx.getString(R.string.tor_status_channel_description))
.setSound(null, null)
.setGroup(group.id)
.build()
val nm = NotificationManagerCompat.from(ctx)
nm.createNotificationChannelGroup(group)
nm.createNotificationChannel(channel)
}
@SuppressLint("MissingPermission")
private fun showNotification(text: String) {
val ctx = appContext ?: return
if (ActivityCompat.checkSelfPermission(ctx, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return
val contentIntent = Intent(ctx, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
val pendingIntent = PendingIntent.getActivity(ctx, 0, contentIntent, PendingIntent.FLAG_MUTABLE)
val notification = NotificationCompat.Builder(ctx, TOR_CHANNEL_ID)
.setContentTitle(ctx.getString(R.string.builtin_tor_title))
.setContentText(text)
.setSmallIcon(R.drawable.ic_tor)
.setOngoing(true)
.setContentIntent(pendingIntent)
.build()
NotificationManagerCompat.from(ctx).notify(TOR_NOTIFICATION_ID, notification)
}
fun cancelNotification() {
appContext?.let { NotificationManagerCompat.from(it).cancel(TOR_NOTIFICATION_ID) }
}
fun showRetrying() {
appContext?.let { showNotification(it.getString(R.string.tor_retrying)) }
}
fun start(context: Context, scope: CoroutineScope) {
if (appContext == null) {
appContext = context.applicationContext
createNotificationChannel()
}
if (!portCollectorStarted) {
portCollectorStarted = true
scope.launch(Dispatchers.IO) {
_socksPort.collect { port ->
if (port > 0) {
HttpClientManager.setDefaultProxyOnPort(port)
}
}
}
}
if (torRuntime != null) return
showNotification(context.getString(R.string.tor_starting))
scope.launch(Dispatchers.IO) {
try {
val workDir = context.filesDir.resolve("kmptor")
val cacheDir = context.cacheDir.resolve("kmptor")
val env = TorRuntime.Environment.Builder(workDir, cacheDir, ResourceLoaderTorExec::getOrCreate)
val runtime = TorRuntime.Builder(env) {
// Log all runtime events for debugging
RuntimeEvent.entries().forEach { event ->
observerStatic(event, OnEvent.Executor.Immediate) { data ->
Log.d(TAG, data.toString())
}
}
// Observe SOCKS listeners to get the assigned port
observerStatic(RuntimeEvent.LISTENERS, OnEvent.Executor.Immediate) { listeners ->
val addr = listeners.socks.firstOrNull()
if (addr != null) {
try {
val port = addr.port.value
Log.i(TAG, "Built-in Tor SOCKS proxy on port $port")
_socksPort.value = port
_isRunning.value = true
appContext?.let { showNotification(it.getString(R.string.builtin_tor_active)) }
} catch (e: Exception) {
Log.e(TAG, "Failed to read Tor SOCKS port", e)
}
}
}
config {
TorOption.__SocksPort.configure { auto() }
}
}
torRuntime = runtime
runtime.startDaemonAsync()
Log.i(TAG, "Built-in Tor started")
} catch (e: Exception) {
Log.e(TAG, "Failed to start built-in Tor", e)
torRuntime = null
_isRunning.value = false
}
}
}
fun stop() {
val runtime = torRuntime ?: return
torRuntime = null
_isRunning.value = false
_socksPort.value = 0
cancelNotification()
try {
runBlocking(Dispatchers.IO) { runtime.stopDaemonAsync() }
Log.i(TAG, "Built-in Tor stopped")
} catch (e: Exception) {
Log.e(TAG, "Error stopping built-in Tor", e)
}
}
}