Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ Wisp implements a full NIP-65 outbox/inbox model with relay scoring:
- **Persistent + ephemeral pool** — `RelayPool` maintains up to 30 persistent connections plus up to 50 short-lived ephemeral ones, with idle cleanup and per-relay cooldowns after failures
- **NIP-42 authentication** — signs AUTH challenges only for relays the user has explicitly approved, with persisted approvals, and waits for AUTH before sending sensitive publishes (DMs, group events)
- **NIP-11 relay info** — fetches and respects relay metadata, capabilities, and limitations
- **Tor support** — route relay traffic through an embedded Tor client for enhanced privacy

### Privacy & Private Messaging

Expand Down Expand Up @@ -164,7 +163,7 @@ Wisp follows an MVVM architecture with clear layer separation:
│ Relay Layer │
│ RelayPool, OutboxRouter, RelayScoreBoard, │
│ SubscriptionManager, RelayHealthTracker, │
TorManager, Relay (OkHttp WebSocket) │
│ Relay (OkHttp WebSocket)
└──────────────────────────────────────────────────────┘
```

Expand All @@ -182,7 +181,7 @@ Wisp follows an MVVM architecture with clear layer separation:
```
app/src/main/kotlin/com/wisp/app/
├── nostr/ # Protocol implementations (NipXX.kt objects)
├── relay/ # WebSocket relay, pool, outbox router, scoring, Tor
├── relay/ # WebSocket relay, pool, outbox router, scoring
├── repo/ # Data repositories, caches, and persistence wrappers
├── db/ # ObjectBox entities (EventEntity, GroupMessageEntity...)
├── ml/ # On-device nspam LightGBM classifier
Expand Down Expand Up @@ -342,7 +341,6 @@ Contributions are welcome. Wisp is open source and community help makes it bette
| Lightning | Breez SDK Spark + NWC (NIP-47) |
| Media | Media3 / ExoPlayer |
| QR Codes | ZXing |
| Privacy Network | Embedded Tor (optional) |
| Build | Gradle 8.x / AGP 8.x |
| Min SDK | Android 8.0 (API 26) |
| Target SDK | Android 15 (API 35) |
Expand Down
2 changes: 0 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,6 @@ dependencies {
implementation(libs.splashscreen)
implementation(libs.profileinstaller)
implementation(libs.zxing.core)
implementation(libs.kmp.tor.runtime)
implementation(libs.kmp.tor.resource.exec)
implementation(libs.mlkit.translate)
implementation(libs.mlkit.language.id)
implementation(libs.kotlinx.coroutines.play.services)
Expand Down
8 changes: 0 additions & 8 deletions app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,6 @@
-keep class androidx.camera.** { *; }
-dontwarn androidx.camera.**

# kmp-tor
-keep class io.matthewnelson.kmp.tor.** { *; }
-dontwarn io.matthewnelson.kmp.tor.**

# ML Kit
-keep class com.google.mlkit.** { *; }
-dontwarn com.google.mlkit.**
Expand All @@ -66,7 +62,3 @@
# JNA (used by Breez SDK UniFFI)
-keep class com.sun.jna.** { *; }
-dontwarn com.sun.jna.**

# java.lang.management (not available on Android)
-dontwarn java.lang.management.ManagementFactory
-dontwarn java.lang.management.RuntimeMXBean
50 changes: 0 additions & 50 deletions app/src/main/kotlin/com/wisp/app/Navigation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -314,47 +314,6 @@ fun WispNavHost(
}
}

// Tor state
val torPrefs = remember { context.getSharedPreferences("wisp_settings", android.content.Context.MODE_PRIVATE) }
val torStatus by com.wisp.app.relay.TorManager.status.collectAsState()
val isTorEnabled = torStatus != com.wisp.app.relay.TorStatus.DISABLED

