diff --git a/app/src/benchmark/java/com/greenart7c3/nostrsigner/service/TorManager.kt b/app/src/benchmark/java/com/greenart7c3/nostrsigner/service/TorManager.kt index 39b11fbd..6a016bed 100644 --- a/app/src/benchmark/java/com/greenart7c3/nostrsigner/service/TorManager.kt +++ b/app/src/benchmark/java/com/greenart7c3/nostrsigner/service/TorManager.kt @@ -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 = _isRunning.asStateFlow() private val _socksPort = MutableStateFlow(0) val socksPort: StateFlow = _socksPort.asStateFlow() + private val _status = MutableStateFlow(TorStatus.Stopped) + val status: StateFlow = _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") diff --git a/app/src/free/java/com/greenart7c3/nostrsigner/service/TorManager.kt b/app/src/free/java/com/greenart7c3/nostrsigner/service/TorManager.kt index 8fde88a4..cc6437ce 100644 --- a/app/src/free/java/com/greenart7c3/nostrsigner/service/TorManager.kt +++ b/app/src/free/java/com/greenart7c3/nostrsigner/service/TorManager.kt @@ -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 = _isRunning.asStateFlow() private val _socksPort = MutableStateFlow(0) val socksPort: StateFlow = _socksPort.asStateFlow() + private val _status = MutableStateFlow(TorStatus.Stopped) + val status: StateFlow = _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") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a33d401e..9c3892ab 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -43,6 +43,10 @@ android:name=".service.StopServiceReceiver" android:enabled="true" android:exported="false" /> + Starting Tor\u2026 Retrying Tor connection\u2026 Built-in Tor status notifications + Connecting to Tor: %1$d%% + Tor connection failed + Restart KeyPackage KeyPackage Relays List Welcome diff --git a/app/src/offline/java/com/greenart7c3/nostrsigner/service/TorManager.kt b/app/src/offline/java/com/greenart7c3/nostrsigner/service/TorManager.kt index 86aaa999..dfe6ef65 100644 --- a/app/src/offline/java/com/greenart7c3/nostrsigner/service/TorManager.kt +++ b/app/src/offline/java/com/greenart7c3/nostrsigner/service/TorManager.kt @@ -13,6 +13,9 @@ object TorManager { private val _isRunning = MutableStateFlow(false) val isRunning: StateFlow = _isRunning.asStateFlow() + private val _status = MutableStateFlow(TorStatus.Stopped) + val status: StateFlow = _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 }