#1825

Four differences found between Nostr policies in IOS and Android

Openwvmscsopened 3d ago

Aggravating factors (from #1821)

  • Distance ties: with identical coordinates, iOS and Android break the tie for the 5th slot differently. The selections can therefore differ even with the same list.

  • Relays requiring NIP-42: present in the list, but unusable by the apps (neither app authenticates).

  • No replacement: an app does not replace a dead relay with the next one. Each dead relay directly reduces the number of relays the channel actually has.

  • Android counts some relays twice (its list entries are not normalised). On the first three points, Android follows the same policy as iOS, or an even more fragile one. While checking them, I also found a fourth problem, specific to Android and significant.

Findings

I read the Android code at commit eed88190c0 (2026-10-26): RelayDirectory.kt, NostrRelayManager.kt, NostrRequest.kt and RelayReconnectPolicy.kt.

1. Distance ties: nothing breaks them

closestRelaysForGeohash sorts relays by distance (sortedBy { haversineMeters(...) }), then takes the first 5 (take(5)). Kotlin's sort is stable: on a tie, the order of the lines in the CSV decides. There is no tie-breaker. iOS breaks ties by host name. With the same data, the two apps can therefore keep different relays.

2. Relays requiring NIP-42: Android doesn't authenticate either

The relay response parser (NostrResponse.fromJsonArray) only recognises EVENT, EOSE, OK and NOTICE. AUTH and CLOSED messages are classified as Unknown and ignored. In practice:

  • the authentication challenge never gets an answer;
  • a subscription closed with auth-required: goes unnoticed. The app believes it is subscribed while nothing arrives;
  • a rejected event (OK false) is only logged, with no retry.

This is the same situation as on iOS: these relays take one of the 5 slots without serving any purpose.

3. Dead relays: never replaced, and retried forever

No replacement. The selection stays the 5 closest, whatever their state. Android only falls back to the default relays when the selection is empty (no relays in the list at all), not when the selected relays are down.

Endless retries, by explicit choice. RelayReconnectPolicy waits longer and longer between attempts (1 s, then 2 s, 4 s…) up to a maximum of 5 minutes, and never gives up. Even a DNS error is retried. The code comment justifies this choice: a phone often loses its network (tunnel, airplane mode), and giving up would cut off the channels until the app is restarted.

Consequence: a relay with an invalid certificate keeps its slot forever and costs one connection attempt every 5 minutes. Apps do not tell permanent failures from transient ones.

4. In addition: Android counts some relays twice

normalizeRelayUrl only adds wss:// in front of the host. It does not lowercase the URL, does not strip :443, and does not remove duplicates. Yet the upstream list contains 49 relays listed twice, as host and host:443, with the same coordinates. Since they are tied, they sort next to each other and take two of the 5 slots for the same server.

I simulated Android's exact selection on every precision-2 cell: 322 out of 1,024 have only 4 distinct relays. A few examples:

#u1: nostr.stakey.net and nostr.stakey.net:443 #gb: relay.staging.plebeian.market twice #9q: nostr.2b9t.xyz twice


Aggravating factors

  • Distance ties: with identical coordinates, iOS and Android break the tie for the 5th slot differently. The selections can therefore differ even with the same list.

  • Relays requiring NIP-42: present in the list, but unusable by the app (the apps dont authenticate).

  • No replacement: an app does not replace a dead relay with the next one. Each dead relay directly reduces the number of relays the channel actually has.

  • Android compte des relais en double (il manque la normalisation des entrées)

Sur les trois premiers points, Android suit la même politique qu'iOS, ou une politique plus fragile encore. En le vérifiant, j'ai aussi trouvé un quatrième problème, propre à Android et important.

Findings

J'ai lu le code Android au commit eed88190c0 du 26/10/2026 : RelayDirectory.kt, NostrRelayManager.kt, NostrRequest.kt et RelayReconnectPolicy.kt.

1. Égalités de distance : rien n'est départagé

closestRelaysForGeohash trie les relais par distance (sortedBy { haversineMeters(...) }), puis prend les 5 premiers (take(5)). Le tri de Kotlin est stable : en cas d'égalité, c'est l'ordre des lignes dans le CSV qui décide. Il n'y a aucun critère de départage. iOS, lui, départage par nom d'hôte. Avec les mêmes données, les deux apps peuvent donc retenir des relais différents.

2. Relais qui exigent NIP-42 : Android ne s'authentifie pas non plus

Le parseur des réponses de relais (NostrResponse.fromJsonArray) ne reconnaît que EVENT, EOSE, OK et NOTICE. Les messages AUTH et CLOSED sont classés Unknown et ignorés. Concrètement :

  • le challenge d'authentification n'obtient jamais de réponse ;
  • un abonnement fermé avec auth-required: n'est pas détecté. L'app croit être abonnée alors que rien n'arrive ;
  • un événement refusé (OK false) est seulement journalisé, sans nouvel essai.

C'est la même situation qu'iOS : ces relais occupent une des 5 places sans servir à rien.

3. Relais en panne : jamais remplacés, et réessayés indéfiniment

Pas de remplacement. La sélection reste les 5 plus proches, quel que soit leur état. Android ne se replie sur les relais par défaut que si la sélection est vide (aucun relais dans la liste), pas quand les relais choisis sont en panne. Nouvel essai sans fin, par choix explicite. RelayReconnectPolicy attend de plus en plus longtemps entre les essais (1 s, puis 2 s, 4 s…) jusqu'à un maximum de 5 minutes, et n'abandonne jamais. Même une erreur DNS est réessayée. Le commentaire du code justifie ce choix : un téléphone perd souvent le réseau (tunnel, mode avion), et abandonner couperait les canaux jusqu'au redémarrage de l'app.

Conséquence : un relais au certificat invalide occupe sa place pour toujours et coûte une tentative de connexion toutes les 5 minutes. Les app ne distinguent pas les pannes durables des pannes passagères.

4. En plus : Android compte certains relais en double

normalizeRelayUrl ajoute seulement wss:// devant l'hôte. Il ne met pas l'URL en minuscules, ne retire pas :443 et ne supprime pas les doublons. Or la liste en amont contient 49 relais présents deux fois, sous la forme host et host:443, avec les mêmes coordonnées. Comme ils sont à égalité, ils sont triés côte à côte et prennent deux des 5 places pour le même serveur.

J'ai simulé la sélection Android exacte sur toutes les cellules de précision 2 : 322 sur 1 024 n'ont que 4 relais distincts. Quelques exemples :

#u1 : nostr.stakey.net et nostr.stakey.net:443 #gb : relay.staging.plebeian.market en double #9q : nostr.2b9t.xyz en double

Activity

0

No activity yet.