// Auto-start Tor if previously enabled
val torScope = androidx.compose.runtime.rememberCoroutineScope()
LaunchedEffect(Unit) {
if (torPrefs.getBoolean("tor_enabled", false)) {
com.wisp.app.relay.TorManager.start()
}
}
// When Tor finishes connecting/disconnecting, swap the relay pool client.
// Skip the initial DISABLED state on first composition — only react to changes.
var torStatusInitialized by remember { mutableStateOf(false) }
LaunchedEffect(torStatus) {
if (!torStatusInitialized) {
torStatusInitialized = true
return@LaunchedEffect
}
if (torStatus == com.wisp.app.relay.TorStatus.CONNECTED ||
torStatus == com.wisp.app.relay.TorStatus.DISABLED) {
if (authViewModel.isLoggedIn) {
feedViewModel.lifecycleManager.onTorSwitch(
savedConfigs = feedViewModel.keyRepo.getRelays(),
savedDmUrls = feedViewModel.keyRepo.getDmRelays()
)
}
}
}
val onToggleTor: (Boolean) -> Unit = { enabled ->
torPrefs.edit().putBoolean("tor_enabled", enabled).apply()
torScope.launch {
if (enabled) {
com.wisp.app.relay.TorManager.start()
} else {
com.wisp.app.relay.TorManager.stop()
}
}
}

