mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 05:45:19 +00:00
Merge branch 'main' into gossip-graph
This commit is contained in:
@@ -13,8 +13,8 @@ android {
|
|||||||
applicationId = "com.bitchat.droid"
|
applicationId = "com.bitchat.droid"
|
||||||
minSdk = libs.versions.minSdk.get().toInt()
|
minSdk = libs.versions.minSdk.get().toInt()
|
||||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||||
versionCode = 18
|
versionCode = 19
|
||||||
versionName = "1.2.2"
|
versionName = "1.2.3"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
|
|||||||
@@ -41,6 +41,12 @@
|
|||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.BitchatAndroid"
|
android:theme="@style/Theme.BitchatAndroid"
|
||||||
tools:targetApi="31">
|
tools:targetApi="31">
|
||||||
|
<activity
|
||||||
|
android:name=".ui.GeohashPickerActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:theme="@style/Theme.BitchatAndroid"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
|
android:windowSoftInputMode="adjustResize" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<style>
|
||||||
|
:root { --text: #333; }
|
||||||
|
html, body, #map { height: 100%; margin: 0; padding: 0; background: #ffffff; }
|
||||||
|
.leaflet-container { background: #ffffff; }
|
||||||
|
.leaflet-div-icon { background: transparent; border: none; }
|
||||||
|
.gh-label { background: transparent; border: none; pointer-events: none; filter: none; }
|
||||||
|
.gh-text {
|
||||||
|
color: #444444;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
text-shadow: 0 0 2px #ffffff, 0 0 2px #ffffff, 0 0 2px #ffffff;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
}
|
||||||
|
.dark .gh-text {
|
||||||
|
color: #dddddd;
|
||||||
|
text-shadow: 0 0 2px #000000, 0 0 2px #000000, 0 0 2px #000000;
|
||||||
|
}
|
||||||
|
.gh-text-selected {
|
||||||
|
color: #00C851 !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="map"></div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
<script>
|
||||||
|
// Minimal geohash (bounds/encode/adjacent)
|
||||||
|
(function () {
|
||||||
|
const base32 = "0123456789bcdefghjkmnpqrstuvwxyz";
|
||||||
|
function bounds(geohash) {
|
||||||
|
let evenBit = true; let latMin = -90, latMax = 90, lonMin = -180, lonMax = 180;
|
||||||
|
geohash = geohash.toLowerCase();
|
||||||
|
for (let i = 0; i < geohash.length; i++) {
|
||||||
|
const idx = base32.indexOf(geohash.charAt(i));
|
||||||
|
if (idx == -1) throw new Error("Invalid geohash");
|
||||||
|
for (let n = 4; n >= 0; n--) {
|
||||||
|
const bitN = (idx >> n) & 1;
|
||||||
|
if (evenBit) { const lonMid = (lonMin + lonMax) / 2; if (bitN == 1) lonMin = lonMid; else lonMax = lonMid; }
|
||||||
|
else { const latMid = (latMin + latMax) / 2; if (bitN == 1) latMin = latMid; else latMax = latMid; }
|
||||||
|
evenBit = !evenBit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { sw: { lat: latMin, lng: lonMin }, ne: { lat: latMax, lng: lonMax } };
|
||||||
|
}
|
||||||
|
function encode(lat, lon, precision) {
|
||||||
|
let idx = 0, bit = 0, evenBit = true, hash = "";
|
||||||
|
let latMin = -90, latMax = 90, lonMin = -180, lonMax = 180;
|
||||||
|
while (hash.length < precision) {
|
||||||
|
if (evenBit) { const lonMid = (lonMin + lonMax) / 2; if (lon >= lonMid) { idx = idx * 2 + 1; lonMin = lonMid; } else { idx = idx * 2; lonMax = lonMid; } }
|
||||||
|
else { const latMid = (latMin + latMax) / 2; if (lat >= latMid) { idx = idx * 2 + 1; latMin = latMid; } else { idx = idx * 2; latMax = latMid; } }
|
||||||
|
evenBit = !evenBit; if (++bit == 5) { hash += base32.charAt(idx); bit = 0; idx = 0; }
|
||||||
|
}
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
function adjacent(hash, dir) {
|
||||||
|
const neighbour = { n:["p0r21436x8zb9dcf5h7kjnmqesgutwvy","bc01fg45238967deuvhjyznpkmstqrwx"], s:["14365h7k9dcfesgujnmqp0r2twvyx8zb","238967debc01fg45kmstqrwxuvhjyznp"], e:["bc01fg45238967deuvhjyznpkmstqrwx","p0r21436x8zb9dcf5h7kjnmqesgutwvy"], w:["238967debc01fg45kmstqrwxuvhjyznp","14365h7k9dcfesgujnmqp0r2twvyx8zb"] };
|
||||||
|
const border = { n:["prxz","bcfguvyz"], s:["028b","0145hjnp"], e:["bcfguvyz","prxz"], w:["0145hjnp","028b"] };
|
||||||
|
hash = hash.toLowerCase(); const lastCh = hash.slice(-1); let parent = hash.slice(0, -1); const type = hash.length % 2;
|
||||||
|
if (border[dir][type].indexOf(lastCh) != -1 && parent != "") parent = adjacent(parent, dir);
|
||||||
|
return parent + base32.charAt(neighbour[dir][type].indexOf(lastCh));
|
||||||
|
}
|
||||||
|
window.__geohash = { bounds, encode, adjacent };
|
||||||
|
})();
|
||||||
|
|
||||||
|
const map = L.map("map", { zoomControl: true, minZoom: 2, maxZoom: 21 }).setView([0, 0], 3);
|
||||||
|
L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", { maxZoom: 21, attribution: "© OpenStreetMap © Carto", opacity: 1.0 }).addTo(map);
|
||||||
|
|
||||||
|
let selectedGeohash = "";
|
||||||
|
let gridLayer = L.layerGroup().addTo(map);
|
||||||
|
let pinnedPrecision = null;
|
||||||
|
let outlineColor = "#00C851";
|
||||||
|
|
||||||
|
function getNeighbors(hash) {
|
||||||
|
const neighbors = [];
|
||||||
|
// N, S, E, W
|
||||||
|
neighbors.push(window.__geohash.adjacent(hash, 'n'));
|
||||||
|
neighbors.push(window.__geohash.adjacent(hash, 's'));
|
||||||
|
neighbors.push(window.__geohash.adjacent(hash, 'e'));
|
||||||
|
neighbors.push(window.__geohash.adjacent(hash, 'w'));
|
||||||
|
// Diagonals
|
||||||
|
neighbors.push(window.__geohash.adjacent(window.__geohash.adjacent(hash, 'n'), 'e'));
|
||||||
|
neighbors.push(window.__geohash.adjacent(window.__geohash.adjacent(hash, 'n'), 'w'));
|
||||||
|
neighbors.push(window.__geohash.adjacent(window.__geohash.adjacent(hash, 's'), 'e'));
|
||||||
|
neighbors.push(window.__geohash.adjacent(window.__geohash.adjacent(hash, 's'), 'w'));
|
||||||
|
return neighbors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickPrecisionForViewport() {
|
||||||
|
const c = map.getCenter();
|
||||||
|
const minPx = 80;
|
||||||
|
const maxPx = 240;
|
||||||
|
let chosen = 1;
|
||||||
|
let lastAboveMin = 1;
|
||||||
|
for (let p = 1; p <= 12; p++) {
|
||||||
|
const gh = window.__geohash.encode(c.lat, c.lng, p);
|
||||||
|
const b = window.__geohash.bounds(gh);
|
||||||
|
const pSw = map.latLngToLayerPoint([b.sw.lat, b.sw.lng]);
|
||||||
|
const pNe = map.latLngToLayerPoint([b.ne.lat, b.ne.lng]);
|
||||||
|
const cellPx = Math.min(Math.abs(pNe.x - pSw.x), Math.abs(pSw.y - pNe.y));
|
||||||
|
if (cellPx >= minPx && cellPx <= maxPx) { chosen = p; break; }
|
||||||
|
if (cellPx >= minPx) { lastAboveMin = p; }
|
||||||
|
if (cellPx < minPx) { chosen = lastAboveMin; break; }
|
||||||
|
if (p === 12) { chosen = 12; }
|
||||||
|
}
|
||||||
|
return chosen;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifySelection() {
|
||||||
|
if (window.Android && window.Android.onGeohashChanged && selectedGeohash) {
|
||||||
|
window.Android.onGeohashChanged(selectedGeohash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomForPrecision(p) {
|
||||||
|
if (p <= 1) return 1; if (p === 2) return 2; if (p === 3) return 3; if (p === 4) return 4;
|
||||||
|
if (p === 5) return 5; if (p === 6) return 7; if (p === 7) return 9; if (p === 8) return 11;
|
||||||
|
if (p === 9) return 13; if (p === 10) return 15; if (p === 11) return 17;
|
||||||
|
return 18;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOverlay() {
|
||||||
|
gridLayer.clearLayers();
|
||||||
|
const c = map.getCenter();
|
||||||
|
const usePinned = pinnedPrecision !== null;
|
||||||
|
const p = usePinned ? pinnedPrecision : pickPrecisionForViewport();
|
||||||
|
selectedGeohash = window.__geohash.encode(c.lat, c.lng, p);
|
||||||
|
notifySelection();
|
||||||
|
|
||||||
|
const centerBounds = window.__geohash.bounds(selectedGeohash);
|
||||||
|
const centerLon = (centerBounds.sw.lng + centerBounds.ne.lng) / 2;
|
||||||
|
const centerLat = (centerBounds.sw.lat + centerBounds.ne.lat) / 2;
|
||||||
|
|
||||||
|
const allHashes = [selectedGeohash, ...getNeighbors(selectedGeohash)];
|
||||||
|
|
||||||
|
const filteredHashes = allHashes.filter(gh => {
|
||||||
|
if (!gh) return false;
|
||||||
|
try {
|
||||||
|
const b = window.__geohash.bounds(gh);
|
||||||
|
const lon = (b.sw.lng + b.ne.lng) / 2;
|
||||||
|
const lat = (b.sw.lat + b.ne.lat) / 2;
|
||||||
|
if (Math.abs(lon - centerLon) > 180) return false; // anti-meridian wrap
|
||||||
|
if (Math.abs(lat - centerLat) > 90) return false; // pole wrap
|
||||||
|
return true;
|
||||||
|
} catch (e) { return false; }
|
||||||
|
});
|
||||||
|
|
||||||
|
filteredHashes.forEach(gh => {
|
||||||
|
const b = window.__geohash.bounds(gh);
|
||||||
|
const sw = [b.sw.lat, b.sw.lng];
|
||||||
|
const ne = [b.ne.lat, b.ne.lng];
|
||||||
|
const isSelected = (gh === selectedGeohash);
|
||||||
|
|
||||||
|
const rect = L.rectangle([sw, ne], {
|
||||||
|
color: isSelected ? outlineColor : '#cccccc',
|
||||||
|
weight: isSelected ? 3 : 1,
|
||||||
|
fillOpacity: 0.0,
|
||||||
|
opacity: 0.9,
|
||||||
|
interactive: false
|
||||||
|
});
|
||||||
|
gridLayer.addLayer(rect);
|
||||||
|
|
||||||
|
const center = [(b.sw.lat + b.ne.lat) / 2, (b.sw.lng + b.ne.lng) / 2];
|
||||||
|
const labelClass = isSelected ? 'gh-text gh-text-selected' : 'gh-text';
|
||||||
|
const label = L.marker(center, {
|
||||||
|
icon: L.divIcon({
|
||||||
|
className: 'gh-label',
|
||||||
|
html: `<span class="${labelClass}">${gh}</span>`
|
||||||
|
}),
|
||||||
|
interactive: false
|
||||||
|
});
|
||||||
|
gridLayer.addLayer(label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
map.on("movestart", () => { pinnedPrecision = null; });
|
||||||
|
map.on("zoomstart", () => { pinnedPrecision = null; });
|
||||||
|
map.on("moveend", updateOverlay);
|
||||||
|
map.on("zoomend", updateOverlay);
|
||||||
|
|
||||||
|
function setCenter(lat, lng) { map.setView([lat, lng], map.getZoom()); }
|
||||||
|
function setPrecision(p) {
|
||||||
|
const clamped = Math.max(1, Math.min(12, p|0));
|
||||||
|
const targetZoom = zoomForPrecision(clamped);
|
||||||
|
map.setZoom(targetZoom);
|
||||||
|
}
|
||||||
|
function focusGeohash(gh) {
|
||||||
|
if (!gh || typeof gh !== 'string') return;
|
||||||
|
const g = gh.toLowerCase();
|
||||||
|
const b = window.__geohash.bounds(g);
|
||||||
|
pinnedPrecision = g.length;
|
||||||
|
map.fitBounds([[b.sw.lat, b.sw.lng],[b.ne.lat, b.ne.lng]], { animate: false, padding: [8,8] });
|
||||||
|
selectedGeohash = g;
|
||||||
|
}
|
||||||
|
function getGeohash() { return selectedGeohash; }
|
||||||
|
|
||||||
|
// Android side will call this with 'dark' or 'light'
|
||||||
|
function setMapTheme(theme) {
|
||||||
|
document.body.className = theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setCenter = setCenter;
|
||||||
|
window.setPrecision = setPrecision;
|
||||||
|
window.focusGeohash = focusGeohash;
|
||||||
|
window.getGeohash = getGeohash;
|
||||||
|
window.setMapTheme = setMapTheme;
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
try { map.off(); } catch (_) {}
|
||||||
|
try { gridLayer.clearLayers(); } catch (_) {}
|
||||||
|
try { map.remove(); } catch (_) {}
|
||||||
|
}
|
||||||
|
window.cleanup = cleanup;
|
||||||
|
|
||||||
|
map.whenReady(updateOverlay);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,273 +1,263 @@
|
|||||||
Relay URL,Latitude,Longitude
|
Relay URL,Latitude,Longitude
|
||||||
relay.chorus.community,50.1109,8.68213
|
|
||||||
relay-rpi.edufeed.org,49.4543,11.0746
|
|
||||||
nostr.chaima.info,50.1109,8.68213
|
|
||||||
relay.nostraddress.com,43.6532,-79.3832
|
|
||||||
nostr.middling.mydns.jp,35.8099,140.12
|
|
||||||
nostr.myshosholoza.co.za,52.3676,4.90414
|
|
||||||
n.ok0.org,-37.7254,176.133
|
|
||||||
relay.21e6.cz,50.1682,14.0546
|
|
||||||
premium.primal.net,43.6532,-79.3832
|
|
||||||
relay.stream.labs.h3.se,59.4016,17.9455
|
|
||||||
nostr.night7.space,50.4754,12.3683
|
|
||||||
nostr.4rs.nl,49.0291,8.35696
|
|
||||||
relay.notoshi.win,13.7683,100.54
|
|
||||||
nostr.carroarmato0.be,50.9017,3.52588
|
|
||||||
relay.angor.io,48.1046,11.6002
|
|
||||||
relay.nostriot.com,41.5695,-83.9786
|
|
||||||
nostr-pub.wellorder.net,45.5201,-122.99
|
|
||||||
relay04.lnfi.network,39.0997,-94.5786
|
|
||||||
x.kojira.io,43.6532,-79.3832
|
|
||||||
strfry.felixzieger.de,50.1013,8.62643
|
|
||||||
purpura.cloud,43.6532,-79.3832
|
|
||||||
alien.macneilmediagroup.com,43.6532,-79.3832
|
|
||||||
wot.sudocarlos.com,51.5072,-0.127586
|
|
||||||
relay02.lnfi.network,39.0997,-94.5786
|
|
||||||
relay.getsafebox.app,43.6532,-79.3832
|
|
||||||
wot.sebastix.social,51.8933,4.42083
|
|
||||||
relay.dwadziesciajeden.pl,52.2297,21.0122
|
|
||||||
nostr.roundrockbitcoiners.com,40.8054,-74.0241
|
|
||||||
nostr.dlsouza.lol,50.1109,8.68213
|
|
||||||
nostr-relay.shirogaku.xyz,43.6532,-79.3832
|
|
||||||
yabu.me,35.6092,139.73
|
|
||||||
nostr.stakey.net,52.3676,4.90414
|
|
||||||
relay.sincensura.org,43.6532,-79.3832
|
|
||||||
nostr-02.czas.top,51.2277,6.77346
|
|
||||||
soloco.nl,43.6532,-79.3832
|
|
||||||
nostream.breadslice.com,43.6532,-79.3832
|
|
||||||
nostr.camalolo.com,24.1469,120.684
|
|
||||||
nostr-dev.wellorder.net,45.5201,-122.99
|
|
||||||
nostr.pleb.one,38.6327,-90.1961
|
|
||||||
cyberspace.nostr1.com,40.7357,-74.1724
|
|
||||||
relay.illuminodes.com,47.6061,-122.333
|
|
||||||
satsage.xyz,37.3986,-121.964
|
|
||||||
nostr.data.haus,50.4754,12.3683
|
|
||||||
nostr.jfischer.org,49.0291,8.35696
|
|
||||||
r.bitcoinhold.net,43.6532,-79.3832
|
|
||||||
relay.barine.co,43.6532,-79.3832
|
|
||||||
wot.sovbit.host,64.1466,-21.9426
|
|
||||||
nostr.vulpem.com,49.4543,11.0746
|
|
||||||
nostr.kalf.org,52.3676,4.90414
|
|
||||||
nostr.thebiglake.org,32.71,-96.6745
|
|
||||||
relay.bitcoinveneto.org,64.1466,-21.9426
|
|
||||||
wot.dergigi.com,64.1476,-21.9392
|
|
||||||
nos.xmark.cc,50.6924,3.20113
|
|
||||||
nostr.88mph.life,43.6532,-79.3832
|
|
||||||
relay.mess.ch,47.3591,8.55292
|
|
||||||
nostr.thaliyal.com,40.8218,-74.45
|
|
||||||
nostr.notribe.net,40.8302,-74.1299
|
|
||||||
relay.orangepill.ovh,49.1689,-0.358841
|
|
||||||
relay.artiostr.ch,43.6532,-79.3832
|
|
||||||
relay.13room.space,43.6532,-79.3832
|
|
||||||
relay.primal.net,43.6532,-79.3832
|
|
||||||
strfry.shock.network,41.8959,-88.2169
|
|
||||||
nostr.2b9t.xyz,34.0549,-118.243
|
|
||||||
ribo.af.nostria.app,-26.2041,28.0473
|
|
||||||
a.nos.lol,50.4754,12.3683
|
|
||||||
relay.mccormick.cx,52.3563,4.95714
|
|
||||||
relay.mostro.network,40.8302,-74.1299
|
|
||||||
relay.bitcoinartclock.com,50.4754,12.3683
|
|
||||||
nostr-relay.cbrx.io,43.6532,-79.3832
|
|
||||||
prl.plus,55.7623,37.6381
|
|
||||||
relay.endfiat.money,43.6532,-79.3832
|
|
||||||
relay.hook.cafe,43.6532,-79.3832
|
|
||||||
relay.siamdev.cc,13.9178,100.424
|
|
||||||
wot.nostr.party,36.1627,-86.7816
|
|
||||||
wot.basspistol.org,49.4521,11.0767
|
|
||||||
dev-nostr.bityacht.io,25.0797,121.234
|
|
||||||
orangesync.tech,51.5072,-0.127586
|
|
||||||
nostr.casa21.space,43.6532,-79.3832
|
|
||||||
relay.digitalezukunft.cyou,45.5019,-73.5674
|
|
||||||
relay.nostrcheck.me,43.6532,-79.3832
|
|
||||||
inbox.mycelium.social,38.627,-90.1994
|
|
||||||
nostr.liberty.fans,36.9104,-89.5875
|
|
||||||
nostr-verified.wellorder.net,45.5201,-122.99
|
|
||||||
relay.sigit.io,50.4754,12.3683
|
|
||||||
tollbooth.stens.dev,51.223,6.78245
|
|
||||||
relay.nostromo.social,49.4543,11.0746
|
|
||||||
wheat.happytavern.co,43.6532,-79.3832
|
|
||||||
relay.vrtmrz.net,43.6532,-79.3832
|
|
||||||
relayrs.notoshi.win,43.6532,-79.3832
|
|
||||||
travis-shears-nostr-relay-v2.fly.dev,41.8781,-87.6298
|
|
||||||
nostr.community.ath.cx,45.5029,-73.5723
|
|
||||||
wot.girino.org,43.6532,-79.3832
|
|
||||||
nostr.coincrowd.fund,39.0438,-77.4874
|
|
||||||
relay.freeplace.nl,52.3676,4.90414
|
|
||||||
nostr-03.dorafactory.org,1.35208,103.82
|
|
||||||
relay5.bitransfer.org,43.6532,-79.3832
|
|
||||||
nostr.tac.lol,47.4748,-122.273
|
|
||||||
nostr.sagaciousd.com,49.2827,-123.121
|
|
||||||
nostr.girino.org,43.6532,-79.3832
|
|
||||||
nostr.jerrynya.fun,31.2304,121.474
|
|
||||||
relay.agorist.space,52.3734,4.89406
|
|
||||||
inbox.azzamo.net,52.2633,21.0283
|
|
||||||
wot.codingarena.top,50.4754,12.3683
|
|
||||||
relay.degmods.com,50.4754,12.3683
|
|
||||||
articles.layer3.news,37.3387,-121.885
|
|
||||||
nostr.rblb.it,43.4633,11.8796
|
|
||||||
relay.davidebtc.me,51.5072,-0.127586
|
|
||||||
relay.hodl.ar,-34.5904,-58.629
|
|
||||||
nostr.rtvslawenia.com,49.4543,11.0746
|
|
||||||
relay01.lnfi.network,39.0997,-94.5786
|
|
||||||
strfry.bonsai.com,37.8715,-122.273
|
|
||||||
relay.nostr.place,32.7767,-96.797
|
|
||||||
relay.bitcoindistrict.org,43.6532,-79.3832
|
|
||||||
relay.aloftus.io,34.0881,-118.379
|
|
||||||
nostr.massmux.com,51.5072,-0.127586
|
|
||||||
relay.wellorder.net,45.5201,-122.99
|
|
||||||
santo.iguanatech.net,40.8302,-74.1299
|
|
||||||
relay.damus.io,43.6532,-79.3832
|
relay.damus.io,43.6532,-79.3832
|
||||||
relay.javi.space,43.4633,11.8796
|
|
||||||
relay.nosto.re,51.8933,4.42083
|
|
||||||
relay.utxo.farm,35.6916,139.768
|
|
||||||
relay.fr13nd5.com,52.5233,13.3426
|
|
||||||
librerelay.aaroniumii.com,43.6532,-79.3832
|
|
||||||
relay.cypherflow.ai,48.8566,2.35222
|
|
||||||
relay.letsfo.com,51.098,17.0321
|
|
||||||
nostr.0x7e.xyz,47.4988,8.72369
|
|
||||||
relay.bullishbounty.com,43.6532,-79.3832
|
|
||||||
relay.tagayasu.xyz,43.6715,-79.38
|
|
||||||
nostr.plantroon.com,50.1013,8.62643
|
|
||||||
strfry.openhoofd.nl,51.9229,4.40833
|
|
||||||
nostrelites.org,41.8781,-87.6298
|
|
||||||
relay.credenso.cafe,43.1149,-80.7228
|
|
||||||
relay.conduit.market,43.6532,-79.3832
|
|
||||||
relay.olas.app,50.4754,12.3683
|
|
||||||
relay.nostrdice.com,-33.8688,151.209
|
|
||||||
nostr.oxtr.dev,50.4754,12.3683
|
|
||||||
relay.nostr.band,60.1699,24.9384
|
|
||||||
khatru.nostrver.se,51.8933,4.42083
|
|
||||||
relay.coinos.io,43.6532,-79.3832
|
|
||||||
nostr.l484.com,30.2944,-97.6223
|
|
||||||
purplerelay.com,50.1109,8.68213
|
|
||||||
relay.nostrhub.fr,48.1046,11.6002
|
|
||||||
black.nostrcity.club,41.8781,-87.6298
|
|
||||||
nostr.snowbla.de,60.1699,24.9384
|
|
||||||
no.str.cr,9.93388,-84.0849
|
|
||||||
nostr.rikmeijer.nl,50.4754,12.3683
|
|
||||||
nproxy.kristapsk.lv,60.1699,24.9384
|
|
||||||
wot.soundhsa.com,34.0479,-118.256
|
|
||||||
nostr.kungfu-g.rip,33.7946,-84.4488
|
|
||||||
nostr.spaceshell.xyz,43.6532,-79.3832
|
|
||||||
nostr.mikoshi.de,50.1109,8.68213
|
|
||||||
gnostr.com,40.9017,29.1616
|
|
||||||
noxir.kpherox.dev,34.8587,135.509
|
|
||||||
nostr.ser1.net,12.9716,77.5946
|
|
||||||
nostr-relay.zimage.com,34.282,-118.439
|
|
||||||
nostr2.girino.org,43.6532,-79.3832
|
nostr2.girino.org,43.6532,-79.3832
|
||||||
dev-relay.lnfi.network,39.0997,-94.5786
|
|
||||||
nostr.hifish.org,47.3769,8.54169
|
|
||||||
itanostr.space,52.2931,4.79099
|
|
||||||
srtrelay.c-stellar.net,43.6532,-79.3832
|
|
||||||
wot.dtonon.com,43.6532,-79.3832
|
|
||||||
relay.mwaters.net,50.9871,2.12554
|
|
||||||
relay.nostr.net,50.4754,12.3683
|
|
||||||
relay.nostx.io,43.6532,-79.3832
|
|
||||||
relay.laantungir.net,-19.4692,-42.5315
|
|
||||||
pyramid.fiatjaf.com,50.1109,8.68213
|
|
||||||
nostr.einundzwanzig.space,50.1109,8.68213
|
|
||||||
relay.satlantis.io,32.8769,-80.0114
|
|
||||||
nostr-relay.amethyst.name,35.7327,-78.8503
|
|
||||||
fanfares.nostr1.com,40.7057,-74.0136
|
|
||||||
relay.evanverma.com,40.8302,-74.1299
|
|
||||||
relay.wavlake.com,41.2619,-95.8608
|
|
||||||
relay.bitchat.party,53.5501,-113.469
|
|
||||||
nostr.yael.at,52.3563,4.95714
|
|
||||||
fenrir-s.notoshi.win,43.6532,-79.3832
|
|
||||||
relay.nostr.wirednet.jp,34.704,135.495
|
|
||||||
relay.origin.land,35.6673,139.751
|
|
||||||
relay.goodmorningbitcoin.com,43.6532,-79.3832
|
|
||||||
relay.mattybs.lol,43.6532,-79.3832
|
|
||||||
relay.nostr.vet,52.6467,4.7395
|
|
||||||
relay.arx-ccn.com,50.4754,12.3683
|
|
||||||
nostr.agentcampfire.com,50.8933,6.05805
|
|
||||||
ribo.eu.nostria.app,52.3676,4.90414
|
|
||||||
nostr.huszonegy.world,47.4979,19.0402
|
|
||||||
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
|
|
||||||
temp.iris.to,43.6532,-79.3832
|
|
||||||
relay.etch.social,41.2619,-95.8608
|
|
||||||
relay.hasenpfeffr.com,39.0438,-77.4874
|
|
||||||
relay.exit.pub,50.4754,12.3683
|
|
||||||
relay.chakany.systems,43.6532,-79.3832
|
|
||||||
nostr.coincards.com,45.4002,-75.8064
|
|
||||||
nostr.hekster.org,37.3986,-121.964
|
|
||||||
nostr-relay.psfoundation.info,39.0438,-77.4874
|
|
||||||
nostr-2.21crypto.ch,47.1928,8.81494
|
|
||||||
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
|
||||||
relay.toastr.net,40.8054,-74.0241
|
|
||||||
relay.wolfcoil.com,35.6092,139.73
|
|
||||||
nostr-02.dorafactory.org,1.35208,103.82
|
|
||||||
portal-relay.pareto.space,49.4543,11.0746
|
|
||||||
nostr.makibisskey.work,43.6532,-79.3832
|
|
||||||
shu04.shugur.net,25.2604,55.2989
|
|
||||||
relay.lnfi.network,39.0438,-77.4874
|
|
||||||
nostr.smut.cloud,43.6532,-79.3832
|
|
||||||
nostr.diakod.com,43.6532,-79.3832
|
|
||||||
relay.lumina.rocks,49.0291,8.35695
|
|
||||||
nostr.now,36.55,139.733
|
|
||||||
relay.mockingyou.com,37.5485,-121.989
|
|
||||||
nostr.blankfors.se,60.1699,24.9384
|
|
||||||
adre.su,59.9311,30.3609
|
|
||||||
nostr.tadryanom.me,43.6532,-79.3832
|
|
||||||
relay2.angor.io,48.1046,11.6002
|
|
||||||
shu02.shugur.net,21.4902,39.2246
|
|
||||||
nostrelay.circum.space,51.2217,6.77616
|
|
||||||
nostr.spacecitynode.com,29.7057,-95.2706
|
|
||||||
vidono.apps.slidestr.net,48.8566,2.35222
|
|
||||||
relay.btcwill.win,43.6532,-79.3832
|
|
||||||
orangepiller.org,60.1699,24.9384
|
|
||||||
solo.itsalldance.space,32.71,-96.6745
|
|
||||||
relay.zone667.com,60.1699,24.9384
|
|
||||||
nostr.azzamo.net,52.2633,21.0283
|
|
||||||
internationalright-wing.org,-22.5986,-48.8003
|
|
||||||
shu05.shugur.net,48.8566,2.35222
|
|
||||||
relay.lifpay.me,1.35208,103.82
|
|
||||||
slick.mjex.me,39.048,-77.4817
|
|
||||||
theoutpost.life,64.1476,-21.9392
|
|
||||||
nostr.rohoss.com,50.1109,8.68213
|
|
||||||
relay.cosmicbolt.net,37.3986,-121.964
|
|
||||||
relay.jeffg.fyi,43.6532,-79.3832
|
|
||||||
nostr.satstralia.com,64.1476,-21.9392
|
|
||||||
relay-admin.thaliyal.com,40.8218,-74.45
|
|
||||||
social.proxymana.net,60.1699,24.9384
|
|
||||||
nostr.sathoarder.com,48.5734,7.75211
|
|
||||||
wot.nostr.place,30.2672,-97.7431
|
|
||||||
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
|
|
||||||
relay.nsnip.io,60.1699,24.9384
|
|
||||||
nostr.zenon.network,43.5009,-70.4428
|
|
||||||
relay.copylaradio.com,50.8198,-1.08798
|
|
||||||
relay.nostrhub.tech,49.4543,11.0746
|
|
||||||
ithurtswhenip.ee,51.0238,-1.33713
|
|
||||||
nostr-01.yakihonne.com,1.32123,103.695
|
|
||||||
nostr-relay.online,43.6532,-79.3832
|
|
||||||
vitor.nostr1.com,40.7057,-74.0136
|
|
||||||
relay-testnet.k8s.layer3.news,37.3387,-121.885
|
|
||||||
schnorr.me,43.6532,-79.3832
|
|
||||||
relay.artx.market,43.652,-79.3633
|
|
||||||
nostr.21crypto.ch,47.1928,8.81494
|
|
||||||
relayone.soundhsa.com,34.0479,-118.256
|
|
||||||
relay.puresignal.news,43.6532,-79.3832
|
|
||||||
nostr.n7ekb.net,47.4941,-122.294
|
nostr.n7ekb.net,47.4941,-122.294
|
||||||
wot.brightbolt.net,47.6735,-116.781
|
rnostr.breadslice.com,43.6532,-79.3832
|
||||||
nostr.overmind.lol,43.6532,-79.3832
|
portal-relay.pareto.space,49.4543,11.0746
|
||||||
relay.magiccity.live,25.8128,-80.2377
|
relay.hasenpfeffr.com,39.0438,-77.4874
|
||||||
kitchen.zap.cooking,43.6532,-79.3832
|
nostr.blankfors.se,60.1699,24.9384
|
||||||
relay.satsdays.com,1.35208,103.82
|
relay5.bitransfer.org,43.6532,-79.3832
|
||||||
nostr-02.yakihonne.com,1.32123,103.695
|
santo.iguanatech.net,40.8302,-74.1299
|
||||||
offchain.pub,36.1809,-115.241
|
nostr.ser1.net,12.9716,77.5946
|
||||||
nostr.lojong.info,43.6532,-79.3832
|
nostr.stakey.net,52.3676,4.90414
|
||||||
relay.tapestry.ninja,40.8054,-74.0241
|
relayrs.notoshi.win,43.6532,-79.3832
|
||||||
zap.watch,45.5029,-73.5723
|
orangepiller.org,60.1699,24.9384
|
||||||
nostrelay.memory-art.xyz,43.6532,-79.3832
|
temp.iris.to,43.6532,-79.3832
|
||||||
nostr.spicyz.io,43.6532,-79.3832
|
relay.cypherflow.ai,48.8566,2.35222
|
||||||
nostr.mom,50.4754,12.3683
|
nostr.kalf.org,52.3676,4.90414
|
||||||
nos.lol,50.4754,12.3683
|
relay.unknown.cloud,43.6532,-79.3832
|
||||||
|
relay.artx.market,43.652,-79.3633
|
||||||
|
relay.arx-ccn.com,50.4754,12.3683
|
||||||
relay.electriclifestyle.com,26.2897,-80.1293
|
relay.electriclifestyle.com,26.2897,-80.1293
|
||||||
wot.nostr.net,43.6532,-79.3832
|
nostr-relay.online,43.6532,-79.3832
|
||||||
nostr.primz.org,43.6532,-79.3832
|
relay.jeffg.fyi,43.6532,-79.3832
|
||||||
|
relay.wellorder.net,45.5201,-122.99
|
||||||
|
relay01.lnfi.network,39.0997,-94.5786
|
||||||
|
noxir.kpherox.dev,34.8587,135.509
|
||||||
|
wot.sebastix.social,51.8933,4.42083
|
||||||
|
nostr.overmind.lol,43.6532,-79.3832
|
||||||
nostr.red5d.dev,43.6532,-79.3832
|
nostr.red5d.dev,43.6532,-79.3832
|
||||||
relay.0xchat.com,1.35208,103.82
|
nostr.zenon.network,43.5009,-70.4428
|
||||||
relay.moinsen.com,50.4754,12.3683
|
cyberspace.nostr1.com,40.7128,-74.006
|
||||||
relay03.lnfi.network,39.0997,-94.5786
|
fenrir-s.notoshi.win,43.6532,-79.3832
|
||||||
|
no.str.cr,9.92857,-84.0528
|
||||||
|
relay.nostrdice.com,-33.8688,151.209
|
||||||
|
nostr.data.haus,50.4754,12.3683
|
||||||
|
relay.g1sms.fr,43.9432,2.07537
|
||||||
|
relay.fundstr.me,42.3601,-71.0589
|
||||||
|
nostr.thaliyal.com,40.8218,-74.45
|
||||||
|
relay.mccormick.cx,52.3563,4.95714
|
||||||
|
nostr.makibisskey.work,43.6532,-79.3832
|
||||||
|
shu01.shugur.net,21.4902,39.2246
|
||||||
|
relay.angor.io,48.1046,11.6002
|
||||||
|
nostr.primz.org,43.6532,-79.3832
|
||||||
|
nostr-02.yakihonne.com,1.32123,103.695
|
||||||
|
relay.freeplace.nl,52.3676,4.90414
|
||||||
|
relay.snort.social,43.6532,-79.3832
|
||||||
|
nostr.rohoss.com,50.1109,8.68213
|
||||||
|
relay.getsafebox.app,43.6532,-79.3832
|
||||||
|
nostr.88mph.life,43.6532,-79.3832
|
||||||
|
wot.sovbit.host,64.1466,-21.9426
|
||||||
|
nostr-02.dorafactory.org,1.35208,103.82
|
||||||
|
itanostr.space,52.2931,4.79099
|
||||||
|
ribo.af.nostria.app,-26.2041,28.0473
|
||||||
|
nostr.azzamo.net,52.2633,21.0283
|
||||||
|
relay.fountain.fm,39.0997,-94.5786
|
||||||
relay.holzeis.me,43.6532,-79.3832
|
relay.holzeis.me,43.6532,-79.3832
|
||||||
|
relay.sincensura.org,43.6532,-79.3832
|
||||||
|
nostr-dev.wellorder.net,45.5201,-122.99
|
||||||
|
relay.hodl.ar,-32.94,-60.6745
|
||||||
|
nostr-relay.psfoundation.info,39.0438,-77.4874
|
||||||
|
relay.goodmorningbitcoin.com,43.6532,-79.3832
|
||||||
|
nostr.rtvslawenia.com,49.4543,11.0746
|
||||||
|
relay.13room.space,43.6532,-79.3832
|
||||||
|
nostr-02.czas.top,53.471,9.88208
|
||||||
|
relay.chorus.community,50.1109,8.68213
|
||||||
|
shu04.shugur.net,25.2604,55.2989
|
||||||
|
satsage.xyz,37.3986,-121.964
|
||||||
|
relay.bullishbounty.com,43.6532,-79.3832
|
||||||
|
nostr.sagaciousd.com,49.2827,-123.121
|
||||||
|
relay.nostromo.social,49.4543,11.0746
|
||||||
|
relay.magiccity.live,25.8128,-80.2377
|
||||||
relay-dev.satlantis.io,40.8302,-74.1299
|
relay-dev.satlantis.io,40.8302,-74.1299
|
||||||
|
nostr.coincrowd.fund,39.0438,-77.4874
|
||||||
|
shu05.shugur.net,48.8566,2.35222
|
||||||
|
nostrelay.memory-art.xyz,43.6532,-79.3832
|
||||||
|
nostr.now,36.55,139.733
|
||||||
|
nostr-pub.wellorder.net,45.5201,-122.99
|
||||||
|
nos.lol,50.4754,12.3683
|
||||||
|
wot.dergigi.com,64.1476,-21.9392
|
||||||
|
inbox.azzamo.net,52.2633,21.0283
|
||||||
|
nostr.mikoshi.de,50.1109,8.68213
|
||||||
|
librerelay.aaroniumii.com,43.6532,-79.3832
|
||||||
|
relay.nosto.re,51.8933,4.42083
|
||||||
|
relay.lumina.rocks,49.0291,8.35695
|
||||||
|
kitchen.zap.cooking,43.6532,-79.3832
|
||||||
|
zap.watch,45.5029,-73.5723
|
||||||
|
nostr.spacecitynode.com,29.7057,-95.2706
|
||||||
|
nostr.bilthon.dev,25.8128,-80.2377
|
||||||
|
relay.aloftus.io,34.0881,-118.379
|
||||||
|
shu02.shugur.net,21.4902,39.2246
|
||||||
|
relay.mwaters.net,50.9871,2.12554
|
||||||
|
relay.moinsen.com,50.4754,12.3683
|
||||||
|
nostr.kungfu-g.rip,33.7946,-84.4488
|
||||||
|
nostr.sathoarder.com,48.5734,7.75211
|
||||||
|
nostream.breadslice.com,43.6532,-79.3832
|
||||||
|
slick.mjex.me,39.048,-77.4817
|
||||||
|
nostr.jerrynya.fun,31.2304,121.474
|
||||||
|
articles.layer3.news,37.3387,-121.885
|
||||||
|
nostr.satstralia.com,64.1476,-21.9392
|
||||||
|
relay.degmods.com,50.4754,12.3683
|
||||||
|
relay.0xchat.com,1.35208,103.82
|
||||||
|
relay.nostx.io,43.6532,-79.3832
|
||||||
|
relay.siamdev.cc,13.9178,100.424
|
||||||
|
relay.mattybs.lol,43.6532,-79.3832
|
||||||
|
relay.bitcoinveneto.org,64.1466,-21.9426
|
||||||
|
nostr.tadryanom.me,43.6532,-79.3832
|
||||||
|
nostr.spaceshell.xyz,43.6532,-79.3832
|
||||||
|
relay.nostr.band,60.1699,24.9384
|
||||||
|
relay03.lnfi.network,39.0997,-94.5786
|
||||||
|
schnorr.me,43.6532,-79.3832
|
||||||
|
khatru.nostrver.se,51.8933,4.42083
|
||||||
|
dealtime-footwear-angry-records.trycloudflare.com,43.6532,-79.3832
|
||||||
|
relay.endfiat.money,43.6532,-79.3832
|
||||||
|
internationalright-wing.org,-22.5022,-48.7114
|
||||||
|
nostr.mom,50.4754,12.3683
|
||||||
|
nostrue.com,40.8054,-74.0241
|
||||||
|
relay.chakany.systems,43.6532,-79.3832
|
||||||
|
vidono.apps.slidestr.net,48.8566,2.35222
|
||||||
|
relay.stream.labs.h3.se,59.4016,17.9455
|
||||||
|
gnostr.com,40.9017,29.1616
|
||||||
|
relay.origin.land,35.6673,139.751
|
||||||
|
relay.sigit.io,50.4754,12.3683
|
||||||
|
theoutpost.life,64.1476,-21.9392
|
||||||
|
adre.su,59.9311,30.3609
|
||||||
|
soloco.nl,43.6532,-79.3832
|
||||||
|
wot.soundhsa.com,34.0479,-118.256
|
||||||
|
nos.xmark.cc,50.6924,3.20113
|
||||||
|
nostr-relay.zimage.com,34.282,-118.439
|
||||||
|
nostr.pleb.one,38.6327,-90.1961
|
||||||
|
purpura.cloud,43.6532,-79.3832
|
||||||
|
ribo.us.nostria.app,41.5868,-93.625
|
||||||
|
relay.digitalezukunft.cyou,45.5019,-73.5674
|
||||||
|
relay.mess.ch,47.3591,8.55292
|
||||||
|
relay.javi.space,43.4633,11.8796
|
||||||
|
nostr.jfischer.org,49.0291,8.35696
|
||||||
|
nostr.rblb.it,43.4633,11.8796
|
||||||
|
relay.nostriot.com,41.5695,-83.9786
|
||||||
|
nostr.21crypto.ch,47.4988,8.72369
|
||||||
|
relay.nostr.vet,52.6467,4.7395
|
||||||
|
fanfares.nostr1.com,40.7057,-74.0136
|
||||||
|
relay.copylaradio.com,51.223,6.78245
|
||||||
|
relay.mostro.network,40.8302,-74.1299
|
||||||
|
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
|
||||||
|
relay.lifpay.me,1.35208,103.82
|
||||||
|
nostr.tac.lol,47.4748,-122.273
|
||||||
|
relay.nostraddress.com,43.6532,-79.3832
|
||||||
|
wot.sudocarlos.com,51.5072,-0.127586
|
||||||
|
nostr.spicyz.io,43.6532,-79.3832
|
||||||
|
relay02.lnfi.network,39.0997,-94.5786
|
||||||
|
nostr.4rs.nl,49.0291,8.35696
|
||||||
|
relay.21e6.cz,50.1682,14.0546
|
||||||
|
nostr.casa21.space,43.6532,-79.3832
|
||||||
|
wot.codingarena.top,50.4754,12.3683
|
||||||
|
nostr-01.yakihonne.com,1.32123,103.695
|
||||||
|
strfry.openhoofd.nl,51.9229,4.40833
|
||||||
|
nostr.plantroon.com,50.1013,8.62643
|
||||||
|
dev-nostr.bityacht.io,25.0797,121.234
|
||||||
|
ribo.eu.nostria.app,52.3676,4.90414
|
||||||
|
wot.nostr.place,30.2672,-97.7431
|
||||||
|
relay.dwadziesciajeden.pl,52.2297,21.0122
|
||||||
|
prl.plus,55.7623,37.6381
|
||||||
|
relay.bitcoinartclock.com,50.4754,12.3683
|
||||||
|
nostr.2b9t.xyz,34.0549,-118.243
|
||||||
|
a.nos.lol,50.4754,12.3683
|
||||||
|
relay.wavlake.com,41.2619,-95.8608
|
||||||
|
relay.toastr.net,40.8054,-74.0241
|
||||||
|
wheat.happytavern.co,43.6532,-79.3832
|
||||||
|
relay.lnfi.network,39.0438,-77.4874
|
||||||
|
nostr.liberty.fans,36.9104,-89.5875
|
||||||
|
vitor.nostr1.com,40.7057,-74.0136
|
||||||
|
nostr.thebiglake.org,32.71,-96.6745
|
||||||
|
relay.exit.pub,50.4754,12.3683
|
||||||
|
freelay.sovbit.host,64.1476,-21.9392
|
||||||
|
purplerelay.com,50.1109,8.68213
|
||||||
|
nostr.camalolo.com,24.1469,120.684
|
||||||
|
black.nostrcity.club,41.8781,-87.6298
|
||||||
|
relay.barine.co,43.6532,-79.3832
|
||||||
|
nostr.0x7e.xyz,47.4988,8.72369
|
||||||
|
relayone.soundhsa.com,34.0479,-118.256
|
||||||
|
relay.vrtmrz.net,43.6532,-79.3832
|
||||||
|
nostr-03.dorafactory.org,1.35208,103.82
|
||||||
|
r.bitcoinhold.net,43.6532,-79.3832
|
||||||
|
relay.agorist.space,52.3734,4.89406
|
||||||
|
nostr-relay.amethyst.name,39.0067,-77.4291
|
||||||
|
relay-rpi.edufeed.org,49.4543,11.0746
|
||||||
|
orangesync.tech,50.1109,8.68213
|
||||||
|
relay04.lnfi.network,39.0997,-94.5786
|
||||||
|
nostr.hifish.org,47.4043,8.57398
|
||||||
|
relay.nostr.wirednet.jp,34.706,135.493
|
||||||
|
nostr.girino.org,43.6532,-79.3832
|
||||||
|
strfry.felixzieger.de,50.1013,8.62643
|
||||||
|
nostr.night7.space,50.4754,12.3683
|
||||||
|
nostr.oxtr.dev,50.4754,12.3683
|
||||||
|
relay.nostrhub.fr,48.1046,11.6002
|
||||||
|
nostr.rikmeijer.nl,50.4754,12.3683
|
||||||
|
wot.basspistol.org,49.4521,11.0767
|
||||||
|
nostr.coincards.com,53.5501,-113.469
|
||||||
|
solo.itsalldance.space,32.71,-96.6745
|
||||||
|
relay.puresignal.news,43.6532,-79.3832
|
||||||
|
nostrelay.circum.space,51.2217,6.77616
|
||||||
|
relay.btcforplebs.com,43.6532,-79.3832
|
||||||
|
nostr.diakod.com,43.6532,-79.3832
|
||||||
|
tollbooth.stens.dev,51.223,6.78245
|
||||||
|
relay.usefusion.ai,39.0438,-77.4874
|
||||||
|
relay-admin.thaliyal.com,40.8218,-74.45
|
||||||
|
nproxy.kristapsk.lv,60.1699,24.9384
|
||||||
|
nostr-verified.wellorder.net,45.5201,-122.99
|
||||||
|
wot.brightbolt.net,47.6735,-116.781
|
||||||
|
relay.orangepill.ovh,49.1689,-0.358841
|
||||||
|
relay.olas.app,50.4754,12.3683
|
||||||
|
offchain.pub,36.1809,-115.241
|
||||||
|
nostr.l484.com,30.2944,-97.6223
|
||||||
|
relay.nostrhub.tech,49.4543,11.0746
|
||||||
|
relay.tapestry.ninja,40.8054,-74.0241
|
||||||
|
nostr.snowbla.de,60.1699,24.9384
|
||||||
|
relay.letsfo.com,51.098,17.0321
|
||||||
|
relay.tagayasu.xyz,43.6715,-79.38
|
||||||
|
strfry.bonsai.com,37.8715,-122.273
|
||||||
|
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
|
||||||
|
nostr.smut.cloud,43.6532,-79.3832
|
||||||
|
x.kojira.io,43.6532,-79.3832
|
||||||
|
yabu.me,35.6092,139.73
|
||||||
|
relay.zone667.com,60.1699,24.9384
|
||||||
|
relay.bitcoindistrict.org,43.6532,-79.3832
|
||||||
|
pyramid.fiatjaf.com,50.1109,8.68213
|
||||||
|
nostr.hekster.org,37.3986,-121.964
|
||||||
|
relay.laantungir.net,-19.4692,-42.5315
|
||||||
|
relay.cosmicbolt.net,37.3986,-121.964
|
||||||
|
strfry.shock.network,41.8959,-88.2169
|
||||||
|
relay.coinos.io,43.6532,-79.3832
|
||||||
|
wot.nostr.net,43.6532,-79.3832
|
||||||
|
nostr.einundzwanzig.space,50.1109,8.68213
|
||||||
|
premium.primal.net,43.6532,-79.3832
|
||||||
|
dev-relay.lnfi.network,39.0997,-94.5786
|
||||||
|
ynostr.yael.at,60.1699,24.9384
|
||||||
|
relay.fr13nd5.com,52.5233,13.3426
|
||||||
|
nostr.lojong.info,43.6532,-79.3832
|
||||||
|
srtrelay.c-stellar.net,43.6532,-79.3832
|
||||||
|
nostr.myshosholoza.co.za,52.3676,4.90414
|
||||||
|
alien.macneilmediagroup.com,43.6532,-79.3832
|
||||||
|
relay.utxo.farm,35.6916,139.768
|
||||||
|
relay.ru.ac.th,13.7584,100.622
|
||||||
|
relay2.angor.io,48.1046,11.6002
|
||||||
|
relay.seq1.net,43.6532,-79.3832
|
||||||
|
relay.illuminodes.com,47.6061,-122.333
|
||||||
|
relay.satlantis.io,32.8769,-80.0114
|
||||||
|
relay.primal.net,43.6532,-79.3832
|
||||||
|
nostr.vulpem.com,49.4543,11.0746
|
||||||
|
relay.evanverma.com,40.8302,-74.1299
|
||||||
|
nostr.chaima.info,51.223,6.78245
|
||||||
|
relay.notoshi.win,13.7829,100.546
|
||||||
|
nostr.carroarmato0.be,50.9928,3.26317
|
||||||
|
relay.ditto.pub,43.6532,-79.3832
|
||||||
|
relay.credenso.cafe,43.1149,-80.7228
|
||||||
|
relay.nostr.place,32.7767,-96.797
|
||||||
|
relay.conduit.market,43.6532,-79.3832
|
||||||
|
nostr.huszonegy.world,47.4979,19.0402
|
||||||
|
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
|
||||||
|
wot.nostr.party,36.1627,-86.7816
|
||||||
|
wot.dtonon.com,43.6532,-79.3832
|
||||||
|
nostr.middling.mydns.jp,35.8099,140.12
|
||||||
|
nostr-2.21crypto.ch,47.4988,8.72369
|
||||||
|
nostr.dlsouza.lol,50.1109,8.68213
|
||||||
|
|||||||
|
@@ -9,7 +9,8 @@ import java.util.*
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Bridging Noise and Nostr favorites
|
* Bridging Noise and Nostr favorites
|
||||||
* Direct port from iOS FavoritesPersistenceService.swift
|
* Direct port from iOS FavoritesPersistenceService.swift, with Android-specific
|
||||||
|
* peerID (16-hex) -> npub indexing for Nostr DM routing.
|
||||||
*/
|
*/
|
||||||
data class FavoriteRelationship(
|
data class FavoriteRelationship(
|
||||||
val peerNoisePublicKey: ByteArray, // Noise static public key (32 bytes)
|
val peerNoisePublicKey: ByteArray, // Noise static public key (32 bytes)
|
||||||
@@ -54,13 +55,14 @@ interface FavoritesChangeListener {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages favorites with Noise↔Nostr mapping
|
* Manages favorites with Noise↔Nostr mapping
|
||||||
* Singleton pattern matching iOS implementation
|
* Singleton pattern matching iOS implementation.
|
||||||
*/
|
*/
|
||||||
class FavoritesPersistenceService private constructor(private val context: Context) {
|
class FavoritesPersistenceService private constructor(private val context: Context) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "FavoritesPersistenceService"
|
private const val TAG = "FavoritesPersistenceService"
|
||||||
private const val FAVORITES_KEY = "favorite_relationships"
|
private const val FAVORITES_KEY = "favorite_relationships" // noiseHex -> relationship
|
||||||
|
private const val PEERID_INDEX_KEY = "favorite_peerid_index" // peerID(16-hex) -> npub
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var INSTANCE: FavoritesPersistenceService? = null
|
private var INSTANCE: FavoritesPersistenceService? = null
|
||||||
@@ -81,39 +83,33 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
|||||||
|
|
||||||
private val stateManager = SecureIdentityStateManager(context)
|
private val stateManager = SecureIdentityStateManager(context)
|
||||||
private val gson = Gson()
|
private val gson = Gson()
|
||||||
private val favorites = mutableMapOf<String, FavoriteRelationship>() // noiseKeyHex -> relationship
|
private val favorites = mutableMapOf<String, FavoriteRelationship>() // noiseHex -> relationship
|
||||||
|
// NEW: Index by current mesh peerID (16-hex) for direct lookup when sending Nostr DMs from mesh context
|
||||||
|
private val peerIdIndex = mutableMapOf<String, String>() // peerID (lowercase 16-hex) -> npub
|
||||||
private val listeners = mutableListOf<FavoritesChangeListener>()
|
private val listeners = mutableListOf<FavoritesChangeListener>()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadFavorites()
|
loadFavorites()
|
||||||
|
loadPeerIdIndex()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Get favorite status for Noise public key */
|
||||||
* Get favorite status for Noise public key
|
|
||||||
*/
|
|
||||||
fun getFavoriteStatus(noisePublicKey: ByteArray): FavoriteRelationship? {
|
fun getFavoriteStatus(noisePublicKey: ByteArray): FavoriteRelationship? {
|
||||||
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
|
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
|
||||||
return favorites[keyHex]
|
return favorites[keyHex]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Get favorite status for 16-hex peerID (by noiseHex prefix match) */
|
||||||
* Get favorite status for 16-hex peerID
|
|
||||||
*/
|
|
||||||
fun getFavoriteStatus(peerID: String): FavoriteRelationship? {
|
fun getFavoriteStatus(peerID: String): FavoriteRelationship? {
|
||||||
// For 16-hex peerIDs, we need to find the corresponding full Noise key
|
val pid = peerID.lowercase()
|
||||||
// This is a simplified lookup - in practice you'd use fingerprint matching
|
|
||||||
for ((_, relationship) in favorites) {
|
for ((_, relationship) in favorites) {
|
||||||
val noiseKeyHex = relationship.peerNoisePublicKey.joinToString("") { "%02x".format(it) }
|
val noiseKeyHex = relationship.peerNoisePublicKey.joinToString("") { "%02x".format(it) }
|
||||||
if (noiseKeyHex.startsWith(peerID)) {
|
if (noiseKeyHex.startsWith(pid)) return relationship
|
||||||
return relationship
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Update Nostr public key for a peer (indexed by Noise key) */
|
||||||
* Update Nostr public key for a peer
|
|
||||||
*/
|
|
||||||
fun updateNostrPublicKey(noisePublicKey: ByteArray, nostrPubkey: String) {
|
fun updateNostrPublicKey(noisePublicKey: ByteArray, nostrPubkey: String) {
|
||||||
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
|
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
|
||||||
val existing = favorites[keyHex]
|
val existing = favorites[keyHex]
|
||||||
@@ -125,7 +121,6 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
|||||||
)
|
)
|
||||||
favorites[keyHex] = updated
|
favorites[keyHex] = updated
|
||||||
} else {
|
} else {
|
||||||
// Create new relationship
|
|
||||||
val relationship = FavoriteRelationship(
|
val relationship = FavoriteRelationship(
|
||||||
peerNoisePublicKey = noisePublicKey,
|
peerNoisePublicKey = noisePublicKey,
|
||||||
peerNostrPublicKey = nostrPubkey,
|
peerNostrPublicKey = nostrPubkey,
|
||||||
@@ -143,11 +138,46 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
|||||||
Log.d(TAG, "Updated Nostr pubkey association for ${keyHex.take(16)}...")
|
Log.d(TAG, "Updated Nostr pubkey association for ${keyHex.take(16)}...")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** NEW: Update Nostr pubkey for specific mesh peerID (16-hex). */
|
||||||
* Update favorite status
|
fun updateNostrPublicKeyForPeerID(peerID: String, nostrPubkey: String) {
|
||||||
*/
|
val pid = peerID.lowercase()
|
||||||
|
if (pid.length == 16 && pid.matches(Regex("^[0-9a-f]+$"))) {
|
||||||
|
peerIdIndex[pid] = nostrPubkey
|
||||||
|
savePeerIdIndex()
|
||||||
|
Log.d(TAG, "Indexed npub for peerID ${pid.take(8)}…")
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "updateNostrPublicKeyForPeerID called with non-16hex peerID: $peerID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** NEW: Resolve Nostr pubkey via current peerID mapping (fast path). */
|
||||||
|
fun findNostrPubkeyForPeerID(peerID: String): String? {
|
||||||
|
return peerIdIndex[peerID.lowercase()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** NEW: Resolve peerID (16-hex) for a given Nostr pubkey (npub or hex). */
|
||||||
|
fun findPeerIDForNostrPubkey(nostrPubkey: String): String? {
|
||||||
|
// First, try direct match in peerIdIndex (values are stored as npub strings)
|
||||||
|
peerIdIndex.entries.firstOrNull { it.value.equals(nostrPubkey, ignoreCase = true) }?.let { return it.key }
|
||||||
|
|
||||||
|
// Attempt legacy mapping via favorites Noise key association
|
||||||
|
val targetHex = normalizeNostrKeyToHex(nostrPubkey)
|
||||||
|
if (targetHex != null) {
|
||||||
|
// Find relationship with matching nostr pubkey (normalized to hex) and then try to map to current peerID via noise key prefix
|
||||||
|
val rel = favorites.values.firstOrNull { it.peerNostrPublicKey?.let { stored -> normalizeNostrKeyToHex(stored) } == targetHex }
|
||||||
|
if (rel != null) {
|
||||||
|
val noiseHex = rel.peerNoisePublicKey.joinToString("") { "%02x".format(it) }
|
||||||
|
// Return 16-hex prefix as best-effort if no explicit mapping exists
|
||||||
|
return noiseHex.take(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update favorite status */
|
||||||
fun updateFavoriteStatus(noisePublicKey: ByteArray, nickname: String, isFavorite: Boolean) {
|
fun updateFavoriteStatus(noisePublicKey: ByteArray, nickname: String, isFavorite: Boolean) {
|
||||||
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
|
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
val existing = favorites[keyHex]
|
val existing = favorites[keyHex]
|
||||||
|
|
||||||
val updated = if (existing != null) {
|
val updated = if (existing != null) {
|
||||||
@@ -176,9 +206,7 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
|||||||
Log.d(TAG, "Updated favorite status for $nickname: $isFavorite")
|
Log.d(TAG, "Updated favorite status for $nickname: $isFavorite")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Update peer favorited-us flag */
|
||||||
* Update peer favorited us status
|
|
||||||
*/
|
|
||||||
fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) {
|
fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) {
|
||||||
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
|
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
|
||||||
val existing = favorites[keyHex]
|
val existing = favorites[keyHex]
|
||||||
@@ -196,55 +224,36 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
fun getMutualFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isMutual }
|
||||||
* Get all mutual favorites
|
fun getOurFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isFavorite }
|
||||||
*/
|
|
||||||
fun getMutualFavorites(): List<FavoriteRelationship> {
|
|
||||||
return favorites.values.filter { it.isMutual }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all favorites we have
|
|
||||||
*/
|
|
||||||
fun getOurFavorites(): List<FavoriteRelationship> {
|
|
||||||
return favorites.values.filter { it.isFavorite }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear all favorites
|
|
||||||
*/
|
|
||||||
fun clearAllFavorites() {
|
fun clearAllFavorites() {
|
||||||
favorites.clear()
|
favorites.clear()
|
||||||
saveFavorites()
|
saveFavorites()
|
||||||
|
peerIdIndex.clear()
|
||||||
|
savePeerIdIndex()
|
||||||
Log.i(TAG, "Cleared all favorites")
|
Log.i(TAG, "Cleared all favorites")
|
||||||
notifyAllCleared()
|
notifyAllCleared()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Find Noise key by Nostr pubkey */
|
||||||
* Find Noise key by Nostr pubkey
|
|
||||||
*/
|
|
||||||
fun findNoiseKey(forNostrPubkey: String): ByteArray? {
|
fun findNoiseKey(forNostrPubkey: String): ByteArray? {
|
||||||
val targetHex = normalizeNostrKeyToHex(forNostrPubkey) ?: return null
|
val targetHex = normalizeNostrKeyToHex(forNostrPubkey) ?: return null
|
||||||
return favorites.values.firstOrNull { rel ->
|
return favorites.values.firstOrNull { rel ->
|
||||||
rel.peerNostrPublicKey?.let { stored ->
|
rel.peerNostrPublicKey?.let { stored -> normalizeNostrKeyToHex(stored) } == targetHex
|
||||||
normalizeNostrKeyToHex(stored)
|
|
||||||
} == targetHex
|
|
||||||
}?.peerNoisePublicKey
|
}?.peerNoisePublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Find Nostr pubkey by Noise key */
|
||||||
* Find Nostr pubkey by Noise key
|
|
||||||
*/
|
|
||||||
fun findNostrPubkey(forNoiseKey: ByteArray): String? {
|
fun findNostrPubkey(forNoiseKey: ByteArray): String? {
|
||||||
val keyHex = forNoiseKey.joinToString("") { "%02x".format(it) }
|
val keyHex = forNoiseKey.joinToString("") { "%02x".format(it) }
|
||||||
return favorites[keyHex]?.peerNostrPublicKey
|
return favorites[keyHex]?.peerNostrPublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Persistence
|
||||||
|
|
||||||
private fun loadFavorites() {
|
private fun loadFavorites() {
|
||||||
try {
|
try {
|
||||||
// Use public methods instead of reflection to access encrypted preferences
|
|
||||||
val favoritesJson = stateManager.getSecureValue(FAVORITES_KEY)
|
val favoritesJson = stateManager.getSecureValue(FAVORITES_KEY)
|
||||||
if (favoritesJson != null) {
|
if (favoritesJson != null) {
|
||||||
val type = object : TypeToken<Map<String, FavoriteRelationshipData>>() {}.type
|
val type = object : TypeToken<Map<String, FavoriteRelationshipData>>() {}.type
|
||||||
@@ -252,10 +261,8 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
|||||||
|
|
||||||
favorites.clear()
|
favorites.clear()
|
||||||
data.forEach { (key, relationshipData) ->
|
data.forEach { (key, relationshipData) ->
|
||||||
val relationship = relationshipData.toFavoriteRelationship()
|
favorites[key] = relationshipData.toFavoriteRelationship()
|
||||||
favorites[key] = relationship
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(TAG, "Loaded ${favorites.size} favorite relationships")
|
Log.d(TAG, "Loaded ${favorites.size} favorite relationships")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -265,27 +272,45 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
|||||||
|
|
||||||
private fun saveFavorites() {
|
private fun saveFavorites() {
|
||||||
try {
|
try {
|
||||||
// Convert to serializable format
|
|
||||||
val data = favorites.mapValues { (_, relationship) ->
|
val data = favorites.mapValues { (_, relationship) ->
|
||||||
FavoriteRelationshipData.fromFavoriteRelationship(relationship)
|
FavoriteRelationshipData.fromFavoriteRelationship(relationship)
|
||||||
}
|
}
|
||||||
|
|
||||||
val favoritesJson = gson.toJson(data)
|
val favoritesJson = gson.toJson(data)
|
||||||
|
|
||||||
// Use public methods instead of reflection to access encrypted preferences
|
|
||||||
stateManager.storeSecureValue(FAVORITES_KEY, favoritesJson)
|
stateManager.storeSecureValue(FAVORITES_KEY, favoritesJson)
|
||||||
|
|
||||||
Log.d(TAG, "Saved ${favorites.size} favorite relationships")
|
Log.d(TAG, "Saved ${favorites.size} favorite relationships")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to save favorites: ${e.message}")
|
Log.e(TAG, "Failed to save favorites: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun loadPeerIdIndex() {
|
||||||
|
try {
|
||||||
|
val json = stateManager.getSecureValue(PEERID_INDEX_KEY)
|
||||||
|
if (json != null) {
|
||||||
|
val type = object : TypeToken<Map<String, String>>() {}.type
|
||||||
|
val data: Map<String, String> = gson.fromJson(json, type)
|
||||||
|
peerIdIndex.clear()
|
||||||
|
peerIdIndex.putAll(data)
|
||||||
|
Log.d(TAG, "Loaded ${peerIdIndex.size} peerID→npub mappings")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to load peerID index: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun savePeerIdIndex() {
|
||||||
|
try {
|
||||||
|
val json = gson.toJson(peerIdIndex)
|
||||||
|
stateManager.storeSecureValue(PEERID_INDEX_KEY, json)
|
||||||
|
Log.d(TAG, "Saved ${peerIdIndex.size} peerID→npub mappings")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to save peerID index: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Listeners
|
// MARK: - Listeners
|
||||||
fun addListener(listener: FavoritesChangeListener) {
|
fun addListener(listener: FavoritesChangeListener) {
|
||||||
synchronized(listeners) {
|
synchronized(listeners) { if (!listeners.contains(listener)) listeners.add(listener) }
|
||||||
if (!listeners.contains(listener)) listeners.add(listener)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fun removeListener(listener: FavoritesChangeListener) {
|
fun removeListener(listener: FavoritesChangeListener) {
|
||||||
synchronized(listeners) { listeners.remove(listener) }
|
synchronized(listeners) { listeners.remove(listener) }
|
||||||
@@ -299,26 +324,16 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
|||||||
snapshot.forEach { runCatching { it.onAllCleared() } }
|
snapshot.forEach { runCatching { it.onAllCleared() } }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Normalize a Nostr public key string (npub bech32 or hex) to lowercase hex */
|
||||||
* Normalize a Nostr public key string (npub bech32 or hex) to lowercase hex for comparison
|
private fun normalizeNostrKeyToHex(value: String): String? = try {
|
||||||
*/
|
if (value.startsWith("npub1")) {
|
||||||
private fun normalizeNostrKeyToHex(value: String): String? {
|
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(value)
|
||||||
return try {
|
if (hrp != "npub") null else data.joinToString("") { "%02x".format(it) }
|
||||||
if (value.startsWith("npub1")) {
|
} else value.lowercase()
|
||||||
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(value)
|
} catch (_: Exception) { null }
|
||||||
if (hrp != "npub") return null
|
|
||||||
data.joinToString("") { "%02x".format(it) }
|
|
||||||
} else {
|
|
||||||
// Assume hex
|
|
||||||
value.lowercase()
|
|
||||||
}
|
|
||||||
} catch (_: Exception) { null }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Serializable data for JSON storage */
|
||||||
* Serializable data class for JSON storage
|
|
||||||
*/
|
|
||||||
private data class FavoriteRelationshipData(
|
private data class FavoriteRelationshipData(
|
||||||
val peerNoisePublicKeyHex: String,
|
val peerNoisePublicKeyHex: String,
|
||||||
val peerNostrPublicKey: String?,
|
val peerNostrPublicKey: String?,
|
||||||
|
|||||||
@@ -285,6 +285,13 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
// Store fingerprint for the peer via centralized fingerprint manager
|
// Store fingerprint for the peer via centralized fingerprint manager
|
||||||
val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey)
|
val fingerprint = peerManager.storeFingerprintForPeer(newPeerID, publicKey)
|
||||||
|
|
||||||
|
// Index existing Nostr mapping by the new peerID if we have it
|
||||||
|
try {
|
||||||
|
com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(publicKey)?.let { npub ->
|
||||||
|
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(newPeerID, npub)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
|
||||||
// If there was a previous peer ID, remove it to avoid duplicates
|
// If there was a previous peer ID, remove it to avoid duplicates
|
||||||
previousPeerID?.let { oldPeerID ->
|
previousPeerID?.let { oldPeerID ->
|
||||||
peerManager.removePeer(oldPeerID)
|
peerManager.removePeer(oldPeerID)
|
||||||
@@ -543,25 +550,13 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
*/
|
*/
|
||||||
fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) {
|
fun sendPrivateMessage(content: String, recipientPeerID: String, recipientNickname: String, messageID: String? = null) {
|
||||||
if (content.isEmpty() || recipientPeerID.isEmpty()) return
|
if (content.isEmpty() || recipientPeerID.isEmpty()) return
|
||||||
if (!recipientPeerID.startsWith("nostr_") && recipientNickname.isEmpty()) return
|
if (recipientNickname.isEmpty()) return
|
||||||
|
|
||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString()
|
val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString()
|
||||||
|
|
||||||
Log.d(TAG, "📨 Sending PM to $recipientPeerID: ${content.take(30)}...")
|
Log.d(TAG, "📨 Sending PM to $recipientPeerID: ${content.take(30)}...")
|
||||||
|
|
||||||
// Check if this is a Nostr contact (geohash DM)
|
|
||||||
if (recipientPeerID.startsWith("nostr_")) {
|
|
||||||
// Get NostrGeohashService instance and send via Nostr
|
|
||||||
try {
|
|
||||||
val nostrGeohashService = com.bitchat.android.nostr.NostrGeohashService.getInstance(context.applicationContext as android.app.Application)
|
|
||||||
nostrGeohashService.sendNostrGeohashDM(content, recipientPeerID, finalMessageID, myPeerID)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to send Nostr geohash DM: ${e.message}")
|
|
||||||
}
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we have an established Noise session
|
// Check if we have an established Noise session
|
||||||
if (encryptionService.hasEstablishedSession(recipientPeerID)) {
|
if (encryptionService.hasEstablishedSession(recipientPeerID)) {
|
||||||
try {
|
try {
|
||||||
@@ -629,15 +624,14 @@ class BluetoothMeshService(private val context: Context) {
|
|||||||
serviceScope.launch {
|
serviceScope.launch {
|
||||||
Log.d(TAG, "📖 Sending read receipt for message $messageID to $recipientPeerID")
|
Log.d(TAG, "📖 Sending read receipt for message $messageID to $recipientPeerID")
|
||||||
|
|
||||||
// Check if this is a Nostr contact (geohash DM)
|
// Route geohash read receipts via MessageRouter instead of here
|
||||||
if (recipientPeerID.startsWith("nostr_")) {
|
val geo = runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance() }.getOrNull()
|
||||||
// Get NostrGeohashService instance and send read receipt via Nostr
|
val isGeoAlias = try {
|
||||||
try {
|
val map = com.bitchat.android.nostr.GeohashAliasRegistry.snapshot()
|
||||||
val nostrGeohashService = com.bitchat.android.nostr.NostrGeohashService.getInstance(context.applicationContext as android.app.Application)
|
map.containsKey(recipientPeerID)
|
||||||
nostrGeohashService.sendNostrGeohashReadReceipt(messageID, recipientPeerID, myPeerID)
|
} catch (_: Exception) { false }
|
||||||
} catch (e: Exception) {
|
if (isGeoAlias && geo != null) {
|
||||||
Log.e(TAG, "Failed to send Nostr geohash read receipt: ${e.message}")
|
geo.sendReadReceipt(com.bitchat.android.model.ReadReceipt(messageID), recipientPeerID)
|
||||||
}
|
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -459,7 +459,9 @@ class MessageHandler(private val myPeerID: String) {
|
|||||||
if (noiseKey != null) {
|
if (noiseKey != null) {
|
||||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, isFavorite)
|
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, isFavorite)
|
||||||
if (npub != null) {
|
if (npub != null) {
|
||||||
|
// Index by noise key and current mesh peerID for fast Nostr routing
|
||||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, npub)
|
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKey(noiseKey, npub)
|
||||||
|
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateNostrPublicKeyForPeerID(fromPeerID, npub)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine iOS-style guidance text
|
// Determine iOS-style guidance text
|
||||||
|
|||||||
@@ -344,7 +344,8 @@ object TorManager {
|
|||||||
bindRetryAttempts = 0
|
bindRetryAttempts = 0
|
||||||
startInactivityMonitoring()
|
startInactivityMonitoring()
|
||||||
}
|
}
|
||||||
s.contains("AMEx: state changed to Running", ignoreCase = true) -> {
|
//s.contains("AMEx: state changed to Running", ignoreCase = true) -> {
|
||||||
|
s.contains("We have found that guard [scrubbed] is usable.", ignoreCase = true) -> {
|
||||||
// If we already saw Sufficiently bootstrapped, mark as RUNNING and ready.
|
// If we already saw Sufficiently bootstrapped, mark as RUNNING and ready.
|
||||||
val bp = if (_status.value.bootstrapPercent >= 100) 100 else 100 // treat Running as ready
|
val bp = if (_status.value.bootstrapPercent >= 100) 100 else 100 // treat Running as ready
|
||||||
_status.value = _status.value.copy(state = TorState.RUNNING, bootstrapPercent = bp, running = true)
|
_status.value = _status.value.copy(state = TorState.RUNNING, bootstrapPercent = bp, running = true)
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package com.bitchat.android.nostr
|
||||||
|
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GeohashAliasRegistry
|
||||||
|
* - Global, thread-safe registry for alias->Nostr pubkey mappings (e.g., nostr_<pub16> -> pubkeyHex)
|
||||||
|
* - Allows non-UI components (e.g., MessageRouter) to resolve geohash DM aliases without depending on UI ViewModels
|
||||||
|
*/
|
||||||
|
object GeohashAliasRegistry {
|
||||||
|
private val map: MutableMap<String, String> = ConcurrentHashMap()
|
||||||
|
|
||||||
|
fun put(alias: String, pubkeyHex: String) {
|
||||||
|
map[alias] = pubkeyHex
|
||||||
|
}
|
||||||
|
|
||||||
|
fun get(alias: String): String? = map[alias]
|
||||||
|
|
||||||
|
fun contains(alias: String): Boolean = map.containsKey(alias)
|
||||||
|
|
||||||
|
fun snapshot(): Map<String, String> = HashMap(map)
|
||||||
|
|
||||||
|
fun clear() { map.clear() }
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package com.bitchat.android.nostr
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.util.Log
|
||||||
|
import com.bitchat.android.model.BitchatMessage
|
||||||
|
import com.bitchat.android.ui.ChatState
|
||||||
|
import com.bitchat.android.ui.MessageManager
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GeohashMessageHandler
|
||||||
|
* - Processes kind=20000 Nostr events for geohash channels
|
||||||
|
* - Updates repository for participants + nicknames
|
||||||
|
* - Emits messages to MessageManager
|
||||||
|
*/
|
||||||
|
class GeohashMessageHandler(
|
||||||
|
private val application: Application,
|
||||||
|
private val state: ChatState,
|
||||||
|
private val messageManager: MessageManager,
|
||||||
|
private val repo: GeohashRepository,
|
||||||
|
private val scope: CoroutineScope
|
||||||
|
) {
|
||||||
|
companion object { private const val TAG = "GeohashMessageHandler" }
|
||||||
|
|
||||||
|
// Simple event deduplication
|
||||||
|
private val processedIds = ArrayDeque<String>()
|
||||||
|
private val seen = HashSet<String>()
|
||||||
|
private val max = 2000
|
||||||
|
|
||||||
|
private fun dedupe(id: String): Boolean {
|
||||||
|
if (seen.contains(id)) return true
|
||||||
|
seen.add(id)
|
||||||
|
processedIds.addLast(id)
|
||||||
|
if (processedIds.size > max) {
|
||||||
|
val old = processedIds.removeFirst()
|
||||||
|
seen.remove(old)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEvent(event: NostrEvent, subscribedGeohash: String) {
|
||||||
|
scope.launch(Dispatchers.Default) {
|
||||||
|
try {
|
||||||
|
if (event.kind != 20000) return@launch
|
||||||
|
val tagGeo = event.tags.firstOrNull { it.size >= 2 && it[0] == "g" }?.getOrNull(1)
|
||||||
|
if (tagGeo == null || !tagGeo.equals(subscribedGeohash, true)) return@launch
|
||||||
|
if (dedupe(event.id)) return@launch
|
||||||
|
|
||||||
|
// PoW validation (if enabled)
|
||||||
|
val pow = PoWPreferenceManager.getCurrentSettings()
|
||||||
|
if (pow.enabled && pow.difficulty > 0) {
|
||||||
|
if (!NostrProofOfWork.validateDifficulty(event, pow.difficulty)) return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blocked users check
|
||||||
|
if (com.bitchat.android.ui.DataManager(application).isGeohashUserBlocked(event.pubkey)) return@launch
|
||||||
|
|
||||||
|
// Update repository (participants, nickname, teleport)
|
||||||
|
// Update repository on a background-safe path; repository will post updates to LiveData
|
||||||
|
repo.updateParticipant(subscribedGeohash, event.pubkey, Date(event.createdAt * 1000L))
|
||||||
|
event.tags.find { it.size >= 2 && it[0] == "n" }?.let { repo.cacheNickname(event.pubkey, it[1]) }
|
||||||
|
event.tags.find { it.size >= 2 && it[0] == "t" && it[1] == "teleport" }?.let { repo.markTeleported(event.pubkey) }
|
||||||
|
// Register a geohash DM alias for this participant so MessageRouter can route DMs via Nostr
|
||||||
|
try {
|
||||||
|
com.bitchat.android.nostr.GeohashAliasRegistry.put("nostr_${event.pubkey.take(16)}", event.pubkey)
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
|
||||||
|
// Skip our own events for message emission
|
||||||
|
val my = NostrIdentityBridge.deriveIdentity(subscribedGeohash, application)
|
||||||
|
if (my.publicKeyHex.equals(event.pubkey, true)) return@launch
|
||||||
|
|
||||||
|
val isTeleportPresence = event.tags.any { it.size >= 2 && it[0] == "t" && it[1] == "teleport" } &&
|
||||||
|
event.content.trim().isEmpty()
|
||||||
|
if (isTeleportPresence) return@launch
|
||||||
|
|
||||||
|
val senderName = repo.displayNameForNostrPubkeyUI(event.pubkey)
|
||||||
|
val msg = BitchatMessage(
|
||||||
|
id = event.id,
|
||||||
|
sender = senderName,
|
||||||
|
content = event.content,
|
||||||
|
timestamp = Date(event.createdAt * 1000L),
|
||||||
|
isRelay = false,
|
||||||
|
originalSender = repo.displayNameForNostrPubkey(event.pubkey),
|
||||||
|
senderPeerID = "nostr:${event.pubkey.take(8)}",
|
||||||
|
mentions = null,
|
||||||
|
channel = "#$subscribedGeohash",
|
||||||
|
powDifficulty = try { NostrProofOfWork.calculateDifficulty(event.id).takeIf { it > 0 } } catch (_: Exception) { null }
|
||||||
|
)
|
||||||
|
withContext(Dispatchers.Main) { messageManager.addChannelMessage("geo:$subscribedGeohash", msg) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "onEvent error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package com.bitchat.android.nostr
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import com.bitchat.android.ui.ChatState
|
||||||
|
import com.bitchat.android.ui.GeoPerson
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GeohashRepository
|
||||||
|
* - Owns geohash participant tracking and nickname caching
|
||||||
|
* - Maintains lightweight state for geohash-related UI
|
||||||
|
*/
|
||||||
|
class GeohashRepository(
|
||||||
|
private val application: Application,
|
||||||
|
private val state: ChatState
|
||||||
|
) {
|
||||||
|
companion object { private const val TAG = "GeohashRepository" }
|
||||||
|
|
||||||
|
// geohash -> (participant pubkeyHex -> lastSeen)
|
||||||
|
private val geohashParticipants: MutableMap<String, MutableMap<String, Date>> = mutableMapOf()
|
||||||
|
|
||||||
|
|
||||||
|
// pubkeyHex(lowercase) -> nickname (without #hash)
|
||||||
|
private val geoNicknames: MutableMap<String, String> = mutableMapOf()
|
||||||
|
|
||||||
|
// conversation key (e.g., "nostr_<pub16>") -> source geohash it belongs to
|
||||||
|
private val conversationGeohash: MutableMap<String, String> = mutableMapOf()
|
||||||
|
|
||||||
|
fun setConversationGeohash(convKey: String, geohash: String) {
|
||||||
|
if (geohash.isNotEmpty()) {
|
||||||
|
conversationGeohash[convKey] = geohash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getConversationGeohash(convKey: String): String? = conversationGeohash[convKey]
|
||||||
|
|
||||||
|
fun findPubkeyByNickname(targetNickname: String): String? {
|
||||||
|
return geoNicknames.entries.firstOrNull { (_, nickname) ->
|
||||||
|
val base = nickname.split("#").firstOrNull() ?: nickname
|
||||||
|
base == targetNickname
|
||||||
|
}?.key
|
||||||
|
}
|
||||||
|
|
||||||
|
// peerID alias -> nostr pubkey mapping for geohash DMs and temp aliases
|
||||||
|
private val nostrKeyMapping: MutableMap<String, String> = mutableMapOf()
|
||||||
|
|
||||||
|
// Current geohash in view
|
||||||
|
private var currentGeohash: String? = null
|
||||||
|
|
||||||
|
fun setCurrentGeohash(geo: String?) { currentGeohash = geo }
|
||||||
|
fun getCurrentGeohash(): String? = currentGeohash
|
||||||
|
|
||||||
|
fun clearAll() {
|
||||||
|
geohashParticipants.clear()
|
||||||
|
geoNicknames.clear()
|
||||||
|
nostrKeyMapping.clear()
|
||||||
|
state.setGeohashPeople(emptyList())
|
||||||
|
state.setTeleportedGeo(emptySet())
|
||||||
|
state.setGeohashParticipantCounts(emptyMap())
|
||||||
|
currentGeohash = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cacheNickname(pubkeyHex: String, nickname: String) {
|
||||||
|
val lower = pubkeyHex.lowercase()
|
||||||
|
val previous = geoNicknames[lower]
|
||||||
|
geoNicknames[lower] = nickname
|
||||||
|
if (previous != nickname && currentGeohash != null) {
|
||||||
|
refreshGeohashPeople()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCachedNickname(pubkeyHex: String): String? = geoNicknames[pubkeyHex.lowercase()]
|
||||||
|
|
||||||
|
fun markTeleported(pubkeyHex: String) {
|
||||||
|
val set = state.getTeleportedGeoValue().toMutableSet()
|
||||||
|
val key = pubkeyHex.lowercase()
|
||||||
|
if (!set.contains(key)) {
|
||||||
|
set.add(key)
|
||||||
|
// Background safe update
|
||||||
|
state.postTeleportedGeo(set)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isPersonTeleported(pubkeyHex: String): Boolean {
|
||||||
|
return state.getTeleportedGeoValue().contains(pubkeyHex.lowercase())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateParticipant(geohash: String, participantId: String, lastSeen: Date) {
|
||||||
|
val participants = geohashParticipants.getOrPut(geohash) { mutableMapOf() }
|
||||||
|
participants[participantId] = lastSeen
|
||||||
|
if (currentGeohash == geohash) refreshGeohashPeople()
|
||||||
|
updateReactiveParticipantCounts()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun geohashParticipantCount(geohash: String): Int {
|
||||||
|
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
|
||||||
|
val participants = geohashParticipants[geohash] ?: return 0
|
||||||
|
// prune expired
|
||||||
|
val it = participants.iterator()
|
||||||
|
while (it.hasNext()) {
|
||||||
|
val e = it.next()
|
||||||
|
if (e.value.before(cutoff)) it.remove()
|
||||||
|
}
|
||||||
|
return participants.size
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refreshGeohashPeople() {
|
||||||
|
val geohash = currentGeohash
|
||||||
|
if (geohash == null) {
|
||||||
|
// Use postValue for thread safety - this can be called from background threads
|
||||||
|
state.postGeohashPeople(emptyList())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
|
||||||
|
val participants = geohashParticipants[geohash] ?: mutableMapOf()
|
||||||
|
// prune expired
|
||||||
|
val it = participants.iterator()
|
||||||
|
while (it.hasNext()) {
|
||||||
|
val e = it.next()
|
||||||
|
if (e.value.before(cutoff)) it.remove()
|
||||||
|
}
|
||||||
|
geohashParticipants[geohash] = participants
|
||||||
|
val people = participants.map { (pubkeyHex, lastSeen) ->
|
||||||
|
val base = getCachedNickname(pubkeyHex) ?: "anon"
|
||||||
|
GeoPerson(
|
||||||
|
id = pubkeyHex.lowercase(),
|
||||||
|
displayName = base, // UI can add #hash if necessary
|
||||||
|
lastSeen = lastSeen
|
||||||
|
)
|
||||||
|
}.sortedByDescending { it.lastSeen }
|
||||||
|
// Use postValue for thread safety - this can be called from background threads
|
||||||
|
state.postGeohashPeople(people)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateReactiveParticipantCounts() {
|
||||||
|
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
|
||||||
|
val counts = mutableMapOf<String, Int>()
|
||||||
|
for ((gh, participants) in geohashParticipants) {
|
||||||
|
val active = participants.values.count { !it.before(cutoff) }
|
||||||
|
counts[gh] = active
|
||||||
|
}
|
||||||
|
// Use postValue for thread safety - this can be called from background threads
|
||||||
|
state.postGeohashParticipantCounts(counts)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun putNostrKeyMapping(tempKeyOrPeer: String, pubkeyHex: String) {
|
||||||
|
nostrKeyMapping[tempKeyOrPeer] = pubkeyHex
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getNostrKeyMapping(): Map<String, String> = nostrKeyMapping.toMap()
|
||||||
|
|
||||||
|
fun displayNameForNostrPubkey(pubkeyHex: String): String {
|
||||||
|
val suffix = pubkeyHex.takeLast(4)
|
||||||
|
val lower = pubkeyHex.lowercase()
|
||||||
|
// Self nickname if matches current identity of current geohash
|
||||||
|
val current = currentGeohash
|
||||||
|
if (current != null) {
|
||||||
|
try {
|
||||||
|
val my = NostrIdentityBridge.deriveIdentity(current, application)
|
||||||
|
if (my.publicKeyHex.equals(lower, true)) {
|
||||||
|
return "${state.getNicknameValue()}#$suffix"
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
val nick = geoNicknames[lower] ?: "anon"
|
||||||
|
return "$nick#$suffix"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun displayNameForNostrPubkeyUI(pubkeyHex: String): String {
|
||||||
|
val lower = pubkeyHex.lowercase()
|
||||||
|
val suffix = pubkeyHex.takeLast(4)
|
||||||
|
val current = currentGeohash
|
||||||
|
val base: String = try {
|
||||||
|
if (current != null) {
|
||||||
|
val my = NostrIdentityBridge.deriveIdentity(current, application)
|
||||||
|
if (my.publicKeyHex.equals(lower, true)) {
|
||||||
|
state.getNicknameValue() ?: "anon"
|
||||||
|
} else geoNicknames[lower] ?: "anon"
|
||||||
|
} else geoNicknames[lower] ?: "anon"
|
||||||
|
} catch (_: Exception) { geoNicknames[lower] ?: "anon" }
|
||||||
|
if (current == null) return base
|
||||||
|
return try {
|
||||||
|
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
|
||||||
|
val participants = geohashParticipants[current] ?: emptyMap()
|
||||||
|
var count = 0
|
||||||
|
for ((k, t) in participants) {
|
||||||
|
if (t.before(cutoff)) continue
|
||||||
|
val name = if (k.equals(lower, true)) base else (geoNicknames[k.lowercase()] ?: "anon")
|
||||||
|
if (name.equals(base, true)) { count++; if (count > 1) break }
|
||||||
|
}
|
||||||
|
if (!participants.containsKey(lower)) count += 1
|
||||||
|
if (count > 1) "$base#$suffix" else base
|
||||||
|
} catch (_: Exception) { base }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get display name for any geohash (not just current one) for header titles
|
||||||
|
*/
|
||||||
|
fun displayNameForGeohashConversation(pubkeyHex: String, sourceGeohash: String): String {
|
||||||
|
val lower = pubkeyHex.lowercase()
|
||||||
|
val suffix = pubkeyHex.takeLast(4)
|
||||||
|
val base = geoNicknames[lower] ?: "anon"
|
||||||
|
return try {
|
||||||
|
val cutoff = Date(System.currentTimeMillis() - 5 * 60 * 1000)
|
||||||
|
val participants = geohashParticipants[sourceGeohash] ?: emptyMap()
|
||||||
|
var count = 0
|
||||||
|
for ((k, t) in participants) {
|
||||||
|
if (t.before(cutoff)) continue
|
||||||
|
val name = if (k.equals(lower, true)) base else (geoNicknames[k.lowercase()] ?: "anon")
|
||||||
|
if (name.equals(base, true)) { count++; if (count > 1) break }
|
||||||
|
}
|
||||||
|
if (!participants.containsKey(lower)) count += 1
|
||||||
|
if (count > 1) "$base#$suffix" else base
|
||||||
|
} catch (_: Exception) { base }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package com.bitchat.android.nostr
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.util.Log
|
||||||
|
import com.bitchat.android.model.BitchatMessage
|
||||||
|
import com.bitchat.android.model.DeliveryStatus
|
||||||
|
import com.bitchat.android.protocol.BitchatPacket
|
||||||
|
import com.bitchat.android.services.SeenMessageStore
|
||||||
|
import com.bitchat.android.ui.ChatState
|
||||||
|
import com.bitchat.android.ui.MeshDelegateHandler
|
||||||
|
import com.bitchat.android.ui.PrivateChatManager
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
class NostrDirectMessageHandler(
|
||||||
|
private val application: Application,
|
||||||
|
private val state: ChatState,
|
||||||
|
private val privateChatManager: PrivateChatManager,
|
||||||
|
private val meshDelegateHandler: MeshDelegateHandler,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val repo: GeohashRepository
|
||||||
|
) {
|
||||||
|
companion object { private const val TAG = "NostrDirectMessageHandler" }
|
||||||
|
|
||||||
|
private val seenStore by lazy { SeenMessageStore.getInstance(application) }
|
||||||
|
|
||||||
|
// Simple event deduplication
|
||||||
|
private val processedIds = ArrayDeque<String>()
|
||||||
|
private val seen = HashSet<String>()
|
||||||
|
private val max = 2000
|
||||||
|
|
||||||
|
private fun dedupe(id: String): Boolean {
|
||||||
|
if (seen.contains(id)) return true
|
||||||
|
seen.add(id)
|
||||||
|
processedIds.addLast(id)
|
||||||
|
if (processedIds.size > max) {
|
||||||
|
val old = processedIds.removeFirst()
|
||||||
|
seen.remove(old)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onGiftWrap(giftWrap: NostrEvent, geohash: String, identity: NostrIdentity) {
|
||||||
|
scope.launch(Dispatchers.Default) {
|
||||||
|
try {
|
||||||
|
if (dedupe(giftWrap.id)) return@launch
|
||||||
|
|
||||||
|
val messageAge = System.currentTimeMillis() / 1000 - giftWrap.createdAt
|
||||||
|
if (messageAge > 173700) return@launch // 48 hours + 15 mins
|
||||||
|
|
||||||
|
val decryptResult = NostrProtocol.decryptPrivateMessage(giftWrap, identity)
|
||||||
|
if (decryptResult == null) {
|
||||||
|
Log.w(TAG, "Failed to decrypt Nostr message")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val (content, senderPubkey, rumorTimestamp) = decryptResult
|
||||||
|
if (!content.startsWith("bitchat1:")) return@launch
|
||||||
|
|
||||||
|
val base64Content = content.removePrefix("bitchat1:")
|
||||||
|
val packetData = base64URLDecode(base64Content) ?: return@launch
|
||||||
|
val packet = BitchatPacket.fromBinaryData(packetData) ?: return@launch
|
||||||
|
|
||||||
|
if (packet.type != com.bitchat.android.protocol.MessageType.NOISE_ENCRYPTED.value) return@launch
|
||||||
|
|
||||||
|
val noisePayload = com.bitchat.android.model.NoisePayload.decode(packet.payload) ?: return@launch
|
||||||
|
val messageTimestamp = Date(rumorTimestamp * 1000L)
|
||||||
|
val convKey = "nostr_${senderPubkey.take(16)}"
|
||||||
|
repo.putNostrKeyMapping(convKey, senderPubkey)
|
||||||
|
com.bitchat.android.nostr.GeohashAliasRegistry.put(convKey, senderPubkey)
|
||||||
|
if (geohash.isNotEmpty()) {
|
||||||
|
// Remember which geohash this conversation belongs to so we can subscribe on-demand
|
||||||
|
repo.setConversationGeohash(convKey, geohash)
|
||||||
|
GeohashConversationRegistry.set(convKey, geohash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure sender appears in geohash people list even if they haven't posted publicly yet
|
||||||
|
if (geohash.isNotEmpty()) {
|
||||||
|
// Cache a best-effort nickname and mark as participant
|
||||||
|
val cached = repo.getCachedNickname(senderPubkey)
|
||||||
|
if (cached == null) {
|
||||||
|
val base = repo.displayNameForNostrPubkeyUI(senderPubkey).substringBefore("#")
|
||||||
|
repo.cacheNickname(senderPubkey, base)
|
||||||
|
}
|
||||||
|
repo.updateParticipant(geohash, senderPubkey, messageTimestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
val senderNickname = repo.displayNameForNostrPubkeyUI(senderPubkey)
|
||||||
|
|
||||||
|
processNoisePayload(noisePayload, convKey, senderNickname, messageTimestamp, senderPubkey, identity)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "onGiftWrap error: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun processNoisePayload(
|
||||||
|
payload: com.bitchat.android.model.NoisePayload,
|
||||||
|
convKey: String,
|
||||||
|
senderNickname: String,
|
||||||
|
timestamp: Date,
|
||||||
|
senderPubkey: String,
|
||||||
|
recipientIdentity: NostrIdentity
|
||||||
|
) {
|
||||||
|
when (payload.type) {
|
||||||
|
com.bitchat.android.model.NoisePayloadType.PRIVATE_MESSAGE -> {
|
||||||
|
val pm = com.bitchat.android.model.PrivateMessagePacket.decode(payload.data) ?: return
|
||||||
|
val existingMessages = state.getPrivateChatsValue()[convKey] ?: emptyList()
|
||||||
|
if (existingMessages.any { it.id == pm.messageID }) return
|
||||||
|
|
||||||
|
val message = BitchatMessage(
|
||||||
|
id = pm.messageID,
|
||||||
|
sender = senderNickname,
|
||||||
|
content = pm.content,
|
||||||
|
timestamp = timestamp,
|
||||||
|
isRelay = false,
|
||||||
|
isPrivate = true,
|
||||||
|
recipientNickname = state.getNicknameValue(),
|
||||||
|
senderPeerID = convKey,
|
||||||
|
deliveryStatus = DeliveryStatus.Delivered(to = state.getNicknameValue() ?: "Unknown", at = Date())
|
||||||
|
)
|
||||||
|
|
||||||
|
val isViewing = state.getSelectedPrivateChatPeerValue() == convKey
|
||||||
|
val suppressUnread = seenStore.hasRead(pm.messageID)
|
||||||
|
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
privateChatManager.handleIncomingPrivateMessage(message, suppressUnread)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!seenStore.hasDelivered(pm.messageID)) {
|
||||||
|
val nostrTransport = NostrTransport.getInstance(application)
|
||||||
|
nostrTransport.sendDeliveryAckGeohash(pm.messageID, senderPubkey, recipientIdentity)
|
||||||
|
seenStore.markDelivered(pm.messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isViewing && !suppressUnread) {
|
||||||
|
val nostrTransport = NostrTransport.getInstance(application)
|
||||||
|
nostrTransport.sendReadReceiptGeohash(pm.messageID, senderPubkey, recipientIdentity)
|
||||||
|
seenStore.markRead(pm.messageID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
com.bitchat.android.model.NoisePayloadType.DELIVERED -> {
|
||||||
|
val messageId = String(payload.data, Charsets.UTF_8)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
meshDelegateHandler.didReceiveDeliveryAck(messageId, convKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
com.bitchat.android.model.NoisePayloadType.READ_RECEIPT -> {
|
||||||
|
val messageId = String(payload.data, Charsets.UTF_8)
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
|
meshDelegateHandler.didReceiveReadReceipt(messageId, convKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun base64URLDecode(input: String): ByteArray? {
|
||||||
|
return try {
|
||||||
|
val padded = input.replace("-", "+")
|
||||||
|
.replace("_", "/")
|
||||||
|
.let { str ->
|
||||||
|
val padding = (4 - str.length % 4) % 4
|
||||||
|
str + "=".repeat(padding)
|
||||||
|
}
|
||||||
|
android.util.Base64.decode(padded, android.util.Base64.DEFAULT)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to decode base64url: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,38 @@
|
|||||||
|
package com.bitchat.android.nostr
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NostrSubscriptionManager
|
||||||
|
* - Encapsulates subscription lifecycle with NostrRelayManager
|
||||||
|
*/
|
||||||
|
class NostrSubscriptionManager(
|
||||||
|
private val application: Application,
|
||||||
|
private val scope: CoroutineScope
|
||||||
|
) {
|
||||||
|
companion object { private const val TAG = "NostrSubscriptionManager" }
|
||||||
|
|
||||||
|
private val relayManager get() = NostrRelayManager.getInstance(application)
|
||||||
|
|
||||||
|
fun connect() = scope.launch { runCatching { relayManager.connect() }.onFailure { Log.e(TAG, "connect failed: ${it.message}") } }
|
||||||
|
fun disconnect() = scope.launch { runCatching { relayManager.disconnect() }.onFailure { Log.e(TAG, "disconnect failed: ${it.message}") } }
|
||||||
|
|
||||||
|
fun subscribeGiftWraps(pubkey: String, sinceMs: Long, id: String, handler: (NostrEvent) -> Unit) {
|
||||||
|
scope.launch {
|
||||||
|
val filter = NostrFilter.giftWrapsFor(pubkey, sinceMs)
|
||||||
|
relayManager.subscribe(filter, id, handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun subscribeGeohash(geohash: String, sinceMs: Long, limit: Int, id: String, handler: (NostrEvent) -> Unit) {
|
||||||
|
scope.launch {
|
||||||
|
val filter = NostrFilter.geohashEphemeral(geohash, sinceMs, limit)
|
||||||
|
relayManager.subscribeForGeohash(geohash, filter, id, handler, includeDefaults = false, nRelays = 5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unsubscribe(id: String) { scope.launch { runCatching { relayManager.unsubscribe(id) } } }
|
||||||
|
}
|
||||||
@@ -56,8 +56,7 @@ class NostrTransport(
|
|||||||
// Resolve favorite by full noise key or by short peerID fallback
|
// Resolve favorite by full noise key or by short peerID fallback
|
||||||
var recipientNostrPubkey: String? = null
|
var recipientNostrPubkey: String? = null
|
||||||
|
|
||||||
// Try to resolve from favorites persistence service
|
// Resolve by peerID first (new peerID→npub index), then fall back to noise key mapping
|
||||||
// This would need integration with the existing favorites system
|
|
||||||
recipientNostrPubkey = resolveNostrPublicKey(to)
|
recipientNostrPubkey = resolveNostrPublicKey(to)
|
||||||
|
|
||||||
if (recipientNostrPubkey == null) {
|
if (recipientNostrPubkey == null) {
|
||||||
@@ -86,13 +85,23 @@ class NostrTransport(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Strict: lookup the recipient's current BitChat peer ID using favorites mapping
|
||||||
|
val recipientPeerIDForEmbed = try {
|
||||||
|
com.bitchat.android.favorites.FavoritesPersistenceService.shared
|
||||||
|
.findPeerIDForNostrPubkey(recipientNostrPubkey)
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
if (recipientPeerIDForEmbed.isNullOrBlank()) {
|
||||||
|
Log.e(TAG, "NostrTransport: no peerID stored for recipient npub; cannot embed PM. npub=${recipientNostrPubkey.take(16)}...")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
val embedded = NostrEmbeddedBitChat.encodePMForNostr(
|
val embedded = NostrEmbeddedBitChat.encodePMForNostr(
|
||||||
content = content,
|
content = content,
|
||||||
messageID = messageID,
|
messageID = messageID,
|
||||||
recipientPeerID = to,
|
recipientPeerID = recipientPeerIDForEmbed,
|
||||||
senderPeerID = senderPeerID
|
senderPeerID = senderPeerID
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
if (embedded == null) {
|
if (embedded == null) {
|
||||||
Log.e(TAG, "NostrTransport: failed to embed PM packet")
|
Log.e(TAG, "NostrTransport: failed to embed PM packet")
|
||||||
return@launch
|
return@launch
|
||||||
@@ -412,23 +421,43 @@ class NostrTransport(
|
|||||||
fun sendPrivateMessageGeohash(
|
fun sendPrivateMessageGeohash(
|
||||||
content: String,
|
content: String,
|
||||||
toRecipientHex: String,
|
toRecipientHex: String,
|
||||||
fromIdentity: NostrIdentity,
|
messageID: String,
|
||||||
messageID: String
|
sourceGeohash: String? = null
|
||||||
) {
|
) {
|
||||||
|
// Use provided geohash or derive from current location
|
||||||
|
val geohash = sourceGeohash ?: run {
|
||||||
|
val selected = try {
|
||||||
|
com.bitchat.android.geohash.LocationChannelManager.getInstance(context).selectedChannel.value
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
if (selected !is com.bitchat.android.geohash.ChannelID.Location) {
|
||||||
|
Log.w(TAG, "NostrTransport: cannot send geohash PM - not in a location channel and no geohash provided")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
selected.channel.geohash
|
||||||
|
}
|
||||||
|
|
||||||
|
val fromIdentity = try {
|
||||||
|
NostrIdentityBridge.deriveIdentity(geohash, context)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "NostrTransport: cannot derive geohash identity for $geohash: ${e.message}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
transportScope.launch {
|
transportScope.launch {
|
||||||
try {
|
try {
|
||||||
if (toRecipientHex.isEmpty()) return@launch
|
if (toRecipientHex.isEmpty()) return@launch
|
||||||
|
|
||||||
Log.d(TAG, "GeoDM: send PM -> recip=${toRecipientHex.take(8)}... mid=${messageID.take(8)}... from=${fromIdentity.publicKeyHex.take(8)}...")
|
Log.d(
|
||||||
|
TAG,
|
||||||
|
"GeoDM: send PM -> recip=${toRecipientHex.take(8)}... mid=${messageID.take(8)}... from=${fromIdentity.publicKeyHex.take(8)}... geohash=$geohash"
|
||||||
|
)
|
||||||
|
|
||||||
// Build embedded BitChat packet without recipient peer ID
|
// Build embedded BitChat packet without recipient peer ID
|
||||||
val embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
|
val embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
|
||||||
content = content,
|
content = content,
|
||||||
messageID = messageID,
|
messageID = messageID,
|
||||||
senderPeerID = senderPeerID
|
senderPeerID = senderPeerID
|
||||||
)
|
) ?: run {
|
||||||
|
|
||||||
if (embedded == null) {
|
|
||||||
Log.e(TAG, "NostrTransport: failed to embed geohash PM packet")
|
Log.e(TAG, "NostrTransport: failed to embed geohash PM packet")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
@@ -444,7 +473,6 @@ class NostrTransport(
|
|||||||
NostrRelayManager.registerPendingGiftWrap(event.id)
|
NostrRelayManager.registerPendingGiftWrap(event.id)
|
||||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to send geohash private message: ${e.message}")
|
Log.e(TAG, "Failed to send geohash private message: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -458,14 +486,15 @@ class NostrTransport(
|
|||||||
*/
|
*/
|
||||||
private fun resolveNostrPublicKey(peerID: String): String? {
|
private fun resolveNostrPublicKey(peerID: String): String? {
|
||||||
try {
|
try {
|
||||||
// Try to resolve from favorites persistence service
|
// 1) Fast path: direct peerID→npub mapping (mutual favorites after mesh mapping)
|
||||||
|
com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkeyForPeerID(peerID)?.let { return it }
|
||||||
|
|
||||||
|
// 2) Legacy path: resolve by noise public key association
|
||||||
val noiseKey = hexStringToByteArray(peerID)
|
val noiseKey = hexStringToByteArray(peerID)
|
||||||
val favoriteStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
|
val favoriteStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
|
||||||
if (favoriteStatus?.peerNostrPublicKey != null) {
|
if (favoriteStatus?.peerNostrPublicKey != null) return favoriteStatus.peerNostrPublicKey
|
||||||
return favoriteStatus.peerNostrPublicKey
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: try with 16-hex peerID lookup
|
// 3) Prefix match on noiseHex from 16-hex peerID
|
||||||
if (peerID.length == 16) {
|
if (peerID.length == 16) {
|
||||||
val fallbackStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
|
val fallbackStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
|
||||||
return fallbackStatus?.peerNostrPublicKey
|
return fallbackStatus?.peerNostrPublicKey
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class MessageRouter private constructor(
|
|||||||
|
|
||||||
// Listener for favorites changes to flush outbox when npub mapping appears/changes
|
// Listener for favorites changes to flush outbox when npub mapping appears/changes
|
||||||
private val favoriteListener = object: com.bitchat.android.favorites.FavoritesChangeListener {
|
private val favoriteListener = object: com.bitchat.android.favorites.FavoritesChangeListener {
|
||||||
|
|
||||||
override fun onFavoriteChanged(noiseKeyHex: String) {
|
override fun onFavoriteChanged(noiseKeyHex: String) {
|
||||||
flushOutboxFor(noiseKeyHex)
|
flushOutboxFor(noiseKeyHex)
|
||||||
// Also try 16-hex short id commonly used in UI if any client used that
|
// Also try 16-hex short id commonly used in UI if any client used that
|
||||||
@@ -55,18 +56,34 @@ class MessageRouter private constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun sendPrivate(content: String, toPeerID: String, recipientNickname: String, messageID: String) {
|
fun sendPrivate(content: String, toPeerID: String, recipientNickname: String, messageID: String) {
|
||||||
|
// First: if this is a geohash DM alias (nostr_<pub16>), route via Nostr using global registry
|
||||||
|
if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(toPeerID)) {
|
||||||
|
Log.d(TAG, "Routing PM via Nostr (geohash) to alias ${toPeerID.take(12)}… id=${messageID.take(8)}…")
|
||||||
|
val recipientHex = com.bitchat.android.nostr.GeohashAliasRegistry.get(toPeerID)
|
||||||
|
if (recipientHex != null) {
|
||||||
|
// Resolve the conversation's source geohash, so we can send from anywhere
|
||||||
|
val sourceGeohash = com.bitchat.android.nostr.GeohashConversationRegistry.get(toPeerID)
|
||||||
|
|
||||||
|
// If repository knows the source geohash, pass it so NostrTransport derives the correct identity
|
||||||
|
nostr.sendPrivateMessageGeohash(content, recipientHex, messageID, sourceGeohash)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val hasMesh = mesh.getPeerInfo(toPeerID)?.isConnected == true
|
val hasMesh = mesh.getPeerInfo(toPeerID)?.isConnected == true
|
||||||
val hasEstablished = mesh.hasEstablishedSession(toPeerID)
|
val hasEstablished = mesh.hasEstablishedSession(toPeerID)
|
||||||
if (hasMesh && hasEstablished) {
|
if (hasMesh && hasEstablished) {
|
||||||
Log.d(TAG, "Routing PM via mesh to ${toPeerID.take(8)}… id=${messageID.take(8)}…")
|
Log.d(TAG, "Routing PM via mesh to ${toPeerID} msg_id=${messageID.take(8)}…")
|
||||||
mesh.sendPrivateMessage(content, toPeerID, recipientNickname, messageID)
|
mesh.sendPrivateMessage(content, toPeerID, recipientNickname, messageID)
|
||||||
} else if (canSendViaNostr(toPeerID)) {
|
} else if (canSendViaNostr(toPeerID)) {
|
||||||
Log.d(TAG, "Routing PM via Nostr to ${toPeerID.take(8)}… id=${messageID.take(8)}…")
|
Log.d(TAG, "Routing PM via Nostr to ${toPeerID.take(32)}… msg_id=${messageID.take(8)}…")
|
||||||
nostr.sendPrivateMessage(content, toPeerID, recipientNickname, messageID)
|
nostr.sendPrivateMessage(content, toPeerID, recipientNickname, messageID)
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "Queued PM for ${toPeerID.take(8)}… (no mesh, no Nostr mapping) id=${messageID.take(8)}…")
|
Log.d(TAG, "Queued PM for ${toPeerID} (no mesh, no Nostr mapping) msg_id=${messageID.take(8)}…")
|
||||||
val q = outbox.getOrPut(toPeerID) { mutableListOf() }
|
val q = outbox.getOrPut(toPeerID) { mutableListOf() }
|
||||||
q.add(Triple(content, recipientNickname, messageID))
|
q.add(Triple(content, recipientNickname, messageID))
|
||||||
|
Log.d(TAG, "Initiating noise handshake after queueing PM for ${toPeerID.take(8)}…")
|
||||||
|
mesh.initiateNoiseHandshake(toPeerID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +99,14 @@ class MessageRouter private constructor(
|
|||||||
|
|
||||||
fun sendDeliveryAck(messageID: String, toPeerID: String) {
|
fun sendDeliveryAck(messageID: String, toPeerID: String) {
|
||||||
// Mesh delivery ACKs are sent by the receiver automatically.
|
// Mesh delivery ACKs are sent by the receiver automatically.
|
||||||
// Only route via Nostr when mesh path isn't available.
|
// Only route via Nostr when mesh path isn't available or when this is a geohash alias
|
||||||
|
if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(toPeerID)) {
|
||||||
|
val recipientHex = com.bitchat.android.nostr.GeohashAliasRegistry.get(toPeerID)
|
||||||
|
if (recipientHex != null) {
|
||||||
|
nostr.sendDeliveryAckGeohash(messageID, recipientHex, try { com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(context)!! } catch (_: Exception) { return })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!((mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID))) {
|
if (!((mesh.getPeerInfo(toPeerID)?.isConnected == true) && mesh.hasEstablishedSession(toPeerID))) {
|
||||||
nostr.sendDeliveryAck(messageID, toPeerID)
|
nostr.sendDeliveryAck(messageID, toPeerID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.bitchat.android.ui
|
package com.bitchat.android.ui
|
||||||
|
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
@@ -31,6 +32,7 @@ import com.bitchat.android.core.ui.utils.singleOrTripleClickable
|
|||||||
* Extracted from ChatScreen.kt for better organization
|
* Extracted from ChatScreen.kt for better organization
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reactive helper to compute favorite state from fingerprint mapping
|
* Reactive helper to compute favorite state from fingerprint mapping
|
||||||
* This eliminates the need for static isFavorite parameters and makes
|
* This eliminates the need for static isFavorite parameters and makes
|
||||||
@@ -159,7 +161,6 @@ fun PeerCounter(
|
|||||||
connectedPeers: List<String>,
|
connectedPeers: List<String>,
|
||||||
joinedChannels: Set<String>,
|
joinedChannels: Set<String>,
|
||||||
hasUnreadChannels: Map<String, Int>,
|
hasUnreadChannels: Map<String, Int>,
|
||||||
hasUnreadPrivateMessages: Set<String>,
|
|
||||||
isConnected: Boolean,
|
isConnected: Boolean,
|
||||||
selectedLocationChannel: com.bitchat.android.geohash.ChannelID?,
|
selectedLocationChannel: com.bitchat.android.geohash.ChannelID?,
|
||||||
geohashPeople: List<GeoPerson>,
|
geohashPeople: List<GeoPerson>,
|
||||||
@@ -189,33 +190,6 @@ fun PeerCounter(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = modifier.clickable { onClick() }.padding(end = 8.dp) // Added right margin to match "bitchat" logo spacing
|
modifier = modifier.clickable { onClick() }.padding(end = 8.dp) // Added right margin to match "bitchat" logo spacing
|
||||||
) {
|
) {
|
||||||
if (hasUnreadChannels.values.any { it > 0 }) {
|
|
||||||
// Channel icon in a Box to ensure consistent size with other icons
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "#",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = Color(0xFF0080FF),
|
|
||||||
fontSize = 16.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasUnreadPrivateMessages.isNotEmpty()) {
|
|
||||||
// Filled mail icon to match sidebar style
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Filled.Email,
|
|
||||||
contentDescription = "Unread private messages",
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = Color(0xFFFF9500) // Orange to match private message theme
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Group,
|
imageVector = Icons.Default.Group,
|
||||||
contentDescription = when (selectedLocationChannel) {
|
contentDescription = when (selectedLocationChannel) {
|
||||||
@@ -226,6 +200,7 @@ fun PeerCounter(
|
|||||||
tint = countColor
|
tint = countColor
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "$peopleCount",
|
text = "$peopleCount",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
@@ -290,7 +265,8 @@ fun ChatHeaderContent(
|
|||||||
selectedLocationChannel = selectedLocationChannel,
|
selectedLocationChannel = selectedLocationChannel,
|
||||||
geohashPeople = geohashPeople,
|
geohashPeople = geohashPeople,
|
||||||
onBackClick = onBackClick,
|
onBackClick = onBackClick,
|
||||||
onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) }
|
onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) },
|
||||||
|
viewModel = viewModel
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
currentChannel != null -> {
|
currentChannel != null -> {
|
||||||
@@ -326,7 +302,8 @@ private fun PrivateChatHeader(
|
|||||||
selectedLocationChannel: com.bitchat.android.geohash.ChannelID?,
|
selectedLocationChannel: com.bitchat.android.geohash.ChannelID?,
|
||||||
geohashPeople: List<GeoPerson>,
|
geohashPeople: List<GeoPerson>,
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
onToggleFavorite: () -> Unit
|
onToggleFavorite: () -> Unit,
|
||||||
|
viewModel: ChatViewModel
|
||||||
) {
|
) {
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
val isNostrDM = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
|
val isNostrDM = peerID.startsWith("nostr_") || peerID.startsWith("nostr:")
|
||||||
@@ -345,12 +322,24 @@ private fun PrivateChatHeader(
|
|||||||
|
|
||||||
// Compute title text: for NIP-17 chats show "#geohash/@username" (iOS parity)
|
// Compute title text: for NIP-17 chats show "#geohash/@username" (iOS parity)
|
||||||
val titleText: String = if (isNostrDM) {
|
val titleText: String = if (isNostrDM) {
|
||||||
val geohash = (selectedLocationChannel as? com.bitchat.android.geohash.ChannelID.Location)?.channel?.geohash
|
// For geohash DMs, get the actual source geohash and proper display name
|
||||||
val shortId = peerID.removePrefix("nostr_").removePrefix("nostr:")
|
val (conversationGeohash, baseName) = try {
|
||||||
val person = geohashPeople.firstOrNull { it.id.startsWith(shortId, ignoreCase = true) }
|
val repoField = com.bitchat.android.ui.GeohashViewModel::class.java.getDeclaredField("repo")
|
||||||
val baseName = person?.displayName?.substringBefore('#') ?: peerNicknames[peerID] ?: "unknown"
|
repoField.isAccessible = true
|
||||||
val geoPart = geohash?.let { "#$it" } ?: "#geohash"
|
val repo = repoField.get(viewModel.geohashViewModel) as com.bitchat.android.nostr.GeohashRepository
|
||||||
"$geoPart/@$baseName"
|
val gh = repo.getConversationGeohash(peerID) ?: "geohash"
|
||||||
|
val fullPubkey = com.bitchat.android.nostr.GeohashAliasRegistry.get(peerID) ?: ""
|
||||||
|
val displayName = if (fullPubkey.isNotEmpty()) {
|
||||||
|
repo.displayNameForGeohashConversation(fullPubkey, gh)
|
||||||
|
} else {
|
||||||
|
peerNicknames[peerID] ?: "unknown"
|
||||||
|
}
|
||||||
|
Pair(gh, displayName)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Pair("geohash", peerNicknames[peerID] ?: "unknown")
|
||||||
|
}
|
||||||
|
|
||||||
|
"#$conversationGeohash/@$baseName"
|
||||||
} else {
|
} else {
|
||||||
// Prefer live mesh nickname; fallback to favorites nickname (supports 16-hex), finally short key
|
// Prefer live mesh nickname; fallback to favorites nickname (supports 16-hex), finally short key
|
||||||
peerNicknames[peerID] ?: run {
|
peerNicknames[peerID] ?: run {
|
||||||
@@ -563,6 +552,19 @@ private fun MainHeader(
|
|||||||
horizontalArrangement = Arrangement.spacedBy(5.dp)
|
horizontalArrangement = Arrangement.spacedBy(5.dp)
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
// Unread private messages badge (click to open most recent DM)
|
||||||
|
if (hasUnreadPrivateMessages.isNotEmpty()) {
|
||||||
|
// Render icon directly to avoid symbol resolution issues
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Email,
|
||||||
|
contentDescription = "Unread private messages",
|
||||||
|
modifier = Modifier
|
||||||
|
.size(16.dp)
|
||||||
|
.clickable { viewModel.openLatestUnreadPrivateChat() },
|
||||||
|
tint = Color(0xFFFF9500)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Location channels button (matching iOS implementation)
|
// Location channels button (matching iOS implementation)
|
||||||
LocationChannelsButton(
|
LocationChannelsButton(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
@@ -582,7 +584,6 @@ private fun MainHeader(
|
|||||||
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
|
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
|
||||||
joinedChannels = joinedChannels,
|
joinedChannels = joinedChannels,
|
||||||
hasUnreadChannels = hasUnreadChannels,
|
hasUnreadChannels = hasUnreadChannels,
|
||||||
hasUnreadPrivateMessages = hasUnreadPrivateMessages,
|
|
||||||
isConnected = isConnected,
|
isConnected = isConnected,
|
||||||
selectedLocationChannel = selectedLocationChannel,
|
selectedLocationChannel = selectedLocationChannel,
|
||||||
geohashPeople = geohashPeople,
|
geohashPeople = geohashPeople,
|
||||||
|
|||||||
@@ -74,18 +74,17 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
|||||||
// Get location channel info for timeline switching
|
// Get location channel info for timeline switching
|
||||||
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
val selectedLocationChannel by viewModel.selectedLocationChannel.observeAsState()
|
||||||
|
|
||||||
// Determine what messages to show based on current context
|
// Determine what messages to show based on current context (unified timelines)
|
||||||
val displayMessages = when {
|
val displayMessages = when {
|
||||||
selectedPrivatePeer != null -> privateChats[selectedPrivatePeer] ?: emptyList()
|
selectedPrivatePeer != null -> privateChats[selectedPrivatePeer] ?: emptyList()
|
||||||
currentChannel != null -> channelMessages[currentChannel] ?: emptyList()
|
currentChannel != null -> channelMessages[currentChannel] ?: emptyList()
|
||||||
else -> {
|
else -> {
|
||||||
val locationChannel = selectedLocationChannel
|
val locationChannel = selectedLocationChannel
|
||||||
if (locationChannel is com.bitchat.android.geohash.ChannelID.Location) {
|
if (locationChannel is com.bitchat.android.geohash.ChannelID.Location) {
|
||||||
// For geohash channels, get messages from geohash history
|
val geokey = "geo:${locationChannel.channel.geohash}"
|
||||||
val geohash = locationChannel.channel.geohash
|
channelMessages[geokey] ?: emptyList()
|
||||||
viewModel.getGeohashMessages(geohash)
|
|
||||||
} else {
|
} else {
|
||||||
messages // Mesh/public messages
|
messages // Mesh timeline
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,9 @@ class ChatState {
|
|||||||
// Geohash people state (iOS-compatible)
|
// Geohash people state (iOS-compatible)
|
||||||
private val _geohashPeople = MutableLiveData<List<GeoPerson>>(emptyList())
|
private val _geohashPeople = MutableLiveData<List<GeoPerson>>(emptyList())
|
||||||
val geohashPeople: LiveData<List<GeoPerson>> = _geohashPeople
|
val geohashPeople: LiveData<List<GeoPerson>> = _geohashPeople
|
||||||
|
// For background thread updates by repositories/handlers in their own scopes
|
||||||
|
val geohashPeopleMutable: MutableLiveData<List<GeoPerson>> get() = _geohashPeople
|
||||||
|
|
||||||
|
|
||||||
private val _teleportedGeo = MutableLiveData<Set<String>>(emptySet())
|
private val _teleportedGeo = MutableLiveData<Set<String>>(emptySet())
|
||||||
val teleportedGeo: LiveData<Set<String>> = _teleportedGeo
|
val teleportedGeo: LiveData<Set<String>> = _teleportedGeo
|
||||||
@@ -155,6 +158,16 @@ class ChatState {
|
|||||||
fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value
|
fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value
|
||||||
fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value ?: emptySet()
|
fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.value ?: emptySet()
|
||||||
fun getJoinedChannelsValue() = _joinedChannels.value ?: emptySet()
|
fun getJoinedChannelsValue() = _joinedChannels.value ?: emptySet()
|
||||||
|
// Thread-safe posting helpers for background updates
|
||||||
|
fun postGeohashPeople(people: List<GeoPerson>) {
|
||||||
|
_geohashPeople.postValue(people)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun postGeohashParticipantCounts(counts: Map<String, Int>) {
|
||||||
|
_geohashParticipantCounts.postValue(counts)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
fun getCurrentChannelValue() = _currentChannel.value
|
fun getCurrentChannelValue() = _currentChannel.value
|
||||||
fun getChannelMessagesValue() = _channelMessages.value ?: emptyMap()
|
fun getChannelMessagesValue() = _channelMessages.value ?: emptyMap()
|
||||||
fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value ?: emptyMap()
|
fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value ?: emptyMap()
|
||||||
@@ -183,6 +196,10 @@ class ChatState {
|
|||||||
_connectedPeers.value = peers
|
_connectedPeers.value = peers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun postTeleportedGeo(teleported: Set<String>) {
|
||||||
|
_teleportedGeo.postValue(teleported)
|
||||||
|
}
|
||||||
|
|
||||||
fun setNickname(nickname: String) {
|
fun setNickname(nickname: String) {
|
||||||
_nickname.value = nickname
|
_nickname.value = nickname
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ import com.bitchat.android.mesh.BluetoothMeshDelegate
|
|||||||
import com.bitchat.android.mesh.BluetoothMeshService
|
import com.bitchat.android.mesh.BluetoothMeshService
|
||||||
import com.bitchat.android.model.BitchatMessage
|
import com.bitchat.android.model.BitchatMessage
|
||||||
import com.bitchat.android.protocol.BitchatPacket
|
import com.bitchat.android.protocol.BitchatPacket
|
||||||
import com.bitchat.android.nostr.NostrGeohashService
|
|
||||||
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import com.bitchat.android.util.NotificationIntervalManager
|
import com.bitchat.android.util.NotificationIntervalManager
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -68,18 +69,20 @@ class ChatViewModel(
|
|||||||
getMeshService = { meshService }
|
getMeshService = { meshService }
|
||||||
)
|
)
|
||||||
|
|
||||||
// Nostr and Geohash service - initialize singleton
|
// New Geohash architecture ViewModel (replaces God object service usage in UI path)
|
||||||
private val nostrGeohashService = NostrGeohashService.initialize(
|
val geohashViewModel = GeohashViewModel(
|
||||||
application = application,
|
application = application,
|
||||||
state = state,
|
state = state,
|
||||||
messageManager = messageManager,
|
messageManager = messageManager,
|
||||||
privateChatManager = privateChatManager,
|
privateChatManager = privateChatManager,
|
||||||
meshDelegateHandler = meshDelegateHandler,
|
meshDelegateHandler = meshDelegateHandler,
|
||||||
coroutineScope = viewModelScope,
|
|
||||||
dataManager = dataManager,
|
dataManager = dataManager,
|
||||||
notificationManager = notificationManager
|
notificationManager = notificationManager
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Expose state through LiveData (maintaining the same interface)
|
// Expose state through LiveData (maintaining the same interface)
|
||||||
val messages: LiveData<List<BitchatMessage>> = state.messages
|
val messages: LiveData<List<BitchatMessage>> = state.messages
|
||||||
val connectedPeers: LiveData<List<String>> = state.connectedPeers
|
val connectedPeers: LiveData<List<String>> = state.connectedPeers
|
||||||
@@ -168,14 +171,12 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize location channel state
|
// Initialize new geohash architecture
|
||||||
nostrGeohashService.initializeLocationChannelState()
|
geohashViewModel.initialize()
|
||||||
|
|
||||||
// Initialize favorites persistence service
|
// Initialize favorites persistence service
|
||||||
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication())
|
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication())
|
||||||
|
|
||||||
// Initialize Nostr integration
|
|
||||||
nostrGeohashService.initializeNostrIntegration()
|
|
||||||
|
|
||||||
// Ensure NostrTransport knows our mesh peer ID for embedded packets
|
// Ensure NostrTransport knows our mesh peer ID for embedded packets
|
||||||
try {
|
try {
|
||||||
@@ -213,6 +214,46 @@ class ChatViewModel(
|
|||||||
meshService.sendBroadcastAnnounce()
|
meshService.sendBroadcastAnnounce()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure Nostr DM subscription for a geohash conversation key if known
|
||||||
|
* Minimal-change approach: reflectively access GeohashViewModel internals to reuse pipeline
|
||||||
|
*/
|
||||||
|
private fun ensureGeohashDMSubscriptionIfNeeded(convKey: String) {
|
||||||
|
try {
|
||||||
|
val repoField = GeohashViewModel::class.java.getDeclaredField("repo")
|
||||||
|
repoField.isAccessible = true
|
||||||
|
val repo = repoField.get(geohashViewModel) as com.bitchat.android.nostr.GeohashRepository
|
||||||
|
val gh = repo.getConversationGeohash(convKey)
|
||||||
|
if (!gh.isNullOrEmpty()) {
|
||||||
|
val subMgrField = GeohashViewModel::class.java.getDeclaredField("subscriptionManager")
|
||||||
|
subMgrField.isAccessible = true
|
||||||
|
val subMgr = subMgrField.get(geohashViewModel) as com.bitchat.android.nostr.NostrSubscriptionManager
|
||||||
|
val identity = com.bitchat.android.nostr.NostrIdentityBridge.deriveIdentity(gh, getApplication())
|
||||||
|
val subId = "geo-dm-$gh"
|
||||||
|
val currentDmSubField = GeohashViewModel::class.java.getDeclaredField("currentDmSubId")
|
||||||
|
currentDmSubField.isAccessible = true
|
||||||
|
val currentId = currentDmSubField.get(geohashViewModel) as String?
|
||||||
|
if (currentId != subId) {
|
||||||
|
(currentId)?.let { subMgr.unsubscribe(it) }
|
||||||
|
currentDmSubField.set(geohashViewModel, subId)
|
||||||
|
subMgr.subscribeGiftWraps(
|
||||||
|
pubkey = identity.publicKeyHex,
|
||||||
|
sinceMs = System.currentTimeMillis() - 172800000L,
|
||||||
|
id = subId,
|
||||||
|
handler = { event ->
|
||||||
|
val dmHandlerField = GeohashViewModel::class.java.getDeclaredField("dmHandler")
|
||||||
|
dmHandlerField.isAccessible = true
|
||||||
|
val dmHandler = dmHandlerField.get(geohashViewModel) as com.bitchat.android.nostr.NostrDirectMessageHandler
|
||||||
|
dmHandler.onGiftWrap(event, gh, identity)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "ensureGeohashDMSubscriptionIfNeeded failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Channel Management (delegated)
|
// MARK: - Channel Management (delegated)
|
||||||
|
|
||||||
fun joinChannel(channel: String, password: String? = null): Boolean {
|
fun joinChannel(channel: String, password: String? = null): Boolean {
|
||||||
@@ -231,6 +272,11 @@ class ChatViewModel(
|
|||||||
// MARK: - Private Chat Management (delegated)
|
// MARK: - Private Chat Management (delegated)
|
||||||
|
|
||||||
fun startPrivateChat(peerID: String) {
|
fun startPrivateChat(peerID: String) {
|
||||||
|
// For geohash conversation keys, ensure DM subscription is active
|
||||||
|
if (peerID.startsWith("nostr_")) {
|
||||||
|
ensureGeohashDMSubscriptionIfNeeded(peerID)
|
||||||
|
}
|
||||||
|
|
||||||
val success = privateChatManager.startPrivateChat(peerID, meshService)
|
val success = privateChatManager.startPrivateChat(peerID, meshService)
|
||||||
if (success) {
|
if (success) {
|
||||||
// Notify notification manager about current private chat
|
// Notify notification manager about current private chat
|
||||||
@@ -259,6 +305,66 @@ class ChatViewModel(
|
|||||||
clearMeshMentionNotifications()
|
clearMeshMentionNotifications()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Open Latest Unread Private Chat
|
||||||
|
|
||||||
|
fun openLatestUnreadPrivateChat() {
|
||||||
|
try {
|
||||||
|
val unreadKeys = state.getUnreadPrivateMessagesValue()
|
||||||
|
if (unreadKeys.isEmpty()) return
|
||||||
|
|
||||||
|
val me = state.getNicknameValue() ?: meshService.myPeerID
|
||||||
|
val chats = state.getPrivateChatsValue()
|
||||||
|
|
||||||
|
// Pick the latest incoming message among unread conversations
|
||||||
|
var bestKey: String? = null
|
||||||
|
var bestTime: Long = Long.MIN_VALUE
|
||||||
|
|
||||||
|
unreadKeys.forEach { key ->
|
||||||
|
val list = chats[key]
|
||||||
|
if (!list.isNullOrEmpty()) {
|
||||||
|
// Prefer the latest incoming message (sender != me), fallback to last message
|
||||||
|
val latestIncoming = list.lastOrNull { it.sender != me }
|
||||||
|
val candidateTime = (latestIncoming ?: list.last()).timestamp.time
|
||||||
|
if (candidateTime > bestTime) {
|
||||||
|
bestTime = candidateTime
|
||||||
|
bestKey = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val targetKey = bestKey ?: unreadKeys.firstOrNull() ?: return
|
||||||
|
|
||||||
|
val openPeer: String = if (targetKey.startsWith("nostr_")) {
|
||||||
|
// Use the exact conversation key for geohash DMs and ensure DM subscription
|
||||||
|
ensureGeohashDMSubscriptionIfNeeded(targetKey)
|
||||||
|
targetKey
|
||||||
|
} else {
|
||||||
|
// Resolve to a canonical mesh peer if needed
|
||||||
|
val canonical = com.bitchat.android.services.ConversationAliasResolver.resolveCanonicalPeerID(
|
||||||
|
selectedPeerID = targetKey,
|
||||||
|
connectedPeers = state.getConnectedPeersValue(),
|
||||||
|
meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey },
|
||||||
|
meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true },
|
||||||
|
nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) },
|
||||||
|
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
||||||
|
)
|
||||||
|
canonical ?: targetKey
|
||||||
|
}
|
||||||
|
|
||||||
|
startPrivateChat(openPeer)
|
||||||
|
|
||||||
|
// If sidebar visible, hide it to focus on the private chat
|
||||||
|
if (state.getShowSidebarValue()) {
|
||||||
|
state.setShowSidebar(false)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "openLatestUnreadPrivateChat failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// END - Open Latest Unread Private Chat
|
||||||
|
|
||||||
|
|
||||||
// MARK: - Message Sending
|
// MARK: - Message Sending
|
||||||
|
|
||||||
fun sendMessage(content: String) {
|
fun sendMessage(content: String) {
|
||||||
@@ -270,7 +376,7 @@ class ChatViewModel(
|
|||||||
commandProcessor.processCommand(content, meshService, meshService.myPeerID, { messageContent, mentions, channel ->
|
commandProcessor.processCommand(content, meshService, meshService.myPeerID, { messageContent, mentions, channel ->
|
||||||
if (selectedLocationForCommand is com.bitchat.android.geohash.ChannelID.Location) {
|
if (selectedLocationForCommand is com.bitchat.android.geohash.ChannelID.Location) {
|
||||||
// Route command-generated public messages via Nostr in geohash channels
|
// Route command-generated public messages via Nostr in geohash channels
|
||||||
nostrGeohashService.sendGeohashMessage(
|
geohashViewModel.sendGeohashMessage(
|
||||||
messageContent,
|
messageContent,
|
||||||
selectedLocationForCommand.channel,
|
selectedLocationForCommand.channel,
|
||||||
meshService.myPeerID,
|
meshService.myPeerID,
|
||||||
@@ -298,7 +404,7 @@ class ChatViewModel(
|
|||||||
connectedPeers = state.getConnectedPeersValue(),
|
connectedPeers = state.getConnectedPeersValue(),
|
||||||
meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey },
|
meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey },
|
||||||
meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true },
|
meshHasPeer = { pid -> meshService.getPeerInfo(pid)?.isConnected == true },
|
||||||
nostrPubHexForAlias = { alias -> nostrGeohashService.getNostrKeyMapping()[alias] },
|
nostrPubHexForAlias = { alias -> com.bitchat.android.nostr.GeohashAliasRegistry.get(alias) },
|
||||||
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
||||||
).also { canonical ->
|
).also { canonical ->
|
||||||
if (canonical != state.getSelectedPrivateChatPeerValue()) {
|
if (canonical != state.getSelectedPrivateChatPeerValue()) {
|
||||||
@@ -323,7 +429,7 @@ class ChatViewModel(
|
|||||||
val selectedLocationChannel = state.selectedLocationChannel.value
|
val selectedLocationChannel = state.selectedLocationChannel.value
|
||||||
if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) {
|
if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) {
|
||||||
// Send to geohash channel via Nostr ephemeral event
|
// Send to geohash channel via Nostr ephemeral event
|
||||||
nostrGeohashService.sendGeohashMessage(content, selectedLocationChannel.channel, meshService.myPeerID, state.getNicknameValue())
|
geohashViewModel.sendGeohashMessage(content, selectedLocationChannel.channel, meshService.myPeerID, state.getNicknameValue())
|
||||||
} else {
|
} else {
|
||||||
// Send public/channel message via mesh
|
// Send public/channel message via mesh
|
||||||
val message = BitchatMessage(
|
val message = BitchatMessage(
|
||||||
@@ -376,17 +482,36 @@ class ChatViewModel(
|
|||||||
Log.d("ChatViewModel", "toggleFavorite called for peerID: $peerID")
|
Log.d("ChatViewModel", "toggleFavorite called for peerID: $peerID")
|
||||||
privateChatManager.toggleFavorite(peerID)
|
privateChatManager.toggleFavorite(peerID)
|
||||||
|
|
||||||
// Persist relationship in FavoritesPersistenceService when we have Noise key
|
// Persist relationship in FavoritesPersistenceService
|
||||||
try {
|
try {
|
||||||
|
var noiseKey: ByteArray? = null
|
||||||
|
var nickname: String = meshService.getPeerNicknames()[peerID] ?: peerID
|
||||||
|
|
||||||
|
// Case 1: Live mesh peer with known info
|
||||||
val peerInfo = meshService.getPeerInfo(peerID)
|
val peerInfo = meshService.getPeerInfo(peerID)
|
||||||
val noiseKey = peerInfo?.noisePublicKey
|
if (peerInfo?.noisePublicKey != null) {
|
||||||
val nickname = peerInfo?.nickname ?: (meshService.getPeerNicknames()[peerID] ?: peerID)
|
noiseKey = peerInfo.noisePublicKey
|
||||||
|
nickname = peerInfo.nickname
|
||||||
|
} else {
|
||||||
|
// Case 2: Offline favorite entry using 64-hex noise public key as peerID
|
||||||
|
if (peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||||
|
try {
|
||||||
|
noiseKey = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||||
|
// Prefer nickname from favorites store if available
|
||||||
|
val rel = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey!!)
|
||||||
|
if (rel != null) nickname = rel.peerNickname
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (noiseKey != null) {
|
if (noiseKey != null) {
|
||||||
val isNowFavorite = dataManager.favoritePeers.contains(
|
// Determine current favorite state from DataManager using fingerprint
|
||||||
com.bitchat.android.mesh.PeerFingerprintManager.getInstance().getFingerprintForPeer(peerID) ?: ""
|
val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication())
|
||||||
)
|
val fingerprint = identityManager.generateFingerprint(noiseKey!!)
|
||||||
|
val isNowFavorite = dataManager.favoritePeers.contains(fingerprint)
|
||||||
|
|
||||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateFavoriteStatus(
|
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updateFavoriteStatus(
|
||||||
noisePublicKey = noiseKey,
|
noisePublicKey = noiseKey!!,
|
||||||
nickname = nickname,
|
nickname = nickname,
|
||||||
isFavorite = isNowFavorite
|
isFavorite = isNowFavorite
|
||||||
)
|
)
|
||||||
@@ -596,7 +721,7 @@ class ChatViewModel(
|
|||||||
|
|
||||||
// Clear Nostr/geohash state, keys, connections, and reinitialize from scratch
|
// Clear Nostr/geohash state, keys, connections, and reinitialize from scratch
|
||||||
try {
|
try {
|
||||||
nostrGeohashService.panicResetNostrAndGeohash()
|
geohashViewModel.panicReset()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}")
|
Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}")
|
||||||
}
|
}
|
||||||
@@ -638,70 +763,73 @@ class ChatViewModel(
|
|||||||
try {
|
try {
|
||||||
val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication())
|
val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication())
|
||||||
identityManager.clearIdentityData()
|
identityManager.clearIdentityData()
|
||||||
Log.d(TAG, "✅ Cleared secure identity state")
|
// Also clear secure values used by FavoritesPersistenceService (favorites + peerID index)
|
||||||
|
try {
|
||||||
|
identityManager.clearSecureValues("favorite_relationships", "favorite_peerid_index")
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
Log.d(TAG, "✅ Cleared secure identity state and secure favorites store")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.d(TAG, "SecureIdentityStateManager not available or already cleared: ${e.message}")
|
Log.d(TAG, "SecureIdentityStateManager not available or already cleared: ${e.message}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear FavoritesPersistenceService persistent relationships
|
||||||
|
try {
|
||||||
|
com.bitchat.android.favorites.FavoritesPersistenceService.shared.clearAllFavorites()
|
||||||
|
Log.d(TAG, "✅ Cleared FavoritesPersistenceService relationships")
|
||||||
|
} catch (_: Exception) { }
|
||||||
|
|
||||||
Log.d(TAG, "✅ Cleared all cryptographic data")
|
Log.d(TAG, "✅ Cleared all cryptographic data")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "❌ Error clearing cryptographic data: ${e.message}")
|
Log.e(TAG, "❌ Error clearing cryptographic data: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Get messages for a specific geohash timeline
|
|
||||||
*/
|
|
||||||
fun getGeohashMessages(geohash: String): List<BitchatMessage> {
|
|
||||||
return nostrGeohashService.getGeohashMessages(geohash)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get participant count for a specific geohash (5-minute activity window)
|
* Get participant count for a specific geohash (5-minute activity window)
|
||||||
*/
|
*/
|
||||||
fun geohashParticipantCount(geohash: String): Int {
|
fun geohashParticipantCount(geohash: String): Int {
|
||||||
return nostrGeohashService.geohashParticipantCount(geohash)
|
return geohashViewModel.geohashParticipantCount(geohash)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Begin sampling multiple geohashes for participant activity
|
* Begin sampling multiple geohashes for participant activity
|
||||||
*/
|
*/
|
||||||
fun beginGeohashSampling(geohashes: List<String>) {
|
fun beginGeohashSampling(geohashes: List<String>) {
|
||||||
nostrGeohashService.beginGeohashSampling(geohashes)
|
geohashViewModel.beginGeohashSampling(geohashes)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* End geohash sampling
|
* End geohash sampling
|
||||||
*/
|
*/
|
||||||
fun endGeohashSampling() {
|
fun endGeohashSampling() {
|
||||||
nostrGeohashService.endGeohashSampling()
|
// No-op in refactored architecture; sampling subscriptions are short-lived
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a geohash person is teleported (iOS-compatible)
|
* Check if a geohash person is teleported (iOS-compatible)
|
||||||
*/
|
*/
|
||||||
fun isPersonTeleported(pubkeyHex: String): Boolean {
|
fun isPersonTeleported(pubkeyHex: String): Boolean {
|
||||||
return nostrGeohashService.isPersonTeleported(pubkeyHex)
|
return geohashViewModel.isPersonTeleported(pubkeyHex)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start geohash DM with pubkey hex (iOS-compatible)
|
* Start geohash DM with pubkey hex (iOS-compatible)
|
||||||
*/
|
*/
|
||||||
fun startGeohashDM(pubkeyHex: String) {
|
fun startGeohashDM(pubkeyHex: String) {
|
||||||
nostrGeohashService.startGeohashDM(pubkeyHex) { convKey ->
|
geohashViewModel.startGeohashDM(pubkeyHex) { convKey ->
|
||||||
startPrivateChat(convKey)
|
startPrivateChat(convKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) {
|
fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) {
|
||||||
nostrGeohashService.selectLocationChannel(channel)
|
geohashViewModel.selectLocationChannel(channel)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Block a user in geohash channels by their nickname
|
* Block a user in geohash channels by their nickname
|
||||||
*/
|
*/
|
||||||
fun blockUserInGeohash(targetNickname: String) {
|
fun blockUserInGeohash(targetNickname: String) {
|
||||||
nostrGeohashService.blockUserInGeohash(targetNickname)
|
geohashViewModel.blockUserInGeohash(targetNickname)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Navigation Management
|
// MARK: - Navigation Management
|
||||||
@@ -774,6 +902,6 @@ class ChatViewModel(
|
|||||||
* Get consistent color for a Nostr pubkey (iOS-compatible)
|
* Get consistent color for a Nostr pubkey (iOS-compatible)
|
||||||
*/
|
*/
|
||||||
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
|
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
|
||||||
return nostrGeohashService.colorForNostrPubkey(pubkeyHex, isDark)
|
return geohashViewModel.colorForNostrPubkey(pubkeyHex, isDark)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ class CommandProcessor(
|
|||||||
val actionMessage = "* ${state.getNicknameValue() ?: "someone"} $verb $targetName $object_ *"
|
val actionMessage = "* ${state.getNicknameValue() ?: "someone"} $verb $targetName $object_ *"
|
||||||
|
|
||||||
// If we're in a geohash location channel, don't add a local echo here.
|
// If we're in a geohash location channel, don't add a local echo here.
|
||||||
// NostrGeohashService.sendGeohashMessage() will add the local echo with proper metadata.
|
// GeohashViewModel.sendGeohashMessage() will add the local echo with proper metadata.
|
||||||
val isInLocationChannel = state.selectedLocationChannel.value is com.bitchat.android.geohash.ChannelID.Location
|
val isInLocationChannel = state.selectedLocationChannel.value is com.bitchat.android.geohash.ChannelID.Location
|
||||||
|
|
||||||
// Send as regular message
|
// Send as regular message
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
package com.bitchat.android.ui
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.WebChromeClient
|
||||||
|
import android.webkit.WebSettings
|
||||||
|
import android.webkit.WebView
|
||||||
|
import android.webkit.WebViewClient
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Remove
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import androidx.core.view.updateLayoutParams
|
||||||
|
import com.bitchat.android.geohash.Geohash
|
||||||
|
import com.bitchat.android.geohash.LocationChannelManager
|
||||||
|
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
class GeohashPickerActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val EXTRA_INITIAL_GEOHASH = "initial_geohash"
|
||||||
|
const val EXTRA_RESULT_GEOHASH = "result_geohash"
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
val initialGeohash = intent.getStringExtra(EXTRA_INITIAL_GEOHASH)?.trim()?.lowercase()
|
||||||
|
var geohashToFocus: String? = null
|
||||||
|
var (initLat, initLon) = 0.0 to 0.0
|
||||||
|
|
||||||
|
if (!initialGeohash.isNullOrEmpty()) {
|
||||||
|
geohashToFocus = initialGeohash
|
||||||
|
try {
|
||||||
|
val (lat, lon) = Geohash.decodeToCenter(initialGeohash)
|
||||||
|
initLat = lat
|
||||||
|
initLon = lon
|
||||||
|
} catch (_: Throwable) {}
|
||||||
|
} else {
|
||||||
|
// If no initial geohash, try to use the user's coarsest location
|
||||||
|
val locationManager = LocationChannelManager.getInstance(applicationContext)
|
||||||
|
val channels = locationManager.availableChannels.value
|
||||||
|
if (!channels.isNullOrEmpty()) {
|
||||||
|
val coarsestChannel = channels.minByOrNull { it.geohash.length }
|
||||||
|
if (coarsestChannel != null) {
|
||||||
|
geohashToFocus = coarsestChannel.geohash
|
||||||
|
try {
|
||||||
|
val (lat, lon) = Geohash.decodeToCenter(coarsestChannel.geohash)
|
||||||
|
initLat = lat
|
||||||
|
initLon = lon
|
||||||
|
} catch (_: Throwable) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val initialPrecision = geohashToFocus?.length ?: 5
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
var currentGeohash by remember { mutableStateOf(geohashToFocus ?: "") }
|
||||||
|
var precision by remember { mutableStateOf(initialPrecision.coerceIn(1, 12)) }
|
||||||
|
var webViewRef by remember { mutableStateOf<WebView?>(null) }
|
||||||
|
|
||||||
|
// iOS system-like colors used across app
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||||
|
val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
|
||||||
|
|
||||||
|
Scaffold { padding ->
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
AndroidView(
|
||||||
|
factory = { context ->
|
||||||
|
WebView(context).apply {
|
||||||
|
settings.javaScriptEnabled = true
|
||||||
|
settings.domStorageEnabled = true
|
||||||
|
settings.cacheMode = WebSettings.LOAD_DEFAULT
|
||||||
|
settings.allowFileAccess = true
|
||||||
|
settings.allowContentAccess = true
|
||||||
|
webChromeClient = WebChromeClient()
|
||||||
|
webViewClient = object : WebViewClient() {
|
||||||
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
|
super.onPageFinished(view, url)
|
||||||
|
// Initialize to last/initial geohash if provided, otherwise center
|
||||||
|
if (!geohashToFocus.isNullOrEmpty()) {
|
||||||
|
evaluateJavascript(
|
||||||
|
"window.focusGeohash('${geohashToFocus}')",
|
||||||
|
null
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
evaluateJavascript(
|
||||||
|
"window.setCenter(${initLat}, ${initLon})",
|
||||||
|
null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply theme to map tiles
|
||||||
|
val nightModeFlags = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
|
||||||
|
val theme = if (nightModeFlags == Configuration.UI_MODE_NIGHT_YES) "dark" else "light"
|
||||||
|
evaluateJavascript("window.setMapTheme('" + theme + "')", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addJavascriptInterface(object {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun onGeohashChanged(geohash: String) {
|
||||||
|
runOnUiThread {
|
||||||
|
currentGeohash = geohash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, "Android")
|
||||||
|
|
||||||
|
loadUrl("file:///android_asset/geohash_picker.html")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding),
|
||||||
|
update = { webView ->
|
||||||
|
webViewRef = webView
|
||||||
|
// ensure it fills parent
|
||||||
|
webView.updateLayoutParams<ViewGroup.LayoutParams> {
|
||||||
|
width = ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
height = ViewGroup.LayoutParams.MATCH_PARENT
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onRelease = { webView ->
|
||||||
|
// Best-effort cleanup to avoid leaks and timers
|
||||||
|
try { webView.evaluateJavascript("window.cleanup && window.cleanup()", null) } catch (_: Throwable) {}
|
||||||
|
try { webView.stopLoading() } catch (_: Throwable) {}
|
||||||
|
try { webView.clearHistory() } catch (_: Throwable) {}
|
||||||
|
try { webView.clearCache(true) } catch (_: Throwable) {}
|
||||||
|
try { webView.loadUrl("about:blank") } catch (_: Throwable) {}
|
||||||
|
try { webView.removeAllViews() } catch (_: Throwable) {}
|
||||||
|
try { webView.destroy() } catch (_: Throwable) {}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Floating info pill
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopCenter)
|
||||||
|
.padding(top = 20.dp)
|
||||||
|
.fillMaxWidth(0.75f),
|
||||||
|
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
tonalElevation = 3.dp,
|
||||||
|
shadowElevation = 6.dp
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "pan and zoom to select a geohash",
|
||||||
|
fontSize = 12.sp,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 14.dp, vertical = 10.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Floating bottom controls
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.padding(bottom = 20.dp, start = 16.dp, end = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
// Geohash label (monospace, app style)
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
tonalElevation = 3.dp,
|
||||||
|
shadowElevation = 6.dp
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (currentGeohash.isNotEmpty()) "#${currentGeohash}" else "select location",
|
||||||
|
fontSize = BASE_FONT_SIZE.sp,
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 14.dp, vertical = 10.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Button row
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Decrease precision
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
precision = (precision - 1).coerceAtLeast(1)
|
||||||
|
webViewRef?.evaluateJavascript("window.setPrecision($precision)", null)
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = standardGreen.copy(alpha = 0.12f),
|
||||||
|
contentColor = standardGreen
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Filled.Remove, contentDescription = "Decrease precision")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increase precision
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
precision = (precision + 1).coerceAtMost(12)
|
||||||
|
webViewRef?.evaluateJavascript("window.setPrecision($precision)", null)
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = standardGreen.copy(alpha = 0.12f),
|
||||||
|
contentColor = standardGreen
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Filled.Add, contentDescription = "Increase precision")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select button
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
webViewRef?.evaluateJavascript("window.getGeohash()") { value ->
|
||||||
|
val gh = value?.trim('"') ?: currentGeohash
|
||||||
|
val result = Intent().apply { putExtra(EXTRA_RESULT_GEOHASH, gh) }
|
||||||
|
setResult(Activity.RESULT_OK, result)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Filled.Check, contentDescription = "Select geohash")
|
||||||
|
Spacer(Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = "select",
|
||||||
|
fontSize = (BASE_FONT_SIZE - 2).sp,
|
||||||
|
fontFamily = FontFamily.Monospace
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package com.bitchat.android.ui
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
|
import androidx.lifecycle.LiveData
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.bitchat.android.nostr.GeohashMessageHandler
|
||||||
|
import com.bitchat.android.nostr.GeohashRepository
|
||||||
|
import com.bitchat.android.nostr.NostrDirectMessageHandler
|
||||||
|
import com.bitchat.android.nostr.NostrIdentityBridge
|
||||||
|
import com.bitchat.android.nostr.NostrProtocol
|
||||||
|
import com.bitchat.android.nostr.NostrRelayManager
|
||||||
|
import com.bitchat.android.nostr.NostrSubscriptionManager
|
||||||
|
import com.bitchat.android.nostr.PoWPreferenceManager
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
class GeohashViewModel(
|
||||||
|
application: Application,
|
||||||
|
private val state: ChatState,
|
||||||
|
private val messageManager: MessageManager,
|
||||||
|
private val privateChatManager: PrivateChatManager,
|
||||||
|
private val meshDelegateHandler: MeshDelegateHandler,
|
||||||
|
private val dataManager: DataManager,
|
||||||
|
private val notificationManager: NotificationManager
|
||||||
|
) : AndroidViewModel(application) {
|
||||||
|
|
||||||
|
companion object { private const val TAG = "GeohashViewModel" }
|
||||||
|
|
||||||
|
private val repo = GeohashRepository(application, state)
|
||||||
|
private val subscriptionManager = NostrSubscriptionManager(application, viewModelScope)
|
||||||
|
private val geohashMessageHandler = GeohashMessageHandler(application, state, messageManager, repo, viewModelScope)
|
||||||
|
private val dmHandler = NostrDirectMessageHandler(application, state, privateChatManager, meshDelegateHandler, viewModelScope, repo)
|
||||||
|
|
||||||
|
private var currentGeohashSubId: String? = null
|
||||||
|
private var currentDmSubId: String? = null
|
||||||
|
private var geoTimer: Job? = null
|
||||||
|
private var locationChannelManager: com.bitchat.android.geohash.LocationChannelManager? = null
|
||||||
|
|
||||||
|
val geohashPeople: LiveData<List<GeoPerson>> = state.geohashPeople
|
||||||
|
val geohashParticipantCounts: LiveData<Map<String, Int>> = state.geohashParticipantCounts
|
||||||
|
val selectedLocationChannel: LiveData<com.bitchat.android.geohash.ChannelID?> = state.selectedLocationChannel
|
||||||
|
|
||||||
|
fun initialize() {
|
||||||
|
subscriptionManager.connect()
|
||||||
|
val identity = NostrIdentityBridge.getCurrentNostrIdentity(getApplication())
|
||||||
|
if (identity != null) {
|
||||||
|
// Use global chat-messages only for full account DMs (mesh context). For geohash DMs, subscribe per-geohash below.
|
||||||
|
subscriptionManager.subscribeGiftWraps(
|
||||||
|
pubkey = identity.publicKeyHex,
|
||||||
|
sinceMs = System.currentTimeMillis() - 172800000L,
|
||||||
|
id = "chat-messages",
|
||||||
|
handler = { event -> dmHandler.onGiftWrap(event, "", identity) } // geohash="" means global account DM (not geohash identity)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
locationChannelManager = com.bitchat.android.geohash.LocationChannelManager.getInstance(getApplication())
|
||||||
|
locationChannelManager?.selectedChannel?.observeForever { channel ->
|
||||||
|
state.setSelectedLocationChannel(channel)
|
||||||
|
switchLocationChannel(channel)
|
||||||
|
}
|
||||||
|
locationChannelManager?.teleported?.observeForever { teleported ->
|
||||||
|
state.setIsTeleported(teleported)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to initialize location channel state: ${e.message}")
|
||||||
|
state.setSelectedLocationChannel(com.bitchat.android.geohash.ChannelID.Mesh)
|
||||||
|
state.setIsTeleported(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun panicReset() {
|
||||||
|
repo.clearAll()
|
||||||
|
subscriptionManager.disconnect()
|
||||||
|
currentGeohashSubId = null
|
||||||
|
currentDmSubId = null
|
||||||
|
geoTimer?.cancel()
|
||||||
|
geoTimer = null
|
||||||
|
try { NostrIdentityBridge.clearAllAssociations(getApplication()) } catch (_: Exception) {}
|
||||||
|
initialize()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendGeohashMessage(content: String, channel: com.bitchat.android.geohash.GeohashChannel, myPeerID: String, nickname: String?) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
val tempId = "temp_${System.currentTimeMillis()}_${kotlin.random.Random.nextInt(1000)}"
|
||||||
|
val pow = PoWPreferenceManager.getCurrentSettings()
|
||||||
|
val localMsg = com.bitchat.android.model.BitchatMessage(
|
||||||
|
id = tempId,
|
||||||
|
sender = nickname ?: myPeerID,
|
||||||
|
content = content,
|
||||||
|
timestamp = Date(),
|
||||||
|
isRelay = false,
|
||||||
|
senderPeerID = "geohash:${channel.geohash}",
|
||||||
|
channel = "#${channel.geohash}",
|
||||||
|
powDifficulty = if (pow.enabled) pow.difficulty else null
|
||||||
|
)
|
||||||
|
messageManager.addChannelMessage("geo:${channel.geohash}", localMsg)
|
||||||
|
if (pow.enabled && pow.difficulty > 0) {
|
||||||
|
com.bitchat.android.ui.PoWMiningTracker.startMiningMessage(tempId)
|
||||||
|
}
|
||||||
|
val identity = NostrIdentityBridge.deriveIdentity(forGeohash = channel.geohash, context = getApplication())
|
||||||
|
val teleported = state.isTeleported.value ?: false
|
||||||
|
val event = NostrProtocol.createEphemeralGeohashEvent(content, channel.geohash, identity, nickname, teleported)
|
||||||
|
val relayManager = NostrRelayManager.getInstance(getApplication())
|
||||||
|
relayManager.sendEventToGeohash(event, channel.geohash, includeDefaults = false, nRelays = 5)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to send geohash message: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun beginGeohashSampling(geohashes: List<String>) {
|
||||||
|
if (geohashes.isEmpty()) return
|
||||||
|
Log.d(TAG, "🌍 Beginning geohash sampling for ${geohashes.size} geohashes")
|
||||||
|
viewModelScope.launch {
|
||||||
|
geohashes.forEach { geohash ->
|
||||||
|
subscriptionManager.subscribeGeohash(
|
||||||
|
geohash = geohash,
|
||||||
|
sinceMs = System.currentTimeMillis() - 86400000L,
|
||||||
|
limit = 200,
|
||||||
|
id = "sampling-$geohash",
|
||||||
|
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun endGeohashSampling() { Log.d(TAG, "🌍 Ending geohash sampling") }
|
||||||
|
fun geohashParticipantCount(geohash: String): Int = repo.geohashParticipantCount(geohash)
|
||||||
|
fun isPersonTeleported(pubkeyHex: String): Boolean = repo.isPersonTeleported(pubkeyHex)
|
||||||
|
|
||||||
|
fun startGeohashDM(pubkeyHex: String, onStartPrivateChat: (String) -> Unit) {
|
||||||
|
val convKey = "nostr_${pubkeyHex.take(16)}"
|
||||||
|
repo.putNostrKeyMapping(convKey, pubkeyHex)
|
||||||
|
onStartPrivateChat(convKey)
|
||||||
|
Log.d(TAG, "🗨️ Started geohash DM with $pubkeyHex -> $convKey")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getNostrKeyMapping(): Map<String, String> = repo.getNostrKeyMapping()
|
||||||
|
|
||||||
|
fun blockUserInGeohash(targetNickname: String) {
|
||||||
|
val pubkey = repo.findPubkeyByNickname(targetNickname)
|
||||||
|
if (pubkey != null) {
|
||||||
|
dataManager.addGeohashBlockedUser(pubkey)
|
||||||
|
val sysMsg = com.bitchat.android.model.BitchatMessage(
|
||||||
|
sender = "system",
|
||||||
|
content = "blocked $targetNickname in geohash channels",
|
||||||
|
timestamp = Date(),
|
||||||
|
isRelay = false
|
||||||
|
)
|
||||||
|
fun startGeohashDM(pubkeyHex: String, onStartPrivateChat: (String) -> Unit) {
|
||||||
|
val convKey = "nostr_${'$'}{pubkeyHex.take(16)}"
|
||||||
|
repo.putNostrKeyMapping(convKey, pubkeyHex)
|
||||||
|
// Record the conversation's geohash using the currently selected location channel (if any)
|
||||||
|
val current = state.selectedLocationChannel.value
|
||||||
|
val gh = (current as? com.bitchat.android.geohash.ChannelID.Location)?.channel?.geohash
|
||||||
|
if (!gh.isNullOrEmpty()) {
|
||||||
|
repo.setConversationGeohash(convKey, gh)
|
||||||
|
com.bitchat.android.nostr.GeohashConversationRegistry.set(convKey, gh)
|
||||||
|
}
|
||||||
|
onStartPrivateChat(convKey)
|
||||||
|
Log.d(TAG, "🗨️ Started geohash DM with ${'$'}pubkeyHex -> ${'$'}convKey (geohash=${'$'}gh)")
|
||||||
|
}
|
||||||
|
|
||||||
|
messageManager.addMessage(sysMsg)
|
||||||
|
} else {
|
||||||
|
val sysMsg = com.bitchat.android.model.BitchatMessage(
|
||||||
|
sender = "system",
|
||||||
|
content = "user '$targetNickname' not found in current geohash",
|
||||||
|
timestamp = Date(),
|
||||||
|
isRelay = false
|
||||||
|
)
|
||||||
|
messageManager.addMessage(sysMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) {
|
||||||
|
locationChannelManager?.select(channel) ?: run { Log.w(TAG, "Cannot select location channel - not initialized") }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun displayNameForNostrPubkeyUI(pubkeyHex: String): String = repo.displayNameForNostrPubkeyUI(pubkeyHex)
|
||||||
|
|
||||||
|
fun colorForNostrPubkey(pubkeyHex: String, isDark: Boolean): androidx.compose.ui.graphics.Color {
|
||||||
|
val seed = "nostr:${pubkeyHex.lowercase()}"
|
||||||
|
return colorForPeerSeed(seed, isDark).copy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun switchLocationChannel(channel: com.bitchat.android.geohash.ChannelID?) {
|
||||||
|
geoTimer?.cancel(); geoTimer = null
|
||||||
|
currentGeohashSubId?.let { subscriptionManager.unsubscribe(it); currentGeohashSubId = null }
|
||||||
|
currentDmSubId?.let { subscriptionManager.unsubscribe(it); currentDmSubId = null }
|
||||||
|
|
||||||
|
when (channel) {
|
||||||
|
is com.bitchat.android.geohash.ChannelID.Mesh -> {
|
||||||
|
Log.d(TAG, "📡 Switched to mesh channel")
|
||||||
|
repo.setCurrentGeohash(null)
|
||||||
|
notificationManager.setCurrentGeohash(null)
|
||||||
|
notificationManager.clearMeshMentionNotifications()
|
||||||
|
repo.refreshGeohashPeople()
|
||||||
|
}
|
||||||
|
is com.bitchat.android.geohash.ChannelID.Location -> {
|
||||||
|
Log.d(TAG, "📍 Switching to geohash channel: ${channel.channel.geohash}")
|
||||||
|
repo.setCurrentGeohash(channel.channel.geohash)
|
||||||
|
notificationManager.setCurrentGeohash(channel.channel.geohash)
|
||||||
|
notificationManager.clearNotificationsForGeohash(channel.channel.geohash)
|
||||||
|
try { messageManager.clearChannelUnreadCount("geo:${channel.channel.geohash}") } catch (_: Exception) { }
|
||||||
|
|
||||||
|
try {
|
||||||
|
val identity = NostrIdentityBridge.deriveIdentity(channel.channel.geohash, getApplication())
|
||||||
|
repo.updateParticipant(channel.channel.geohash, identity.publicKeyHex, Date())
|
||||||
|
val teleported = state.isTeleported.value ?: false
|
||||||
|
if (teleported) repo.markTeleported(identity.publicKeyHex)
|
||||||
|
} catch (e: Exception) { Log.w(TAG, "Failed identity setup: ${e.message}") }
|
||||||
|
|
||||||
|
startGeoParticipantsTimer()
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
val geohash = channel.channel.geohash
|
||||||
|
val subId = "geohash-$geohash"; currentGeohashSubId = subId
|
||||||
|
subscriptionManager.subscribeGeohash(
|
||||||
|
geohash = geohash,
|
||||||
|
sinceMs = System.currentTimeMillis() - 3600000L,
|
||||||
|
limit = 200,
|
||||||
|
id = subId,
|
||||||
|
handler = { event -> geohashMessageHandler.onEvent(event, geohash) }
|
||||||
|
)
|
||||||
|
val dmIdentity = NostrIdentityBridge.deriveIdentity(geohash, getApplication())
|
||||||
|
val dmSubId = "geo-dm-$geohash"; currentDmSubId = dmSubId
|
||||||
|
subscriptionManager.subscribeGiftWraps(
|
||||||
|
pubkey = dmIdentity.publicKeyHex,
|
||||||
|
sinceMs = System.currentTimeMillis() - 172800000L,
|
||||||
|
id = dmSubId,
|
||||||
|
handler = { event -> dmHandler.onGiftWrap(event, geohash, dmIdentity) }
|
||||||
|
)
|
||||||
|
// Also register alias in global registry for routing convenience
|
||||||
|
com.bitchat.android.nostr.GeohashAliasRegistry.put("nostr_${dmIdentity.publicKeyHex.take(16)}", dmIdentity.publicKeyHex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
null -> {
|
||||||
|
Log.d(TAG, "📡 No channel selected")
|
||||||
|
repo.setCurrentGeohash(null)
|
||||||
|
repo.refreshGeohashPeople()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startGeoParticipantsTimer() {
|
||||||
|
geoTimer = viewModelScope.launch {
|
||||||
|
while (repo.getCurrentGeohash() != null) {
|
||||||
|
delay(30000)
|
||||||
|
repo.refreshGeohashPeople()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Map
|
||||||
import androidx.compose.material.icons.filled.PinDrop
|
import androidx.compose.material.icons.filled.PinDrop
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
@@ -30,6 +31,8 @@ import com.bitchat.android.geohash.GeohashChannel
|
|||||||
import com.bitchat.android.geohash.GeohashChannelLevel
|
import com.bitchat.android.geohash.GeohashChannelLevel
|
||||||
import com.bitchat.android.geohash.LocationChannelManager
|
import com.bitchat.android.geohash.LocationChannelManager
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Location Channels Sheet for selecting geohash-based location channels
|
* Location Channels Sheet for selecting geohash-based location channels
|
||||||
@@ -71,6 +74,18 @@ fun LocationChannelsSheet(
|
|||||||
// Scroll state for LazyColumn
|
// Scroll state for LazyColumn
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
val mapPickerLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.StartActivityForResult()
|
||||||
|
) { result ->
|
||||||
|
if (result.resultCode == android.app.Activity.RESULT_OK) {
|
||||||
|
val gh = result.data?.getStringExtra(GeohashPickerActivity.EXTRA_RESULT_GEOHASH)
|
||||||
|
if (!gh.isNullOrBlank()) {
|
||||||
|
customGeohash = gh
|
||||||
|
customError = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// iOS system colors (matches iOS exactly)
|
// iOS system colors (matches iOS exactly)
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
|
||||||
@@ -312,6 +327,26 @@ fun LocationChannelsSheet(
|
|||||||
)
|
)
|
||||||
|
|
||||||
val normalized = customGeohash.trim().lowercase().replace("#", "")
|
val normalized = customGeohash.trim().lowercase().replace("#", "")
|
||||||
|
|
||||||
|
// Map picker button
|
||||||
|
IconButton(onClick = {
|
||||||
|
val initial = when {
|
||||||
|
normalized.isNotBlank() -> normalized
|
||||||
|
selectedChannel is ChannelID.Location -> (selectedChannel as ChannelID.Location).channel.geohash
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
val intent = Intent(context, GeohashPickerActivity::class.java).apply {
|
||||||
|
putExtra(GeohashPickerActivity.EXTRA_INITIAL_GEOHASH, initial)
|
||||||
|
}
|
||||||
|
mapPickerLauncher.launch(intent)
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Map,
|
||||||
|
contentDescription = "Open map",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
val isValid = validateGeohash(normalized)
|
val isValid = validateGeohash(normalized)
|
||||||
|
|
||||||
// iOS-style teleport button
|
// iOS-style teleport button
|
||||||
|
|||||||
@@ -107,16 +107,21 @@ class MeshDelegateHandler(
|
|||||||
meshNoiseKeyForPeer = { pid -> getPeerInfo(pid)?.noisePublicKey },
|
meshNoiseKeyForPeer = { pid -> getPeerInfo(pid)?.noisePublicKey },
|
||||||
meshHasPeer = { pid -> peers.contains(pid) },
|
meshHasPeer = { pid -> peers.contains(pid) },
|
||||||
nostrPubHexForAlias = { alias ->
|
nostrPubHexForAlias = { alias ->
|
||||||
// Best-effort: derive pub hex from favorites mapping
|
// Use GeohashAliasRegistry for geohash aliases, but for mesh favorites, derive from favorites mapping
|
||||||
val prefix = alias.removePrefix("nostr_")
|
if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(alias)) {
|
||||||
val favs = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() } catch (_: Exception) { emptyList() }
|
com.bitchat.android.nostr.GeohashAliasRegistry.get(alias)
|
||||||
favs.firstNotNullOfOrNull { rel ->
|
} else {
|
||||||
rel.peerNostrPublicKey?.let { s ->
|
// Best-effort: derive pub hex from favorites mapping for mesh nostr_ aliases
|
||||||
runCatching { com.bitchat.android.nostr.Bech32.decode(s) }.getOrNull()?.let { dec ->
|
val prefix = alias.removePrefix("nostr_")
|
||||||
if (dec.first == "npub") dec.second.joinToString("") { b -> "%02x".format(b) } else null
|
val favs = try { com.bitchat.android.favorites.FavoritesPersistenceService.shared.getOurFavorites() } catch (_: Exception) { emptyList() }
|
||||||
|
favs.firstNotNullOfOrNull { rel ->
|
||||||
|
rel.peerNostrPublicKey?.let { s ->
|
||||||
|
runCatching { com.bitchat.android.nostr.Bech32.decode(s) }.getOrNull()?.let { dec ->
|
||||||
|
if (dec.first == "npub") dec.second.joinToString("") { b -> "%02x".format(b) } else null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}?.takeIf { it.startsWith(prefix, ignoreCase = true) }
|
||||||
}?.takeIf { it.startsWith(prefix, ignoreCase = true) }
|
}
|
||||||
},
|
},
|
||||||
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
findNoiseKeyForNostr = { key -> com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNoiseKey(key) }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -52,8 +52,18 @@ class MessageManager(private val state: ChatState) {
|
|||||||
currentChannelMessages[channel] = channelMessageList
|
currentChannelMessages[channel] = channelMessageList
|
||||||
state.setChannelMessages(currentChannelMessages)
|
state.setChannelMessages(currentChannelMessages)
|
||||||
|
|
||||||
// Update unread count if not currently in this channel
|
// Update unread count if not currently viewing this channel
|
||||||
if (state.getCurrentChannelValue() != channel) {
|
// Consider both classic channels (state.currentChannel) and geohash location channel selection
|
||||||
|
val viewingClassicChannel = state.getCurrentChannelValue() == channel
|
||||||
|
val viewingGeohashChannel = try {
|
||||||
|
if (channel.startsWith("geo:")) {
|
||||||
|
val geo = channel.removePrefix("geo:")
|
||||||
|
val selected = state.selectedLocationChannel.value
|
||||||
|
selected is com.bitchat.android.geohash.ChannelID.Location && selected.channel.geohash.equals(geo, ignoreCase = true)
|
||||||
|
} else false
|
||||||
|
} catch (_: Exception) { false }
|
||||||
|
|
||||||
|
if (!viewingClassicChannel && !viewingGeohashChannel) {
|
||||||
val currentUnread = state.getUnreadChannelMessagesValue().toMutableMap()
|
val currentUnread = state.getUnreadChannelMessagesValue().toMutableMap()
|
||||||
currentUnread[channel] = (currentUnread[channel] ?: 0) + 1
|
currentUnread[channel] = (currentUnread[channel] ?: 0) + 1
|
||||||
state.setUnreadChannelMessages(currentUnread)
|
state.setUnreadChannelMessages(currentUnread)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.bitchat.android.ui
|
|||||||
import com.bitchat.android.model.BitchatMessage
|
import com.bitchat.android.model.BitchatMessage
|
||||||
import com.bitchat.android.model.DeliveryStatus
|
import com.bitchat.android.model.DeliveryStatus
|
||||||
import com.bitchat.android.mesh.PeerFingerprintManager
|
import com.bitchat.android.mesh.PeerFingerprintManager
|
||||||
|
import java.security.MessageDigest
|
||||||
|
|
||||||
import com.bitchat.android.mesh.BluetoothMeshService
|
import com.bitchat.android.mesh.BluetoothMeshService
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
@@ -123,21 +125,40 @@ class PrivateChatManager(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun toggleFavorite(peerID: String) {
|
fun toggleFavorite(peerID: String) {
|
||||||
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) ?: return
|
var fingerprint = fingerprintManager.getFingerprintForPeer(peerID)
|
||||||
|
|
||||||
|
// Fallback: if this looks like a 64-hex Noise public key (offline favorite entry),
|
||||||
|
// compute a synthetic fingerprint (SHA-256 of public key) to allow unfollowing offline peers
|
||||||
|
if (fingerprint == null && peerID.length == 64 && peerID.matches(Regex("^[0-9a-fA-F]+$"))) {
|
||||||
|
try {
|
||||||
|
val pubBytes = peerID.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
|
||||||
|
val digest = java.security.MessageDigest.getInstance("SHA-256")
|
||||||
|
val fpBytes = digest.digest(pubBytes)
|
||||||
|
fingerprint = fpBytes.joinToString("") { "%02x".format(it) }
|
||||||
|
Log.d(TAG, "Computed fingerprint from noise key hex for offline toggle: $fingerprint")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to compute fingerprint from noise key hex: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fingerprint == null) {
|
||||||
|
Log.w(TAG, "toggleFavorite: no fingerprint for peerID=$peerID; ignoring toggle")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Log.d(TAG, "toggleFavorite called for peerID: $peerID, fingerprint: $fingerprint")
|
Log.d(TAG, "toggleFavorite called for peerID: $peerID, fingerprint: $fingerprint")
|
||||||
|
|
||||||
val wasFavorite = dataManager.isFavorite(fingerprint)
|
val wasFavorite = dataManager.isFavorite(fingerprint!!)
|
||||||
Log.d(TAG, "Current favorite status: $wasFavorite")
|
Log.d(TAG, "Current favorite status: $wasFavorite")
|
||||||
|
|
||||||
val currentFavorites = state.getFavoritePeersValue()
|
val currentFavorites = state.getFavoritePeersValue()
|
||||||
Log.d(TAG, "Current UI state favorites: $currentFavorites")
|
Log.d(TAG, "Current UI state favorites: $currentFavorites")
|
||||||
|
|
||||||
if (wasFavorite) {
|
if (wasFavorite) {
|
||||||
dataManager.removeFavorite(fingerprint)
|
dataManager.removeFavorite(fingerprint!!)
|
||||||
Log.d(TAG, "Removed from favorites: $fingerprint")
|
Log.d(TAG, "Removed from favorites: $fingerprint")
|
||||||
} else {
|
} else {
|
||||||
dataManager.addFavorite(fingerprint)
|
dataManager.addFavorite(fingerprint!!)
|
||||||
Log.d(TAG, "Added to favorites: $fingerprint")
|
Log.d(TAG, "Added to favorites: $fingerprint")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +170,7 @@ class PrivateChatManager(
|
|||||||
Log.d(TAG, "All peer fingerprints: ${fingerprintManager.getAllPeerFingerprints()}")
|
Log.d(TAG, "All peer fingerprints: ${fingerprintManager.getAllPeerFingerprints()}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun isFavorite(peerID: String): Boolean {
|
fun isFavorite(peerID: String): Boolean {
|
||||||
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) ?: return false
|
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) ?: return false
|
||||||
val isFav = dataManager.isFavorite(fingerprint)
|
val isFav = dataManager.isFavorite(fingerprint)
|
||||||
@@ -365,87 +387,12 @@ class PrivateChatManager(
|
|||||||
"Our peer ID lexicographically >= target peer ID, sending identity announcement to prompt handshake from $peerID"
|
"Our peer ID lexicographically >= target peer ID, sending identity announcement to prompt handshake from $peerID"
|
||||||
)
|
)
|
||||||
meshService.sendAnnouncementToPeer(peerID)
|
meshService.sendAnnouncementToPeer(peerID)
|
||||||
|
Log.d(TAG, "Sent identity announcement to $peerID – starting handshake now from our side")
|
||||||
|
noiseSessionDelegate.initiateHandshake(peerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// /**
|
|
||||||
// * Legacy reflection-based implementation for backward compatibility
|
|
||||||
// */
|
|
||||||
// private fun establishNoiseSessionIfNeededLegacy(peerID: String, meshService: Any) {
|
|
||||||
// try {
|
|
||||||
// // Check if we already have an established Noise session with this peer
|
|
||||||
// val hasSessionMethod = meshService::class.java.getDeclaredMethod("hasEstablishedSession", String::class.java)
|
|
||||||
// val hasSession = hasSessionMethod.invoke(meshService, peerID) as Boolean
|
|
||||||
//
|
|
||||||
// if (hasSession) {
|
|
||||||
// Log.d(TAG, "Noise session already established with $peerID")
|
|
||||||
// return
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// Log.d(TAG, "No Noise session with $peerID, determining who should initiate handshake")
|
|
||||||
//
|
|
||||||
// // Get our peer ID from mesh service for lexicographical comparison
|
|
||||||
// val myPeerIDField = meshService::class.java.getField("myPeerID")
|
|
||||||
// val myPeerID = myPeerIDField.get(meshService) as String
|
|
||||||
//
|
|
||||||
// // Use lexicographical comparison to decide who initiates (same logic as MessageHandler)
|
|
||||||
// if (myPeerID < peerID) {
|
|
||||||
// // We should initiate the handshake
|
|
||||||
// Log.d(TAG, "Our peer ID lexicographically < target peer ID, initiating Noise handshake with $peerID")
|
|
||||||
// initiateHandshakeWithPeer(peerID, meshService)
|
|
||||||
// } else {
|
|
||||||
// // They should initiate, we send a Noise identity announcement
|
|
||||||
// Log.d(TAG, "Our peer ID lexicographically >= target peer ID, sending Noise identity announcement to prompt handshake from $peerID")
|
|
||||||
// sendNoiseIdentityAnnouncement(meshService)
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// } catch (e: Exception) {
|
|
||||||
// Log.e(TAG, "Failed to establish Noise session with $peerID: ${e.message}")
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initiate handshake with specific peer using the existing delegate pattern
|
|
||||||
*/
|
|
||||||
private fun initiateHandshakeWithPeer(peerID: String, meshService: Any) {
|
|
||||||
try {
|
|
||||||
// Use the existing MessageHandler delegate approach to initiate handshake
|
|
||||||
// This calls the same code that's in MessageHandler's delegate.initiateNoiseHandshake()
|
|
||||||
val messageHandler = meshService::class.java.getDeclaredField("messageHandler")
|
|
||||||
messageHandler.isAccessible = true
|
|
||||||
val handler = messageHandler.get(meshService)
|
|
||||||
|
|
||||||
val delegate = handler::class.java.getDeclaredField("delegate")
|
|
||||||
delegate.isAccessible = true
|
|
||||||
val handlerDelegate = delegate.get(handler)
|
|
||||||
|
|
||||||
val method =
|
|
||||||
handlerDelegate::class.java.getMethod("initiateNoiseHandshake", String::class.java)
|
|
||||||
method.invoke(handlerDelegate, peerID)
|
|
||||||
|
|
||||||
Log.d(TAG, "Successfully initiated Noise handshake with $peerID using delegate pattern")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to initiate Noise handshake with $peerID: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send Noise identity announcement to prompt other peer to initiate handshake
|
|
||||||
* This follows the same pattern as broadcastNoiseIdentityAnnouncement() in BluetoothMeshService
|
|
||||||
*/
|
|
||||||
private fun sendNoiseIdentityAnnouncement(meshService: Any) {
|
|
||||||
try {
|
|
||||||
// Call broadcastNoiseIdentityAnnouncement which sends a NoiseIdentityAnnouncement
|
|
||||||
val method =
|
|
||||||
meshService::class.java.getDeclaredMethod("broadcastNoiseIdentityAnnouncement")
|
|
||||||
method.invoke(meshService)
|
|
||||||
Log.d(TAG, "Successfully sent Noise identity announcement")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.e(TAG, "Failed to send Noise identity announcement: ${e.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Utility Functions
|
// MARK: - Utility Functions
|
||||||
|
|
||||||
private fun getPeerIDForNickname(nickname: String, meshService: BluetoothMeshService): String? {
|
private fun getPeerIDForNickname(nickname: String, meshService: BluetoothMeshService): String? {
|
||||||
|
|||||||
@@ -413,6 +413,22 @@ fun PeopleSection(
|
|||||||
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
|
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
|
||||||
if (isMappedToConnected) return@forEach
|
if (isMappedToConnected) return@forEach
|
||||||
|
|
||||||
|
// Resolve potential Nostr conversation key for this favorite (for unread detection)
|
||||||
|
val nostrConvKey: String? = try {
|
||||||
|
val npubOrHex = com.bitchat.android.favorites.FavoritesPersistenceService.shared.findNostrPubkey(fav.peerNoisePublicKey)
|
||||||
|
if (npubOrHex != null) {
|
||||||
|
val hex = if (npubOrHex.startsWith("npub")) {
|
||||||
|
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(npubOrHex)
|
||||||
|
if (hrp == "npub") data.joinToString("") { "%02x".format(it) } else null
|
||||||
|
} else {
|
||||||
|
npubOrHex.lowercase()
|
||||||
|
}
|
||||||
|
hex?.let { "nostr_${it.take(16)}" }
|
||||||
|
} else null
|
||||||
|
} catch (_: Exception) { null }
|
||||||
|
|
||||||
|
val hasUnread = hasUnreadPrivateMessages.contains(favPeerID) || (nostrConvKey != null && hasUnreadPrivateMessages.contains(nostrConvKey))
|
||||||
|
|
||||||
// If user clicks an offline favorite and the mapped peer is currently connected under a different ID,
|
// If user clicks an offline favorite and the mapped peer is currently connected under a different ID,
|
||||||
// open chat with the connected peerID instead of the noise hex for a seamless window
|
// open chat with the connected peerID instead of the noise hex for a seamless window
|
||||||
val mappedConnectedPeerID = noiseHexByPeerID.entries.firstOrNull { it.value.equals(favPeerID, ignoreCase = true) }?.key
|
val mappedConnectedPeerID = noiseHexByPeerID.entries.firstOrNull { it.value.equals(favPeerID, ignoreCase = true) }?.key
|
||||||
@@ -420,13 +436,20 @@ fun PeopleSection(
|
|||||||
val (bName, _) = com.bitchat.android.ui.splitSuffix(dn)
|
val (bName, _) = com.bitchat.android.ui.splitSuffix(dn)
|
||||||
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
||||||
|
|
||||||
|
// Compute unreadCount from either noise conversation or Nostr conversation
|
||||||
|
val unreadCount = (
|
||||||
|
privateChats[favPeerID]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(favPeerID) } ?: 0
|
||||||
|
) + (
|
||||||
|
if (nostrConvKey != null) privateChats[nostrConvKey]?.count { msg -> msg.sender != nickname && hasUnreadPrivateMessages.contains(nostrConvKey) } ?: 0 else 0
|
||||||
|
)
|
||||||
|
|
||||||
PeerItem(
|
PeerItem(
|
||||||
peerID = favPeerID,
|
peerID = favPeerID,
|
||||||
displayName = dn,
|
displayName = dn,
|
||||||
isDirect = false,
|
isDirect = false,
|
||||||
isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
|
isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
|
||||||
isFavorite = true,
|
isFavorite = true,
|
||||||
hasUnreadDM = hasUnreadPrivateMessages.contains(favPeerID),
|
hasUnreadDM = hasUnread,
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onItemClick = { onPrivateChatStart(mappedConnectedPeerID ?: favPeerID) },
|
onItemClick = { onPrivateChatStart(mappedConnectedPeerID ?: favPeerID) },
|
||||||
@@ -434,21 +457,23 @@ fun PeopleSection(
|
|||||||
Log.d("SidebarComponents", "Sidebar toggle favorite (offline): peerID=$favPeerID")
|
Log.d("SidebarComponents", "Sidebar toggle favorite (offline): peerID=$favPeerID")
|
||||||
viewModel.toggleFavorite(favPeerID)
|
viewModel.toggleFavorite(favPeerID)
|
||||||
},
|
},
|
||||||
unreadCount = privateChats[favPeerID]?.count { msg ->
|
unreadCount = if (unreadCount > 0) unreadCount else if (hasUnread) 1 else 0,
|
||||||
msg.sender != nickname && hasUnreadPrivateMessages.contains(favPeerID)
|
|
||||||
} ?: if (hasUnreadPrivateMessages.contains(favPeerID)) 1 else 0,
|
|
||||||
showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null),
|
showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null),
|
||||||
showHashSuffix = showHash
|
showHashSuffix = showHash
|
||||||
)
|
)
|
||||||
appendedOfflineIds.add(favPeerID)
|
appendedOfflineIds.add(favPeerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also show any incoming Nostr chats that exist locally but are not in connected peers or favorites yet
|
// NOTE: Do NOT append Nostr-only (nostr_*) conversations to the mesh people list.
|
||||||
// This ensures a user can open and read Nostr messages while the sender remains offline
|
// Geohash DMs should appear in the GeohashPeople list for the active geohash, not in mesh offline contacts.
|
||||||
|
// We intentionally remove previously-added behavior that mixed geohash DMs into mesh sidebar.
|
||||||
|
// If you need to surface non-geohash offline mesh conversations in the future, do it here for 64-hex noise IDs only.
|
||||||
|
/*
|
||||||
val alreadyShownIds = connectedIds + appendedOfflineIds
|
val alreadyShownIds = connectedIds + appendedOfflineIds
|
||||||
privateChats.keys
|
privateChats.keys
|
||||||
.filter { key ->
|
.filter { key ->
|
||||||
(key.startsWith("nostr_") || hex64Regex.matches(key)) &&
|
// Only include 64-hex noise IDs (mesh identities); exclude any nostr_* aliases
|
||||||
|
hex64Regex.matches(key) &&
|
||||||
!alreadyShownIds.contains(key) &&
|
!alreadyShownIds.contains(key) &&
|
||||||
// Skip if this key maps to a connected peer via noiseHex mapping
|
// Skip if this key maps to a connected peer via noiseHex mapping
|
||||||
!noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) }
|
!noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) }
|
||||||
@@ -460,24 +485,27 @@ fun PeopleSection(
|
|||||||
val (bName, _) = com.bitchat.android.ui.splitSuffix(dn)
|
val (bName, _) = com.bitchat.android.ui.splitSuffix(dn)
|
||||||
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
||||||
|
|
||||||
PeerItem(
|
PeerItem(
|
||||||
peerID = convKey,
|
peerID = convKey,
|
||||||
displayName = dn,
|
displayName = dn,
|
||||||
isDirect = false,
|
isDirect = false,
|
||||||
isSelected = convKey == selectedPrivatePeer,
|
isSelected = convKey == selectedPrivatePeer,
|
||||||
isFavorite = false,
|
isFavorite = false,
|
||||||
hasUnreadDM = hasUnreadPrivateMessages.contains(convKey),
|
hasUnreadDM = hasUnreadPrivateMessages.contains(convKey),
|
||||||
colorScheme = colorScheme,
|
colorScheme = colorScheme,
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onItemClick = { onPrivateChatStart(convKey) },
|
onItemClick = { onPrivateChatStart(convKey) },
|
||||||
onToggleFavorite = { viewModel.toggleFavorite(convKey) },
|
onToggleFavorite = { viewModel.toggleFavorite(convKey) },
|
||||||
unreadCount = privateChats[convKey]?.count { msg ->
|
unreadCount = privateChats[convKey]?.count { msg ->
|
||||||
msg.sender != nickname && hasUnreadPrivateMessages.contains(convKey)
|
msg.sender != nickname && hasUnreadPrivateMessages.contains(convKey)
|
||||||
} ?: if (hasUnreadPrivateMessages.contains(convKey)) 1 else 0,
|
} ?: if (hasUnreadPrivateMessages.contains(convKey)) 1 else 0,
|
||||||
showNostrGlobe = true,
|
showNostrGlobe = false,
|
||||||
showHashSuffix = showHash
|
showHashSuffix = showHash
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
// End intentional removal
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,6 +566,15 @@ private fun PeerItem(
|
|||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
tint = Color(0xFF9C27B0) // Purple
|
tint = Color(0xFF9C27B0) // Purple
|
||||||
)
|
)
|
||||||
|
} else if (!isDirect && isFavorite) {
|
||||||
|
// Offline favorited user: show outlined circle icon
|
||||||
|
Icon(
|
||||||
|
//painter = androidx.compose.ui.res.painterResource(id = R.drawable.ic_offline_favorite),
|
||||||
|
imageVector = Icons.Outlined.Circle,
|
||||||
|
contentDescription = "Offline favorite",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = Color.Gray
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = if (isDirect) Icons.Outlined.SettingsInputAntenna else Icons.Filled.Route,
|
imageVector = if (isDirect) Icons.Outlined.SettingsInputAntenna else Icons.Filled.Route,
|
||||||
|
|||||||
@@ -262,6 +262,8 @@ fun DebugSettingsSheet(
|
|||||||
steps = 30
|
steps = 30
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val overallCount = connectedDevices.size
|
||||||
|
Text("connections: $overallCount / $maxOverall", fontFamily = FontFamily.Monospace, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f))
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text("max overall", fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
|
Text("max overall", fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
|
||||||
Slider(
|
Slider(
|
||||||
@@ -307,29 +309,35 @@ fun DebugSettingsSheet(
|
|||||||
}
|
}
|
||||||
val maxValRaw = series.maxOrNull() ?: 0f
|
val maxValRaw = series.maxOrNull() ?: 0f
|
||||||
val maxVal = if (maxValRaw > 0f) maxValRaw else 0f
|
val maxVal = if (maxValRaw > 0f) maxValRaw else 0f
|
||||||
Box(Modifier.fillMaxWidth().height(48.dp)) {
|
val leftGutter = 40.dp
|
||||||
|
Box(Modifier.fillMaxWidth().height(56.dp)) {
|
||||||
|
// Graph canvas
|
||||||
androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) {
|
androidx.compose.foundation.Canvas(Modifier.fillMaxSize()) {
|
||||||
val axisPx = 28.dp.toPx() // leave room on left for ticks
|
val axisPx = leftGutter.toPx() // reserved left gutter for labels
|
||||||
val barCount = series.size
|
val barCount = series.size
|
||||||
val availW = (size.width - axisPx).coerceAtLeast(1f)
|
val availW = (size.width - axisPx).coerceAtLeast(1f)
|
||||||
val w = availW / barCount
|
val w = availW / barCount
|
||||||
val h = size.height
|
val h = size.height
|
||||||
// draw baseline at y=0 (bottom)
|
// Baseline at bottom (y = 0)
|
||||||
drawLine(
|
drawLine(
|
||||||
color = Color(0x33888888),
|
color = Color(0x33888888),
|
||||||
start = androidx.compose.ui.geometry.Offset(axisPx, h - 1f),
|
start = androidx.compose.ui.geometry.Offset(axisPx, h - 1f),
|
||||||
end = androidx.compose.ui.geometry.Offset(size.width, h - 1f),
|
end = androidx.compose.ui.geometry.Offset(size.width, h - 1f),
|
||||||
strokeWidth = 1f
|
strokeWidth = 1f
|
||||||
)
|
)
|
||||||
|
// Bars from bottom-up; skip zeros entirely
|
||||||
series.forEachIndexed { i, value ->
|
series.forEachIndexed { i, value ->
|
||||||
val ratio = if (maxVal > 0f) (value / maxVal).coerceIn(0f, 1f) else 0f // min always 0
|
if (value > 0f && maxVal > 0f) {
|
||||||
val barHeight = h * ratio
|
val ratio = (value / maxVal).coerceIn(0f, 1f)
|
||||||
// Draw bars from bottom up, starting after left axis area
|
val barHeight = (h * ratio).coerceAtLeast(0f)
|
||||||
drawRect(
|
if (barHeight > 0.5f) {
|
||||||
color = Color(0xFF00C851),
|
drawRect(
|
||||||
topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = h - barHeight),
|
color = Color(0xFF00C851),
|
||||||
size = androidx.compose.ui.geometry.Size(w, barHeight)
|
topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = h - barHeight),
|
||||||
)
|
size = androidx.compose.ui.geometry.Size(w, barHeight)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Y-axis ticks (min/max) in the left margin
|
// Y-axis ticks (min/max) in the left margin
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Announcement Gossip TLV (Direct Neighbors)
|
||||||
|
|
||||||
|
This document specifies an optional TLV extension to the BitChat `ANNOUNCE` message that allows peers to gossip which other peers they are currently connected to directly over Bluetooth. Implementations can use this to build a mesh topology view (nodes = peers, edges = direct connections).
|
||||||
|
|
||||||
|
Status: optional and backward-compatible.
|
||||||
|
|
||||||
|
## Layering Overview
|
||||||
|
|
||||||
|
- Outer packet: BitChat binary packet with `type = 0x01` (ANNOUNCE). Header is unchanged.
|
||||||
|
- Payload: A sequence of TLVs. Unknown TLVs MUST be ignored for forward compatibility.
|
||||||
|
- Signature: The packet MAY be signed using the Ed25519 public key carried in TLV `0x03`. The gossip TLV (if present) is part of the payload and therefore covered by the signature.
|
||||||
|
|
||||||
|
## TLV Format
|
||||||
|
|
||||||
|
Each TLV uses a compact layout:
|
||||||
|
|
||||||
|
- `type`: 1 byte
|
||||||
|
- `length`: 1 byte (0..255)
|
||||||
|
- `value`: `length` bytes
|
||||||
|
|
||||||
|
Existing TLVs (unchanged):
|
||||||
|
|
||||||
|
- `0x01` NICKNAME: UTF‑8 string (≤ 255 bytes)
|
||||||
|
- `0x02` NOISE_PUBLIC_KEY: Noise static public key bytes (typically 32 bytes for X25519)
|
||||||
|
- `0x03` SIGNING_PUBLIC_KEY: Ed25519 public key bytes (typically 32 bytes)
|
||||||
|
|
||||||
|
New TLV (optional):
|
||||||
|
|
||||||
|
- `0x04` DIRECT_NEIGHBORS: Concatenation of up to 10 peer IDs, each encoded as exactly 8 bytes. There is no inner count; the number of neighbors is `length / 8`. If `length` is not a multiple of 8, trailing partial bytes MUST be ignored.
|
||||||
|
|
||||||
|
### Peer ID Binary Encoding (8 bytes)
|
||||||
|
|
||||||
|
Peer IDs are represented as 8 raw bytes (16 hex chars) in “network order” (left‑to‑right):
|
||||||
|
|
||||||
|
- Take the peer ID hex string, lowercase/truncate to at most 16 hex chars.
|
||||||
|
- Convert each 2 hex chars to 1 byte from left to right.
|
||||||
|
- If fewer than 16 hex chars are available, pad the remaining bytes with `0x00` at the end to reach 8 bytes.
|
||||||
|
|
||||||
|
This matches the on‑wire 8‑byte `senderID`/`recipientID` encoding used in the BitChat packet header.
|
||||||
|
|
||||||
|
## Sender Behavior
|
||||||
|
|
||||||
|
- Build the base announcement payload by emitting TLVs `0x01`..`0x03` as usual.
|
||||||
|
- Optionally append TLV `0x04` with up to 10 unique, directly connected peer IDs.
|
||||||
|
- Remove duplicates before encoding.
|
||||||
|
- Order is arbitrary and not semantically significant.
|
||||||
|
- Sign the ANNOUNCE packet so the gossip TLV is covered (recommended):
|
||||||
|
- Signature algorithm: Ed25519 using the key in TLV `0x03`.
|
||||||
|
- Signature input: the binary packet encoding with the signature field omitted and the TTL normalized to `0`. This allows TTL to change during relays without invalidating the signature.
|
||||||
|
- The payload may be compressed per the base protocol; the gossip TLV is encoded prior to optional compression.
|
||||||
|
|
||||||
|
## Receiver Behavior
|
||||||
|
|
||||||
|
- Decompress payload if the packet’s compression flag is set, then parse TLVs in order.
|
||||||
|
- Parse TLVs `0x01`..`0x03` as usual; ignore any unknown TLVs.
|
||||||
|
- If a `0x04` TLV is present:
|
||||||
|
- Interpret the value as `N = length / 8` peer IDs (ignore trailing non‑aligned bytes).
|
||||||
|
- Each 8‑byte chunk is decoded back to a 16‑hex‑char peer ID string (lowercase).
|
||||||
|
- De‑duplicate neighbors.
|
||||||
|
- Topology maintenance guidance (optional, but recommended for consistent behavior):
|
||||||
|
- Maintain, for each announcing peer A, the last announcement timestamp and the neighbor list from TLV `0x04`.
|
||||||
|
- When a newer announcement from A arrives (use the 8‑byte unsigned `timestamp` in the BitChat packet header), replace A’s previously recorded neighbor list with the new one. If older or equal, ignore the neighbor update.
|
||||||
|
- Treat the neighbor list as a set of undirected edges `{A, B}` in your topology visualization; i.e., if A reports direct peers `[B, C]`, add edges A–B and A–C.
|
||||||
|
|
||||||
|
## Limits and Compatibility
|
||||||
|
|
||||||
|
- Max neighbors per TLV: 10. Senders MAY send fewer; receivers MUST accept any number `N ≥ 0` implied by `length / 8` up to the received `length`.
|
||||||
|
- Omission: If the TLV `0x04` is omitted, the announce remains valid. Peers can still chat and interoperate normally; the topology graph will just not include edges reported by that peer (other peers that include A in their neighbor lists can still introduce edges to A).
|
||||||
|
- Unknown TLVs MUST be ignored. This makes the extension safe for older implementations.
|
||||||
|
|
||||||
|
## Minimal Example (conceptual)
|
||||||
|
|
||||||
|
ANNOUNCE payload TLVs (concatenated):
|
||||||
|
|
||||||
|
- `01 [len=N] [UTF‑8 nickname]`
|
||||||
|
- `02 [len=32] [32 bytes X25519 pubkey]`
|
||||||
|
- `03 [len=32] [32 bytes Ed25519 pubkey]`
|
||||||
|
- `04 [len=8*M] [peerID1(8) || peerID2(8) || ... || peerIDM(8)]` (optional)
|
||||||
|
|
||||||
|
Where each `peerIDk(8)` is the 8‑byte binary form of the peer ID as specified above.
|
||||||
|
|
||||||
|
That’s the entire change; the outer packet header, message type, and relay/TTL behavior are unchanged.
|
||||||
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Source-Based Routing for BitChat Packets
|
||||||
|
|
||||||
|
This document specifies an optional source-based routing extension to the BitChat packet format. A sender may attach a hop-by-hop route (list of peer IDs) to instruct relays on the intended path. Relays that support this feature will try to forward to the next hop directly; otherwise, they fall back to regular broadcast relaying.
|
||||||
|
|
||||||
|
Status: optional and backward-compatible.
|
||||||
|
|
||||||
|
## Layering Overview
|
||||||
|
|
||||||
|
- Outer packet: BitChat binary packet with unchanged fixed header (version/type/ttl/timestamp/flags/payloadLength).
|
||||||
|
- Flags: adds a new bit `HAS_ROUTE (0x08)`.
|
||||||
|
- Variable sections (when present, in order):
|
||||||
|
1) `SenderID` (8 bytes)
|
||||||
|
2) `RecipientID` (8 bytes) if `HAS_RECIPIENT`
|
||||||
|
3) `Route` (if `HAS_ROUTE`): `count` (1 byte) + `count * 8` bytes hop IDs
|
||||||
|
4) `Payload` (with optional compression preamble)
|
||||||
|
5) `Signature` (64 bytes) if `HAS_SIGNATURE`
|
||||||
|
|
||||||
|
Unknown flags are ignored by older implementations (they will simply not see a route and continue broadcasting as before).
|
||||||
|
|
||||||
|
## Route Field Encoding
|
||||||
|
|
||||||
|
- Presence: Signaled by the `HAS_ROUTE (0x08)` bit in `flags`.
|
||||||
|
- Layout (immediately after optional `RecipientID`):
|
||||||
|
- `count`: 1 byte (0..255)
|
||||||
|
- `hops`: concatenation of `count` peer IDs, each encoded as exactly 8 bytes
|
||||||
|
- Peer ID encoding (8 bytes): same as used elsewhere in BitChat (16 hex chars → 8 bytes; left-to-right conversion; pad with `0x00` if shorter). This matches the on‑wire `senderID`/`recipientID` encoding.
|
||||||
|
- Size impact: `1 + 8*N` bytes, where `N = count`.
|
||||||
|
- Empty route: `HAS_ROUTE` with `count = 0` is treated as no route (relays ignore it).
|
||||||
|
|
||||||
|
## Sender Behavior
|
||||||
|
|
||||||
|
- Applicability: Intended for addressed packets (i.e., where `recipientID` is set and is not the broadcast ID). For broadcast packets, omit the route.
|
||||||
|
- Path computation: Use Dijkstra’s shortest path (unit weights) on your internal mesh topology to find a route from `src` (your peerID) to `dst` (recipient peerID). The hop list SHOULD include the full path `[src, ..., dst]`.
|
||||||
|
- Encoding: Set `HAS_ROUTE`, write `count = path.length`, then the 8‑byte hop IDs in order. Keep `count <= 255`.
|
||||||
|
- Signing: The route is covered by the Ed25519 signature (recommended):
|
||||||
|
- Signature input is the canonical encoding with `signature` omitted and `ttl = 0` (TTL excluded to allow relay decrement) — same rule as base protocol.
|
||||||
|
|
||||||
|
## Relay Behavior
|
||||||
|
|
||||||
|
When receiving a packet that is not addressed to you:
|
||||||
|
|
||||||
|
1) If `HAS_ROUTE` is not set, or the route is empty, relay using your normal broadcast logic (subject to TTL/probability policies).
|
||||||
|
2) If `HAS_ROUTE` is set and your peer ID appears at index `i` in the hop list:
|
||||||
|
- If there is a next hop at `i+1`, attempt a targeted unicast to that next hop if you have a direct connection to it.
|
||||||
|
- If successful, do NOT broadcast this packet further.
|
||||||
|
- If not directly connected (or the send fails), fall back to broadcast relaying.
|
||||||
|
- If you are the last hop (no `i+1`), proceed with standard handling (e.g., if not addressed to you, do not relay further).
|
||||||
|
|
||||||
|
TTL handling remains unchanged: relays decrement TTL by 1 before forwarding (whether targeted or broadcast). If TTL reaches 0, do not relay.
|
||||||
|
|
||||||
|
## Receiver Behavior (Destination)
|
||||||
|
|
||||||
|
- This extension does not change how addressed packets are handled by the final recipient. If the packet is addressed to you (`recipientID == myPeerID`), process it normally (e.g., decrypt Noise payload, verify signatures, etc.).
|
||||||
|
- Signature verification MUST include the route field when present; route tampering will invalidate the signature.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
- Omission: If `HAS_ROUTE` is omitted, legacy behavior applies. Relays that don’t implement this feature will ignore the route entirely, because they won’t set or check `HAS_ROUTE`.
|
||||||
|
- Partial support: If any relay on the path cannot directly reach the next hop, it will fall back to broadcast relaying; delivery is still probabilistic like the base protocol.
|
||||||
|
|
||||||
|
## Minimal Example (conceptual)
|
||||||
|
|
||||||
|
- Header (fixed 13 bytes): unchanged.
|
||||||
|
- Variable sections (ordered):
|
||||||
|
- `SenderID(8)`
|
||||||
|
- `RecipientID(8)` (if present)
|
||||||
|
- `HAS_ROUTE` set → `count=3`, `hops = [H0 H1 H2]` where each `Hk` is 8 bytes
|
||||||
|
- Payload (optionally compressed)
|
||||||
|
- Signature (64)
|
||||||
|
|
||||||
|
Where `H0` is the sender’s peer ID, `H2` is the recipient’s peer ID, and `H1` is an intermediate relay. The receiver verifies the signature over the packet encoding (with `ttl = 0` and `signature` omitted), which includes the `hops` when `HAS_ROUTE` is set.
|
||||||
|
|
||||||
|
## Operational Notes
|
||||||
|
|
||||||
|
- Routing optimality depends on the freshness and completeness of the topology your implementation has learned (e.g., via gossip of direct neighbors). Recompute routes as needed.
|
||||||
|
- Route length should be kept small to reduce overhead and the probability of missing a direct link at some hop.
|
||||||
|
- Implementations may introduce policy controls (e.g., disable source routing, cap max route length).
|
||||||
|
|
||||||
Reference in New Issue
Block a user