mirror of
https://github.com/greenart7c3/Amber.git
synced 2026-10-05 19:08:23 +00:00
Add restart action and live status to the built-in Tor notification
The built-in Tor notification now reflects the real daemon state instead of only 'starting' and 'active': it shows bootstrap progress with a percentage and progress bar while connecting, 'connected' once the daemon is bootstrapped and the SOCKS port is up, and a 'failed' state (with the error message) when the daemon dies or fails to start. A new Restart notification action stops and restarts the Tor runtime via TorRestartReceiver. TorManager now exposes a TorStatus StateFlow (Stopped, Connecting, Connected, Failed) across all flavors, driven by kmp-tor RuntimeEvent STATE (bootstrap percentage), ERROR, and LISTENERS observers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KaBjcQPBoUMPu4QHbXJc4e
This commit is contained in:
@@ -21,6 +21,7 @@ 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.TorState
|
||||
import io.matthewnelson.kmp.tor.runtime.core.OnEvent
|
||||
import io.matthewnelson.kmp.tor.runtime.core.config.TorOption
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -36,6 +37,7 @@ object TorManager {
|
||||
private const val TOR_NOTIFICATION_ID = 3
|
||||
private const val TOR_CHANNEL_ID = "TorChannel"
|
||||
private const val TOR_CHANNEL_GROUP_ID = "TorGroup"
|
||||
private const val TOR_RESTART_REQUEST_CODE = 15
|
||||
|
||||
@Volatile private var appContext: Context? = null
|
||||
|
||||
@@ -45,12 +47,27 @@ object TorManager {
|
||||
@Volatile
|
||||
private var portCollectorStarted = false
|
||||
|
||||
@Volatile
|
||||
private var stopRequested = false
|
||||
|
||||
@Volatile
|
||||
private var restarting = false
|
||||
|
||||
@Volatile
|
||||
private var daemonStarted = false
|
||||
|
||||
@Volatile
|
||||
private var bootstrapped = false
|
||||
|
||||
private val _isRunning = MutableStateFlow(false)
|
||||
val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()
|
||||
|
||||
private val _socksPort = MutableStateFlow(0)
|
||||
val socksPort: StateFlow<Int> = _socksPort.asStateFlow()
|
||||
|
||||
private val _status = MutableStateFlow<TorStatus>(TorStatus.Stopped)
|
||||
val status: StateFlow<TorStatus> = _status.asStateFlow()
|
||||
|
||||
fun init(context: Context) {
|
||||
if (appContext != null) return
|
||||
appContext = context.applicationContext
|
||||
@@ -74,21 +91,58 @@ object TorManager {
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun updateStatus(status: TorStatus) {
|
||||
if (_status.value == status) return
|
||||
_status.value = status
|
||||
val ctx = appContext ?: return
|
||||
when (status) {
|
||||
is TorStatus.Stopped -> cancelNotification()
|
||||
is TorStatus.Connecting -> {
|
||||
val text = if (status.percentage <= 0) {
|
||||
ctx.getString(R.string.tor_starting)
|
||||
} else {
|
||||
ctx.getString(R.string.tor_connecting, status.percentage)
|
||||
}
|
||||
showNotification(text, progress = status.percentage)
|
||||
}
|
||||
is TorStatus.Connected -> showNotification(ctx.getString(R.string.builtin_tor_active))
|
||||
is TorStatus.Failed -> {
|
||||
val text = if (status.message.isNullOrBlank()) {
|
||||
ctx.getString(R.string.tor_connection_failed)
|
||||
} else {
|
||||
"${ctx.getString(R.string.tor_connection_failed)}: ${status.message}"
|
||||
}
|
||||
showNotification(text, ongoing = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun showNotification(text: String) {
|
||||
private fun showNotification(text: String, progress: Int? = null, ongoing: Boolean = true) {
|
||||
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)
|
||||
val restartIntent = Intent(ctx, TorRestartReceiver::class.java)
|
||||
val restartPendingIntent = PendingIntent.getBroadcast(
|
||||
ctx,
|
||||
TOR_RESTART_REQUEST_CODE,
|
||||
restartIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val builder = NotificationCompat.Builder(ctx, TOR_CHANNEL_ID)
|
||||
.setContentTitle(ctx.getString(R.string.builtin_tor_title))
|
||||
.setContentText(text)
|
||||
.setSmallIcon(R.drawable.ic_tor)
|
||||
.setOngoing(true)
|
||||
.setOngoing(ongoing)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.build()
|
||||
NotificationManagerCompat.from(ctx).notify(TOR_NOTIFICATION_ID, notification)
|
||||
.addAction(0, ctx.getString(R.string.tor_restart), restartPendingIntent)
|
||||
if (progress != null) {
|
||||
builder.setProgress(100, progress, progress <= 0)
|
||||
}
|
||||
NotificationManagerCompat.from(ctx).notify(TOR_NOTIFICATION_ID, builder.build())
|
||||
}
|
||||
|
||||
fun cancelNotification() {
|
||||
@@ -115,7 +169,10 @@ object TorManager {
|
||||
}
|
||||
}
|
||||
if (torRuntime != null) return
|
||||
showNotification(context.getString(R.string.tor_starting))
|
||||
stopRequested = false
|
||||
daemonStarted = false
|
||||
bootstrapped = false
|
||||
updateStatus(TorStatus.Connecting(0))
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val workDir = context.filesDir.resolve("kmptor")
|
||||
@@ -131,6 +188,42 @@ object TorManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Observe the daemon state to report bootstrap progress
|
||||
observerStatic(RuntimeEvent.STATE, OnEvent.Executor.Immediate) { state ->
|
||||
when (val daemon = state.daemon) {
|
||||
is TorState.Daemon.Starting -> {
|
||||
daemonStarted = true
|
||||
updateStatus(TorStatus.Connecting(0))
|
||||
}
|
||||
is TorState.Daemon.On -> {
|
||||
daemonStarted = true
|
||||
bootstrapped = daemon.isBootstrapped
|
||||
if (daemon.isBootstrapped && _socksPort.value > 0) {
|
||||
updateStatus(TorStatus.Connected)
|
||||
} else {
|
||||
updateStatus(TorStatus.Connecting(daemon.bootstrap.toInt()))
|
||||
}
|
||||
}
|
||||
is TorState.Daemon.Stopping -> {}
|
||||
is TorState.Daemon.Off -> {
|
||||
if (daemonStarted && !stopRequested) {
|
||||
Log.e(TAG, "Built-in Tor daemon stopped unexpectedly")
|
||||
bootstrapped = false
|
||||
_isRunning.value = false
|
||||
updateStatus(TorStatus.Failed(null))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Surface runtime errors in the status notification
|
||||
observerStatic(RuntimeEvent.ERROR, OnEvent.Executor.Immediate) { error ->
|
||||
Log.e(TAG, "Built-in Tor runtime error", error)
|
||||
if (_status.value !is TorStatus.Connected) {
|
||||
updateStatus(TorStatus.Failed(error.message))
|
||||
}
|
||||
}
|
||||
|
||||
// Observe SOCKS listeners to get the assigned port
|
||||
observerStatic(RuntimeEvent.LISTENERS, OnEvent.Executor.Immediate) { listeners ->
|
||||
val addr = listeners.socks.firstOrNull()
|
||||
@@ -140,7 +233,9 @@ object TorManager {
|
||||
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)) }
|
||||
if (bootstrapped) {
|
||||
updateStatus(TorStatus.Connected)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to read Tor SOCKS port", e)
|
||||
}
|
||||
@@ -159,16 +254,35 @@ object TorManager {
|
||||
Log.e(TAG, "Failed to start built-in Tor", e)
|
||||
torRuntime = null
|
||||
_isRunning.value = false
|
||||
updateStatus(TorStatus.Failed(e.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun restart(context: Context, scope: CoroutineScope) {
|
||||
if (restarting) return
|
||||
restarting = true
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
Log.i(TAG, "Restarting built-in Tor")
|
||||
stop()
|
||||
start(context, scope)
|
||||
} finally {
|
||||
restarting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
val runtime = torRuntime ?: return
|
||||
stopRequested = true
|
||||
bootstrapped = false
|
||||
val runtime = torRuntime
|
||||
torRuntime = null
|
||||
_isRunning.value = false
|
||||
_socksPort.value = 0
|
||||
updateStatus(TorStatus.Stopped)
|
||||
cancelNotification()
|
||||
if (runtime == null) return
|
||||
try {
|
||||
runBlocking(Dispatchers.IO) { runtime.stopDaemonAsync() }
|
||||
Log.i(TAG, "Built-in Tor stopped")
|
||||
|
||||
@@ -21,6 +21,7 @@ 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.TorState
|
||||
import io.matthewnelson.kmp.tor.runtime.core.OnEvent
|
||||
import io.matthewnelson.kmp.tor.runtime.core.config.TorOption
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -36,6 +37,7 @@ object TorManager {
|
||||
private const val TOR_NOTIFICATION_ID = 3
|
||||
private const val TOR_CHANNEL_ID = "TorChannel"
|
||||
private const val TOR_CHANNEL_GROUP_ID = "TorGroup"
|
||||
private const val TOR_RESTART_REQUEST_CODE = 15
|
||||
|
||||
@Volatile private var appContext: Context? = null
|
||||
|
||||
@@ -45,12 +47,27 @@ object TorManager {
|
||||
@Volatile
|
||||
private var portCollectorStarted = false
|
||||
|
||||
@Volatile
|
||||
private var stopRequested = false
|
||||
|
||||
@Volatile
|
||||
private var restarting = false
|
||||
|
||||
@Volatile
|
||||
private var daemonStarted = false
|
||||
|
||||
@Volatile
|
||||
private var bootstrapped = false
|
||||
|
||||
private val _isRunning = MutableStateFlow(false)
|
||||
val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()
|
||||
|
||||
private val _socksPort = MutableStateFlow(0)
|
||||
val socksPort: StateFlow<Int> = _socksPort.asStateFlow()
|
||||
|
||||
private val _status = MutableStateFlow<TorStatus>(TorStatus.Stopped)
|
||||
val status: StateFlow<TorStatus> = _status.asStateFlow()
|
||||
|
||||
fun init(context: Context) {
|
||||
if (appContext != null) return
|
||||
appContext = context.applicationContext
|
||||
@@ -74,21 +91,58 @@ object TorManager {
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun updateStatus(status: TorStatus) {
|
||||
if (_status.value == status) return
|
||||
_status.value = status
|
||||
val ctx = appContext ?: return
|
||||
when (status) {
|
||||
is TorStatus.Stopped -> cancelNotification()
|
||||
is TorStatus.Connecting -> {
|
||||
val text = if (status.percentage <= 0) {
|
||||
ctx.getString(R.string.tor_starting)
|
||||
} else {
|
||||
ctx.getString(R.string.tor_connecting, status.percentage)
|
||||
}
|
||||
showNotification(text, progress = status.percentage)
|
||||
}
|
||||
is TorStatus.Connected -> showNotification(ctx.getString(R.string.builtin_tor_active))
|
||||
is TorStatus.Failed -> {
|
||||
val text = if (status.message.isNullOrBlank()) {
|
||||
ctx.getString(R.string.tor_connection_failed)
|
||||
} else {
|
||||
"${ctx.getString(R.string.tor_connection_failed)}: ${status.message}"
|
||||
}
|
||||
showNotification(text, ongoing = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun showNotification(text: String) {
|
||||
private fun showNotification(text: String, progress: Int? = null, ongoing: Boolean = true) {
|
||||
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)
|
||||
val restartIntent = Intent(ctx, TorRestartReceiver::class.java)
|
||||
val restartPendingIntent = PendingIntent.getBroadcast(
|
||||
ctx,
|
||||
TOR_RESTART_REQUEST_CODE,
|
||||
restartIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val builder = NotificationCompat.Builder(ctx, TOR_CHANNEL_ID)
|
||||
.setContentTitle(ctx.getString(R.string.builtin_tor_title))
|
||||
.setContentText(text)
|
||||
.setSmallIcon(R.drawable.ic_tor)
|
||||
.setOngoing(true)
|
||||
.setOngoing(ongoing)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setContentIntent(pendingIntent)
|
||||
.build()
|
||||
NotificationManagerCompat.from(ctx).notify(TOR_NOTIFICATION_ID, notification)
|
||||
.addAction(0, ctx.getString(R.string.tor_restart), restartPendingIntent)
|
||||
if (progress != null) {
|
||||
builder.setProgress(100, progress, progress <= 0)
|
||||
}
|
||||
NotificationManagerCompat.from(ctx).notify(TOR_NOTIFICATION_ID, builder.build())
|
||||
}
|
||||
|
||||
fun cancelNotification() {
|
||||
@@ -115,7 +169,10 @@ object TorManager {
|
||||
}
|
||||
}
|
||||
if (torRuntime != null) return
|
||||
showNotification(context.getString(R.string.tor_starting))
|
||||
stopRequested = false
|
||||
daemonStarted = false
|
||||
bootstrapped = false
|
||||
updateStatus(TorStatus.Connecting(0))
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val workDir = context.filesDir.resolve("kmptor")
|
||||
@@ -131,6 +188,42 @@ object TorManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Observe the daemon state to report bootstrap progress
|
||||
observerStatic(RuntimeEvent.STATE, OnEvent.Executor.Immediate) { state ->
|
||||
when (val daemon = state.daemon) {
|
||||
is TorState.Daemon.Starting -> {
|
||||
daemonStarted = true
|
||||
updateStatus(TorStatus.Connecting(0))
|
||||
}
|
||||
is TorState.Daemon.On -> {
|
||||
daemonStarted = true
|
||||
bootstrapped = daemon.isBootstrapped
|
||||
if (daemon.isBootstrapped && _socksPort.value > 0) {
|
||||
updateStatus(TorStatus.Connected)
|
||||
} else {
|
||||
updateStatus(TorStatus.Connecting(daemon.bootstrap.toInt()))
|
||||
}
|
||||
}
|
||||
is TorState.Daemon.Stopping -> {}
|
||||
is TorState.Daemon.Off -> {
|
||||
if (daemonStarted && !stopRequested) {
|
||||
AmberLog.e(TAG, "Built-in Tor daemon stopped unexpectedly")
|
||||
bootstrapped = false
|
||||
_isRunning.value = false
|
||||
updateStatus(TorStatus.Failed(null))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Surface runtime errors in the status notification
|
||||
observerStatic(RuntimeEvent.ERROR, OnEvent.Executor.Immediate) { error ->
|
||||
AmberLog.e(TAG, "Built-in Tor runtime error", error)
|
||||
if (_status.value !is TorStatus.Connected) {
|
||||
updateStatus(TorStatus.Failed(error.message))
|
||||
}
|
||||
}
|
||||
|
||||
// Observe SOCKS listeners to get the assigned port
|
||||
observerStatic(RuntimeEvent.LISTENERS, OnEvent.Executor.Immediate) { listeners ->
|
||||
val addr = listeners.socks.firstOrNull()
|
||||
@@ -140,7 +233,9 @@ object TorManager {
|
||||
AmberLog.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)) }
|
||||
if (bootstrapped) {
|
||||
updateStatus(TorStatus.Connected)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
AmberLog.e(TAG, "Failed to read Tor SOCKS port", e)
|
||||
}
|
||||
@@ -159,16 +254,35 @@ object TorManager {
|
||||
AmberLog.e(TAG, "Failed to start built-in Tor", e)
|
||||
torRuntime = null
|
||||
_isRunning.value = false
|
||||
updateStatus(TorStatus.Failed(e.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun restart(context: Context, scope: CoroutineScope) {
|
||||
if (restarting) return
|
||||
restarting = true
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
AmberLog.i(TAG, "Restarting built-in Tor")
|
||||
stop()
|
||||
start(context, scope)
|
||||
} finally {
|
||||
restarting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
val runtime = torRuntime ?: return
|
||||
stopRequested = true
|
||||
bootstrapped = false
|
||||
val runtime = torRuntime
|
||||
torRuntime = null
|
||||
_isRunning.value = false
|
||||
_socksPort.value = 0
|
||||
updateStatus(TorStatus.Stopped)
|
||||
cancelNotification()
|
||||
if (runtime == null) return
|
||||
try {
|
||||
runBlocking(Dispatchers.IO) { runtime.stopDaemonAsync() }
|
||||
AmberLog.i(TAG, "Built-in Tor stopped")
|
||||
|
||||
@@ -43,6 +43,10 @@
|
||||
android:name=".service.StopServiceReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
<receiver
|
||||
android:name=".service.TorRestartReceiver"
|
||||
android:enabled="true"
|
||||
android:exported="false" />
|
||||
<receiver
|
||||
android:name=".service.BootReceiver"
|
||||
android:enabled="true"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.greenart7c3.nostrsigner.service
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.greenart7c3.nostrsigner.Amber
|
||||
import com.greenart7c3.nostrsigner.models.TorMode
|
||||
|
||||
class TorRestartReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (Amber.instance.settings.torMode != TorMode.BUILTIN) return
|
||||
TorManager.restart(context.applicationContext, Amber.instance.applicationIOScope)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.greenart7c3.nostrsigner.service
|
||||
|
||||
sealed class TorStatus {
|
||||
data object Stopped : TorStatus()
|
||||
|
||||
data class Connecting(val percentage: Int) : TorStatus()
|
||||
|
||||
data object Connected : TorStatus()
|
||||
|
||||
data class Failed(val message: String?) : TorStatus()
|
||||
}
|
||||
@@ -581,6 +581,9 @@
|
||||
<string name="tor_starting">Starting Tor\u2026</string>
|
||||
<string name="tor_retrying">Retrying Tor connection\u2026</string>
|
||||
<string name="tor_status_channel_description">Built-in Tor status notifications</string>
|
||||
<string name="tor_connecting">Connecting to Tor: %1$d%%</string>
|
||||
<string name="tor_connection_failed">Tor connection failed</string>
|
||||
<string name="tor_restart">Restart</string>
|
||||
<string name="event_kind_443">KeyPackage</string>
|
||||
<string name="event_kind_10051">KeyPackage Relays List</string>
|
||||
<string name="event_kind_444">Welcome</string>
|
||||
|
||||
@@ -13,6 +13,9 @@ object TorManager {
|
||||
private val _isRunning = MutableStateFlow(false)
|
||||
val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()
|
||||
|
||||
private val _status = MutableStateFlow<TorStatus>(TorStatus.Stopped)
|
||||
val status: StateFlow<TorStatus> = _status.asStateFlow()
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun init(context: Context) {
|
||||
// No-op in offline flavor
|
||||
@@ -23,6 +26,11 @@ object TorManager {
|
||||
// No-op in offline flavor
|
||||
}
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun restart(context: Context, scope: CoroutineScope) {
|
||||
// No-op in offline flavor
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
// No-op in offline flavor
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user