val startDestination = rememberSaveable {
when {
!authViewModel.isLoggedIn -> Routes.SPLASH
Expand Down Expand Up @@ -674,9 +633,6 @@ fun WispNavHost(
composable(Routes.SPLASH) {
SplashScreen(
viewModel = splashViewModel,
isTorEnabled = isTorEnabled,
torStatus = torStatus,
onToggleTor = onToggleTor,
onSignUp = {
if (authViewModel.signUp()) {
navController.navigate(Routes.ONBOARDING_PROFILE) {
Expand All @@ -693,9 +649,6 @@ fun WispNavHost(
composable(Routes.AUTH) {
AuthScreen(
viewModel = authViewModel,
isTorEnabled = isTorEnabled,
torStatus = torStatus,
onToggleTor = onToggleTor,
showSignUp = false,
onAuthenticated = { isNewAccount ->
val wasAddingAccount = authViewModel.isAddingAccount
Expand Down Expand Up @@ -784,9 +737,6 @@ fun WispNavHost(
viewModel = feedViewModel,
isDarkTheme = isDarkTheme,
onToggleTheme = onToggleTheme,
isTorEnabled = isTorEnabled,
torStatus = torStatus,
onToggleTor = onToggleTor,
scrollToTopTrigger = scrollToTopTrigger,
onCompose = {
replyTarget = null
Expand Down
8 changes: 1 addition & 7 deletions app/src/main/kotlin/com/wisp/app/WispApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,9 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.crossfade
import com.wisp.app.db.WispObjectBox
import com.wisp.app.relay.HttpClientFactory
import com.wisp.app.relay.TorManager
import com.wisp.app.repo.DiagnosticLogger
import com.wisp.app.repo.ExchangeRateRepository
import com.wisp.app.repo.ZapSender
import okhttp3.Call

class WispApp : Application(), SingletonImageLoader.Factory {

Expand All @@ -23,20 +21,16 @@ class WispApp : Application(), SingletonImageLoader.Factory {
CrashHandler.install(this)
DiagnosticLogger.init(this)
WispObjectBox.init(this)
TorManager.initialize(this)
ZapSender.init(this)
ExchangeRateRepository.init(this)
}

override fun newImageLoader(context: android.content.Context): ImageLoader {
val torAwareCallFactory = Call.Factory { request ->
HttpClientFactory.getImageClient().newCall(request)
}
return ImageLoader.Builder(context)
.components {
add(AnimatedImageDecoder.Factory())
add(VideoFrameDecoder.Factory())
add(OkHttpNetworkFetcherFactory(callFactory = { torAwareCallFactory }))
add(OkHttpNetworkFetcherFactory(callFactory = { HttpClientFactory.getImageClient() }))
}
.memoryCache {
MemoryCache.Builder()
Expand Down
8 changes: 1 addition & 7 deletions app/src/main/kotlin/com/wisp/app/nostr/Nip65.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.wisp.app.nostr

import android.util.Log
import com.wisp.app.relay.RelayConfig

object Nip65 {
Expand All @@ -17,18 +16,13 @@ object Nip65 {
write = marker == null || marker == "write"
)
}
val deduped = result.groupBy { it.url }.map { (url, configs) ->
return result.groupBy { it.url }.map { (url, configs) ->
RelayConfig(
url = url,
read = configs.any { it.read },
write = configs.any { it.write }
)
}
val onionRelays = deduped.filter { RelayConfig.isOnionUrl(it.url) }
if (onionRelays.isNotEmpty()) {
Log.d("TorRelay", "[Nip65] parseRelayList: pubkey=${event.pubkey.take(8)}… has ${onionRelays.size} .onion relay(s): ${onionRelays.map { it.url }}")
}
return deduped
}

fun buildRelayTags(relays: List<RelayConfig>): List<List<String>> {
Expand Down
55 changes: 8 additions & 47 deletions app/src/main/kotlin/com/wisp/app/relay/HttpClientFactory.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,31 +5,12 @@ import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import okhttp3.Dispatcher
import okhttp3.Dns
import okhttp3.OkHttpClient
import java.net.InetAddress
import java.util.concurrent.TimeUnit

object HttpClientFactory {

private val TOR_TIMEOUT_MULTIPLIER = 3L

/**
* DNS resolver that forces all hostname resolution through the SOCKS5 proxy.
* When Tor is active, returns an unresolved InetAddress so OkHttp sends the
* hostname through the SOCKS tunnel and the Tor exit node resolves DNS.
* This prevents DNS leaks.
*/
private val torSafeDns = object : Dns {
override fun lookup(hostname: String): List<InetAddress> {
return listOf(InetAddress.getByAddress(hostname, byteArrayOf(0, 0, 0, 0)))
}
}

fun createRelayClient(): OkHttpClient {
val isTor = TorManager.isEnabled()
val connectTimeout = if (isTor) 30L else 10L

// OkHttp's default Dispatcher.maxRequests is 64, which caps concurrent
// WebSocket upgrade requests. With outbox routing creating 50+ ephemeral
// connections, new user-initiated connections get queued and time out.
Expand All @@ -38,9 +19,9 @@ object HttpClientFactory {
maxRequestsPerHost = 10
}

val builder = OkHttpClient.Builder()
return OkHttpClient.Builder()
.dispatcher(dispatcher)
.connectTimeout(connectTimeout, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.pingInterval(30, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS)
// Strip permessage-deflate from the REQUEST so the server never negotiates
Expand All @@ -54,29 +35,17 @@ object HttpClientFactory {
.build()
chain.proceed(request)
}

if (isTor) {
TorManager.proxy?.let { builder.proxy(it) }
builder.dns(torSafeDns)
}

return builder.build()
.build()
}

private var imageClient: OkHttpClient? = null
private var imageClientBuiltWithTor: Boolean = false

fun getImageClient(): OkHttpClient {
val torNow = TorManager.isEnabled()
val client = imageClient
if (client != null && imageClientBuiltWithTor == torNow) return client
imageClient?.let { return it }
return createHttpClient(
connectTimeoutSeconds = 10,
readTimeoutSeconds = 30
).also {
imageClient = it
imageClientBuiltWithTor = torNow
}
).also { imageClient = it }
}

fun createExoPlayer(context: Context): ExoPlayer {
Expand Down Expand Up @@ -106,21 +75,13 @@ object HttpClientFactory {
writeTimeoutSeconds: Long = 0,
followRedirects: Boolean = true
): OkHttpClient {
val isTor = TorManager.isEnabled()
val multiplier = if (isTor) TOR_TIMEOUT_MULTIPLIER else 1L

val builder = OkHttpClient.Builder()
.connectTimeout(connectTimeoutSeconds * multiplier, TimeUnit.SECONDS)
.readTimeout(readTimeoutSeconds * multiplier, TimeUnit.SECONDS)
.connectTimeout(connectTimeoutSeconds, TimeUnit.SECONDS)
.readTimeout(readTimeoutSeconds, TimeUnit.SECONDS)
.followRedirects(followRedirects)

if (writeTimeoutSeconds > 0) {
builder.writeTimeout(writeTimeoutSeconds * multiplier, TimeUnit.SECONDS)
}

if (isTor) {
TorManager.proxy?.let { builder.proxy(it) }
builder.dns(torSafeDns)
builder.writeTimeout(writeTimeoutSeconds, TimeUnit.SECONDS)
}

return builder.build()
Expand Down
9 changes: 0 additions & 9 deletions app/src/main/kotlin/com/wisp/app/relay/Relay.kt
Original file line number Diff line number Diff line change
Expand Up @@ -132,15 +132,9 @@ class Relay(
}
val socketId = System.nanoTime()
Log.d("RLC", "[Relay] connect() creating ws#$socketId for ${config.url}")
if (config.url.contains(".onion")) {
Log.d("TorRelay", "[Relay] connect() .onion relay: ${config.url} proxy=${client.proxy} connectTimeout=${client.connectTimeoutMillis}ms")
}
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.d("RLC", "[Relay] ws#$socketId onOpen ${config.url} | isConnected was=$isConnected")
if (config.url.contains(".onion")) {
Log.d("TorRelay", "[Relay] .onion connection SUCCESS: ${config.url}")
}
isConnected = true
// Successful connection — reset attempt tracking
synchronized(attemptLock) { connectAttempts.clear() }
Expand All @@ -164,9 +158,6 @@ class Relay(
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
val isCurrent = synchronized(connectLock) { this@Relay.webSocket === webSocket }
Log.e("RLC", "[Relay] ws#$socketId onFailure ${config.url}: ${t.javaClass.simpleName}: ${t.message} | httpCode=${response?.code} | isCurrent=$isCurrent isConnected=$isConnected")
if (config.url.contains(".onion")) {
Log.e("TorRelay", "[Relay] .onion connection FAILED: ${config.url} | error=${t.javaClass.simpleName}: ${t.message}", t)
}
synchronized(connectLock) {
if (this@Relay.webSocket === webSocket) {
isConnected = false
Expand Down
21 changes: 1 addition & 20 deletions app/src/main/kotlin/com/wisp/app/relay/RelayConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -52,20 +52,11 @@ data class RelayConfig(

private val IP_HOST_REGEX = Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$")

fun isOnionUrl(url: String): Boolean = url.contains(".onion")

/**
* Structural URL validation — can this URL be stored in a relay list?
* Always allows .onion addresses (with ws:// or wss://) regardless of Tor state.
* Rejects: localhost, IP addresses, URLs with ports (unless .onion).
* Rejects: non-wss schemes, localhost, IP addresses, URLs with ports.
*/
fun isValidUrl(url: String): Boolean {
if (isOnionUrl(url)) {
// .onion relays can use ws:// (TLS redundant over Tor) or wss://
if (!url.startsWith("wss://") && !url.startsWith("ws://")) return false
return true
}

if (!url.startsWith("wss://")) return false
val afterScheme = url.removePrefix("wss://")
val hostPort = afterScheme.split("/", limit = 2)[0]
Expand All @@ -76,16 +67,6 @@ data class RelayConfig(
return true
}

/**
* Returns true if the relay URL can be connected to right now.
* .onion addresses require Tor to be active.
*/
fun isConnectableUrl(url: String): Boolean {
if (!isValidUrl(url)) return false
if (isOnionUrl(url) && !TorManager.isEnabled()) return false
return true
}

private val LOCAL_HOST_REGEX = Regex(
"^ws://(" +
"localhost|" +
Expand Down
18 changes: 0 additions & 18 deletions app/src/main/kotlin/com/wisp/app/relay/RelayLifecycleManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -170,24 +170,6 @@ class RelayLifecycleManager(
}
}

/**
* Handle Tor on/off switch. Swaps the OkHttpClient, reconnects all relays,
* and re-establishes subscriptions via [onReconnected].
* Pass the full saved relay list so .onion relays are included when Tor turns on.
*/
fun onTorSwitch(
savedConfigs: List<RelayConfig>? = null,
savedDmUrls: List<String>? = null
) {
reconnectJob?.cancel()
reconnectJob = scope.launch {
relayPool.swapClientAndReconnect(savedConfigs, savedDmUrls)
relayPool.awaitAnyConnected(minCount = 3, timeoutMs = 10_000)
relayPool.appIsActive = true
onReconnected(true)
}
}

/**
* Stop observing. Call on account switch or cleanup.
*/
Expand Down
Loading