From 03b3788a69d3a3609c2f04db806fecc4e75899b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 22:33:08 +0000 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_015X6g3nwsWu6HbuwpA2ALu8 --- .github/workflows/benchmark-apk.yml | 54 ++++++ app/build.gradle.kts | 14 ++ app/src/benchmark/AndroidManifest.xml | 68 +++++++ .../nostrsigner/service/TorManager.kt | 179 ++++++++++++++++++ 4 files changed, 315 insertions(+) create mode 100644 .github/workflows/benchmark-apk.yml create mode 100644 app/src/benchmark/AndroidManifest.xml create mode 100644 app/src/benchmark/java/com/greenart7c3/nostrsigner/service/TorManager.kt diff --git a/.github/workflows/benchmark-apk.yml b/.github/workflows/benchmark-apk.yml new file mode 100644 index 00000000..0d179e2e --- /dev/null +++ b/.github/workflows/benchmark-apk.yml @@ -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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 89990486..17b1f028 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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 diff --git a/app/src/benchmark/AndroidManifest.xml b/app/src/benchmark/AndroidManifest.xml new file mode 100644 index 00000000..4d309e9e --- /dev/null +++ b/app/src/benchmark/AndroidManifest.xml @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/benchmark/java/com/greenart7c3/nostrsigner/service/TorManager.kt b/app/src/benchmark/java/com/greenart7c3/nostrsigner/service/TorManager.kt new file mode 100644 index 00000000..39b11fbd --- /dev/null +++ b/app/src/benchmark/java/com/greenart7c3/nostrsigner/service/TorManager.kt @@ -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 = _isRunning.asStateFlow() + + private val _socksPort = MutableStateFlow(0) + val socksPort: StateFlow = _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) + } + } +}