Merge pull request #494 from greenart7c3/claude/tor-notification-status-restart-b0pktj

Add restart action and live status to the built-in Tor notification
This commit is contained in:
greenart7c3
2026-07-06 07:37:35 -03:00
committed by GitHub
7 changed files with 284 additions and 16 deletions
@@ -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")
+4
View File
@@ -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()
}
+3
View File
@@ -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
}