mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 20:45:20 +00:00
Merge branch 'main' into gossip-graph
This commit is contained in:
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "com.bitchat.droid"
|
||||
minSdk = libs.versions.minSdk.get().toInt()
|
||||
targetSdk = libs.versions.targetSdk.get().toInt()
|
||||
versionCode = 18
|
||||
versionName = "1.2.2"
|
||||
versionCode = 19
|
||||
versionName = "1.2.3"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
|
||||
@@ -41,6 +41,12 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.BitchatAndroid"
|
||||
tools:targetApi="31">
|
||||
<activity
|
||||
android:name=".ui.GeohashPickerActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.BitchatAndroid"
|
||||
android:screenOrientation="portrait"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
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.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.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
|
||||
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
|
||||
wot.brightbolt.net,47.6735,-116.781
|
||||
nostr.overmind.lol,43.6532,-79.3832
|
||||
relay.magiccity.live,25.8128,-80.2377
|
||||
kitchen.zap.cooking,43.6532,-79.3832
|
||||
relay.satsdays.com,1.35208,103.82
|
||||
nostr-02.yakihonne.com,1.32123,103.695
|
||||
offchain.pub,36.1809,-115.241
|
||||
nostr.lojong.info,43.6532,-79.3832
|
||||
relay.tapestry.ninja,40.8054,-74.0241
|
||||
zap.watch,45.5029,-73.5723
|
||||
nostrelay.memory-art.xyz,43.6532,-79.3832
|
||||
nostr.spicyz.io,43.6532,-79.3832
|
||||
nostr.mom,50.4754,12.3683
|
||||
nos.lol,50.4754,12.3683
|
||||
rnostr.breadslice.com,43.6532,-79.3832
|
||||
portal-relay.pareto.space,49.4543,11.0746
|
||||
relay.hasenpfeffr.com,39.0438,-77.4874
|
||||
nostr.blankfors.se,60.1699,24.9384
|
||||
relay5.bitransfer.org,43.6532,-79.3832
|
||||
santo.iguanatech.net,40.8302,-74.1299
|
||||
nostr.ser1.net,12.9716,77.5946
|
||||
nostr.stakey.net,52.3676,4.90414
|
||||
relayrs.notoshi.win,43.6532,-79.3832
|
||||
orangepiller.org,60.1699,24.9384
|
||||
temp.iris.to,43.6532,-79.3832
|
||||
relay.cypherflow.ai,48.8566,2.35222
|
||||
nostr.kalf.org,52.3676,4.90414
|
||||
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
|
||||
wot.nostr.net,43.6532,-79.3832
|
||||
nostr.primz.org,43.6532,-79.3832
|
||||
nostr-relay.online,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
|
||||
relay.0xchat.com,1.35208,103.82
|
||||
relay.moinsen.com,50.4754,12.3683
|
||||
relay03.lnfi.network,39.0997,-94.5786
|
||||
nostr.zenon.network,43.5009,-70.4428
|
||||
cyberspace.nostr1.com,40.7128,-74.006
|
||||
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.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
|
||||
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
|
||||
* 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(
|
||||
val peerNoisePublicKey: ByteArray, // Noise static public key (32 bytes)
|
||||
@@ -54,13 +55,14 @@ interface FavoritesChangeListener {
|
||||
|
||||
/**
|
||||
* Manages favorites with Noise↔Nostr mapping
|
||||
* Singleton pattern matching iOS implementation
|
||||
* Singleton pattern matching iOS implementation.
|
||||
*/
|
||||
class FavoritesPersistenceService private constructor(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
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
|
||||
private var INSTANCE: FavoritesPersistenceService? = null
|
||||
@@ -81,39 +83,33 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
||||
|
||||
private val stateManager = SecureIdentityStateManager(context)
|
||||
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>()
|
||||
|
||||
init {
|
||||
loadFavorites()
|
||||
loadPeerIdIndex()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get favorite status for Noise public key
|
||||
*/
|
||||
/** Get favorite status for Noise public key */
|
||||
fun getFavoriteStatus(noisePublicKey: ByteArray): FavoriteRelationship? {
|
||||
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
|
||||
return favorites[keyHex]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get favorite status for 16-hex peerID
|
||||
*/
|
||||
/** Get favorite status for 16-hex peerID (by noiseHex prefix match) */
|
||||
fun getFavoriteStatus(peerID: String): FavoriteRelationship? {
|
||||
// For 16-hex peerIDs, we need to find the corresponding full Noise key
|
||||
// This is a simplified lookup - in practice you'd use fingerprint matching
|
||||
val pid = peerID.lowercase()
|
||||
for ((_, relationship) in favorites) {
|
||||
val noiseKeyHex = relationship.peerNoisePublicKey.joinToString("") { "%02x".format(it) }
|
||||
if (noiseKeyHex.startsWith(peerID)) {
|
||||
return relationship
|
||||
}
|
||||
if (noiseKeyHex.startsWith(pid)) return relationship
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Nostr public key for a peer
|
||||
*/
|
||||
/** Update Nostr public key for a peer (indexed by Noise key) */
|
||||
fun updateNostrPublicKey(noisePublicKey: ByteArray, nostrPubkey: String) {
|
||||
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
|
||||
val existing = favorites[keyHex]
|
||||
@@ -125,7 +121,6 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
||||
)
|
||||
favorites[keyHex] = updated
|
||||
} else {
|
||||
// Create new relationship
|
||||
val relationship = FavoriteRelationship(
|
||||
peerNoisePublicKey = noisePublicKey,
|
||||
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)}...")
|
||||
}
|
||||
|
||||
/**
|
||||
* Update favorite status
|
||||
*/
|
||||
/** NEW: Update Nostr pubkey for specific mesh peerID (16-hex). */
|
||||
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) {
|
||||
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
|
||||
|
||||
val existing = favorites[keyHex]
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
/**
|
||||
* Update peer favorited us status
|
||||
*/
|
||||
/** Update peer favorited-us flag */
|
||||
fun updatePeerFavoritedUs(noisePublicKey: ByteArray, theyFavoritedUs: Boolean) {
|
||||
val keyHex = noisePublicKey.joinToString("") { "%02x".format(it) }
|
||||
val existing = favorites[keyHex]
|
||||
@@ -196,55 +224,36 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all mutual favorites
|
||||
*/
|
||||
fun getMutualFavorites(): List<FavoriteRelationship> {
|
||||
return favorites.values.filter { it.isMutual }
|
||||
}
|
||||
fun getMutualFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isMutual }
|
||||
fun getOurFavorites(): List<FavoriteRelationship> = favorites.values.filter { it.isFavorite }
|
||||
|
||||
/**
|
||||
* Get all favorites we have
|
||||
*/
|
||||
fun getOurFavorites(): List<FavoriteRelationship> {
|
||||
return favorites.values.filter { it.isFavorite }
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all favorites
|
||||
*/
|
||||
fun clearAllFavorites() {
|
||||
favorites.clear()
|
||||
saveFavorites()
|
||||
peerIdIndex.clear()
|
||||
savePeerIdIndex()
|
||||
Log.i(TAG, "Cleared all favorites")
|
||||
notifyAllCleared()
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Noise key by Nostr pubkey
|
||||
*/
|
||||
/** Find Noise key by Nostr pubkey */
|
||||
fun findNoiseKey(forNostrPubkey: String): ByteArray? {
|
||||
val targetHex = normalizeNostrKeyToHex(forNostrPubkey) ?: return null
|
||||
return favorites.values.firstOrNull { rel ->
|
||||
rel.peerNostrPublicKey?.let { stored ->
|
||||
normalizeNostrKeyToHex(stored)
|
||||
} == targetHex
|
||||
rel.peerNostrPublicKey?.let { stored -> normalizeNostrKeyToHex(stored) } == targetHex
|
||||
}?.peerNoisePublicKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Find Nostr pubkey by Noise key
|
||||
*/
|
||||
/** Find Nostr pubkey by Noise key */
|
||||
fun findNostrPubkey(forNoiseKey: ByteArray): String? {
|
||||
val keyHex = forNoiseKey.joinToString("") { "%02x".format(it) }
|
||||
return favorites[keyHex]?.peerNostrPublicKey
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
// MARK: - Persistence
|
||||
|
||||
private fun loadFavorites() {
|
||||
try {
|
||||
// Use public methods instead of reflection to access encrypted preferences
|
||||
val favoritesJson = stateManager.getSecureValue(FAVORITES_KEY)
|
||||
if (favoritesJson != null) {
|
||||
val type = object : TypeToken<Map<String, FavoriteRelationshipData>>() {}.type
|
||||
@@ -252,10 +261,8 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
||||
|
||||
favorites.clear()
|
||||
data.forEach { (key, relationshipData) ->
|
||||
val relationship = relationshipData.toFavoriteRelationship()
|
||||
favorites[key] = relationship
|
||||
favorites[key] = relationshipData.toFavoriteRelationship()
|
||||
}
|
||||
|
||||
Log.d(TAG, "Loaded ${favorites.size} favorite relationships")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
@@ -265,27 +272,45 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
||||
|
||||
private fun saveFavorites() {
|
||||
try {
|
||||
// Convert to serializable format
|
||||
val data = favorites.mapValues { (_, relationship) ->
|
||||
FavoriteRelationshipData.fromFavoriteRelationship(relationship)
|
||||
}
|
||||
|
||||
val favoritesJson = gson.toJson(data)
|
||||
|
||||
// Use public methods instead of reflection to access encrypted preferences
|
||||
stateManager.storeSecureValue(FAVORITES_KEY, favoritesJson)
|
||||
|
||||
Log.d(TAG, "Saved ${favorites.size} favorite relationships")
|
||||
} catch (e: Exception) {
|
||||
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
|
||||
fun addListener(listener: FavoritesChangeListener) {
|
||||
synchronized(listeners) {
|
||||
if (!listeners.contains(listener)) listeners.add(listener)
|
||||
}
|
||||
synchronized(listeners) { if (!listeners.contains(listener)) listeners.add(listener) }
|
||||
}
|
||||
fun removeListener(listener: FavoritesChangeListener) {
|
||||
synchronized(listeners) { listeners.remove(listener) }
|
||||
@@ -299,26 +324,16 @@ class FavoritesPersistenceService private constructor(private val context: Conte
|
||||
snapshot.forEach { runCatching { it.onAllCleared() } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a Nostr public key string (npub bech32 or hex) to lowercase hex for comparison
|
||||
*/
|
||||
private fun normalizeNostrKeyToHex(value: String): String? {
|
||||
return try {
|
||||
if (value.startsWith("npub1")) {
|
||||
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(value)
|
||||
if (hrp != "npub") return null
|
||||
data.joinToString("") { "%02x".format(it) }
|
||||
} else {
|
||||
// Assume hex
|
||||
value.lowercase()
|
||||
}
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
/** Normalize a Nostr public key string (npub bech32 or hex) to lowercase hex */
|
||||
private fun normalizeNostrKeyToHex(value: String): String? = try {
|
||||
if (value.startsWith("npub1")) {
|
||||
val (hrp, data) = com.bitchat.android.nostr.Bech32.decode(value)
|
||||
if (hrp != "npub") null else data.joinToString("") { "%02x".format(it) }
|
||||
} else value.lowercase()
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializable data class for JSON storage
|
||||
*/
|
||||
/** Serializable data for JSON storage */
|
||||
private data class FavoriteRelationshipData(
|
||||
val peerNoisePublicKeyHex: String,
|
||||
val peerNostrPublicKey: String?,
|
||||
|
||||
@@ -285,6 +285,13 @@ class BluetoothMeshService(private val context: Context) {
|
||||
// Store fingerprint for the peer via centralized fingerprint manager
|
||||
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
|
||||
previousPeerID?.let { 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) {
|
||||
if (content.isEmpty() || recipientPeerID.isEmpty()) return
|
||||
if (!recipientPeerID.startsWith("nostr_") && recipientNickname.isEmpty()) return
|
||||
if (recipientNickname.isEmpty()) return
|
||||
|
||||
serviceScope.launch {
|
||||
val finalMessageID = messageID ?: java.util.UUID.randomUUID().toString()
|
||||
|
||||
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
|
||||
if (encryptionService.hasEstablishedSession(recipientPeerID)) {
|
||||
try {
|
||||
@@ -629,15 +624,14 @@ class BluetoothMeshService(private val context: Context) {
|
||||
serviceScope.launch {
|
||||
Log.d(TAG, "📖 Sending read receipt for message $messageID to $recipientPeerID")
|
||||
|
||||
// Check if this is a Nostr contact (geohash DM)
|
||||
if (recipientPeerID.startsWith("nostr_")) {
|
||||
// Get NostrGeohashService instance and send read receipt via Nostr
|
||||
try {
|
||||
val nostrGeohashService = com.bitchat.android.nostr.NostrGeohashService.getInstance(context.applicationContext as android.app.Application)
|
||||
nostrGeohashService.sendNostrGeohashReadReceipt(messageID, recipientPeerID, myPeerID)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send Nostr geohash read receipt: ${e.message}")
|
||||
}
|
||||
// Route geohash read receipts via MessageRouter instead of here
|
||||
val geo = runCatching { com.bitchat.android.services.MessageRouter.tryGetInstance() }.getOrNull()
|
||||
val isGeoAlias = try {
|
||||
val map = com.bitchat.android.nostr.GeohashAliasRegistry.snapshot()
|
||||
map.containsKey(recipientPeerID)
|
||||
} catch (_: Exception) { false }
|
||||
if (isGeoAlias && geo != null) {
|
||||
geo.sendReadReceipt(com.bitchat.android.model.ReadReceipt(messageID), recipientPeerID)
|
||||
return@launch
|
||||
}
|
||||
|
||||
|
||||
@@ -459,7 +459,9 @@ class MessageHandler(private val myPeerID: String) {
|
||||
if (noiseKey != null) {
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.shared.updatePeerFavoritedUs(noiseKey, isFavorite)
|
||||
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.updateNostrPublicKeyForPeerID(fromPeerID, npub)
|
||||
}
|
||||
|
||||
// Determine iOS-style guidance text
|
||||
|
||||
@@ -344,7 +344,8 @@ object TorManager {
|
||||
bindRetryAttempts = 0
|
||||
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.
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
var recipientNostrPubkey: String? = null
|
||||
|
||||
// Try to resolve from favorites persistence service
|
||||
// This would need integration with the existing favorites system
|
||||
// Resolve by peerID first (new peerID→npub index), then fall back to noise key mapping
|
||||
recipientNostrPubkey = resolveNostrPublicKey(to)
|
||||
|
||||
if (recipientNostrPubkey == null) {
|
||||
@@ -86,13 +85,23 @@ class NostrTransport(
|
||||
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(
|
||||
content = content,
|
||||
messageID = messageID,
|
||||
recipientPeerID = to,
|
||||
recipientPeerID = recipientPeerIDForEmbed,
|
||||
senderPeerID = senderPeerID
|
||||
)
|
||||
|
||||
|
||||
if (embedded == null) {
|
||||
Log.e(TAG, "NostrTransport: failed to embed PM packet")
|
||||
return@launch
|
||||
@@ -412,23 +421,43 @@ class NostrTransport(
|
||||
fun sendPrivateMessageGeohash(
|
||||
content: 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 {
|
||||
try {
|
||||
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
|
||||
val embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
|
||||
content = content,
|
||||
messageID = messageID,
|
||||
senderPeerID = senderPeerID
|
||||
)
|
||||
|
||||
if (embedded == null) {
|
||||
) ?: run {
|
||||
Log.e(TAG, "NostrTransport: failed to embed geohash PM packet")
|
||||
return@launch
|
||||
}
|
||||
@@ -444,7 +473,6 @@ class NostrTransport(
|
||||
NostrRelayManager.registerPendingGiftWrap(event.id)
|
||||
NostrRelayManager.getInstance(context).sendEvent(event)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to send geohash private message: ${e.message}")
|
||||
}
|
||||
@@ -458,14 +486,15 @@ class NostrTransport(
|
||||
*/
|
||||
private fun resolveNostrPublicKey(peerID: String): String? {
|
||||
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 favoriteStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(noiseKey)
|
||||
if (favoriteStatus?.peerNostrPublicKey != null) {
|
||||
return favoriteStatus.peerNostrPublicKey
|
||||
}
|
||||
if (favoriteStatus?.peerNostrPublicKey != null) return favoriteStatus.peerNostrPublicKey
|
||||
|
||||
// Fallback: try with 16-hex peerID lookup
|
||||
// 3) Prefix match on noiseHex from 16-hex peerID
|
||||
if (peerID.length == 16) {
|
||||
val fallbackStatus = com.bitchat.android.favorites.FavoritesPersistenceService.shared.getFavoriteStatus(peerID)
|
||||
return fallbackStatus?.peerNostrPublicKey
|
||||
|
||||
@@ -43,6 +43,7 @@ class MessageRouter private constructor(
|
||||
|
||||
// Listener for favorites changes to flush outbox when npub mapping appears/changes
|
||||
private val favoriteListener = object: com.bitchat.android.favorites.FavoritesChangeListener {
|
||||
|
||||
override fun onFavoriteChanged(noiseKeyHex: String) {
|
||||
flushOutboxFor(noiseKeyHex)
|
||||
// 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) {
|
||||
// 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 hasEstablished = mesh.hasEstablishedSession(toPeerID)
|
||||
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)
|
||||
} 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)
|
||||
} 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() }
|
||||
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) {
|
||||
// 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))) {
|
||||
nostr.sendDeliveryAck(messageID, toPeerID)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.bitchat.android.ui
|
||||
|
||||
|
||||
import android.util.Log
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
@@ -31,6 +32,7 @@ import com.bitchat.android.core.ui.utils.singleOrTripleClickable
|
||||
* Extracted from ChatScreen.kt for better organization
|
||||
*/
|
||||
|
||||
|
||||
/**
|
||||
* Reactive helper to compute favorite state from fingerprint mapping
|
||||
* This eliminates the need for static isFavorite parameters and makes
|
||||
@@ -159,7 +161,6 @@ fun PeerCounter(
|
||||
connectedPeers: List<String>,
|
||||
joinedChannels: Set<String>,
|
||||
hasUnreadChannels: Map<String, Int>,
|
||||
hasUnreadPrivateMessages: Set<String>,
|
||||
isConnected: Boolean,
|
||||
selectedLocationChannel: com.bitchat.android.geohash.ChannelID?,
|
||||
geohashPeople: List<GeoPerson>,
|
||||
@@ -189,33 +190,6 @@ fun PeerCounter(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
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(
|
||||
imageVector = Icons.Default.Group,
|
||||
contentDescription = when (selectedLocationChannel) {
|
||||
@@ -226,6 +200,7 @@ fun PeerCounter(
|
||||
tint = countColor
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
||||
Text(
|
||||
text = "$peopleCount",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
@@ -290,7 +265,8 @@ fun ChatHeaderContent(
|
||||
selectedLocationChannel = selectedLocationChannel,
|
||||
geohashPeople = geohashPeople,
|
||||
onBackClick = onBackClick,
|
||||
onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) }
|
||||
onToggleFavorite = { viewModel.toggleFavorite(selectedPrivatePeer) },
|
||||
viewModel = viewModel
|
||||
)
|
||||
}
|
||||
currentChannel != null -> {
|
||||
@@ -326,7 +302,8 @@ private fun PrivateChatHeader(
|
||||
selectedLocationChannel: com.bitchat.android.geohash.ChannelID?,
|
||||
geohashPeople: List<GeoPerson>,
|
||||
onBackClick: () -> Unit,
|
||||
onToggleFavorite: () -> Unit
|
||||
onToggleFavorite: () -> Unit,
|
||||
viewModel: ChatViewModel
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
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)
|
||||
val titleText: String = if (isNostrDM) {
|
||||
val geohash = (selectedLocationChannel as? com.bitchat.android.geohash.ChannelID.Location)?.channel?.geohash
|
||||
val shortId = peerID.removePrefix("nostr_").removePrefix("nostr:")
|
||||
val person = geohashPeople.firstOrNull { it.id.startsWith(shortId, ignoreCase = true) }
|
||||
val baseName = person?.displayName?.substringBefore('#') ?: peerNicknames[peerID] ?: "unknown"
|
||||
val geoPart = geohash?.let { "#$it" } ?: "#geohash"
|
||||
"$geoPart/@$baseName"
|
||||
// For geohash DMs, get the actual source geohash and proper display name
|
||||
val (conversationGeohash, baseName) = try {
|
||||
val repoField = com.bitchat.android.ui.GeohashViewModel::class.java.getDeclaredField("repo")
|
||||
repoField.isAccessible = true
|
||||
val repo = repoField.get(viewModel.geohashViewModel) as com.bitchat.android.nostr.GeohashRepository
|
||||
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 {
|
||||
// Prefer live mesh nickname; fallback to favorites nickname (supports 16-hex), finally short key
|
||||
peerNicknames[peerID] ?: run {
|
||||
@@ -563,6 +552,19 @@ private fun MainHeader(
|
||||
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)
|
||||
LocationChannelsButton(
|
||||
viewModel = viewModel,
|
||||
@@ -582,7 +584,6 @@ private fun MainHeader(
|
||||
connectedPeers = connectedPeers.filter { it != viewModel.meshService.myPeerID },
|
||||
joinedChannels = joinedChannels,
|
||||
hasUnreadChannels = hasUnreadChannels,
|
||||
hasUnreadPrivateMessages = hasUnreadPrivateMessages,
|
||||
isConnected = isConnected,
|
||||
selectedLocationChannel = selectedLocationChannel,
|
||||
geohashPeople = geohashPeople,
|
||||
|
||||
@@ -74,18 +74,17 @@ fun ChatScreen(viewModel: ChatViewModel) {
|
||||
// Get location channel info for timeline switching
|
||||
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 {
|
||||
selectedPrivatePeer != null -> privateChats[selectedPrivatePeer] ?: emptyList()
|
||||
currentChannel != null -> channelMessages[currentChannel] ?: emptyList()
|
||||
else -> {
|
||||
val locationChannel = selectedLocationChannel
|
||||
if (locationChannel is com.bitchat.android.geohash.ChannelID.Location) {
|
||||
// For geohash channels, get messages from geohash history
|
||||
val geohash = locationChannel.channel.geohash
|
||||
viewModel.getGeohashMessages(geohash)
|
||||
val geokey = "geo:${locationChannel.channel.geohash}"
|
||||
channelMessages[geokey] ?: emptyList()
|
||||
} else {
|
||||
messages // Mesh/public messages
|
||||
messages // Mesh timeline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,9 @@ class ChatState {
|
||||
// Geohash people state (iOS-compatible)
|
||||
private val _geohashPeople = MutableLiveData<List<GeoPerson>>(emptyList())
|
||||
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())
|
||||
val teleportedGeo: LiveData<Set<String>> = _teleportedGeo
|
||||
@@ -155,6 +158,16 @@ class ChatState {
|
||||
fun getSelectedPrivateChatPeerValue() = _selectedPrivateChatPeer.value
|
||||
fun getUnreadPrivateMessagesValue() = _unreadPrivateMessages.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 getChannelMessagesValue() = _channelMessages.value ?: emptyMap()
|
||||
fun getUnreadChannelMessagesValue() = _unreadChannelMessages.value ?: emptyMap()
|
||||
@@ -183,6 +196,10 @@ class ChatState {
|
||||
_connectedPeers.value = peers
|
||||
}
|
||||
|
||||
fun postTeleportedGeo(teleported: Set<String>) {
|
||||
_teleportedGeo.postValue(teleported)
|
||||
}
|
||||
|
||||
fun setNickname(nickname: String) {
|
||||
_nickname.value = nickname
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@ import com.bitchat.android.mesh.BluetoothMeshDelegate
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.protocol.BitchatPacket
|
||||
import com.bitchat.android.nostr.NostrGeohashService
|
||||
|
||||
|
||||
import kotlinx.coroutines.launch
|
||||
import com.bitchat.android.util.NotificationIntervalManager
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -68,18 +69,20 @@ class ChatViewModel(
|
||||
getMeshService = { meshService }
|
||||
)
|
||||
|
||||
// Nostr and Geohash service - initialize singleton
|
||||
private val nostrGeohashService = NostrGeohashService.initialize(
|
||||
// New Geohash architecture ViewModel (replaces God object service usage in UI path)
|
||||
val geohashViewModel = GeohashViewModel(
|
||||
application = application,
|
||||
state = state,
|
||||
messageManager = messageManager,
|
||||
privateChatManager = privateChatManager,
|
||||
meshDelegateHandler = meshDelegateHandler,
|
||||
coroutineScope = viewModelScope,
|
||||
dataManager = dataManager,
|
||||
notificationManager = notificationManager
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
// Expose state through LiveData (maintaining the same interface)
|
||||
val messages: LiveData<List<BitchatMessage>> = state.messages
|
||||
val connectedPeers: LiveData<List<String>> = state.connectedPeers
|
||||
@@ -168,14 +171,12 @@ class ChatViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize location channel state
|
||||
nostrGeohashService.initializeLocationChannelState()
|
||||
// Initialize new geohash architecture
|
||||
geohashViewModel.initialize()
|
||||
|
||||
// Initialize favorites persistence service
|
||||
com.bitchat.android.favorites.FavoritesPersistenceService.initialize(getApplication())
|
||||
|
||||
// Initialize Nostr integration
|
||||
nostrGeohashService.initializeNostrIntegration()
|
||||
|
||||
// Ensure NostrTransport knows our mesh peer ID for embedded packets
|
||||
try {
|
||||
@@ -213,6 +214,46 @@ class ChatViewModel(
|
||||
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)
|
||||
|
||||
fun joinChannel(channel: String, password: String? = null): Boolean {
|
||||
@@ -231,6 +272,11 @@ class ChatViewModel(
|
||||
// MARK: - Private Chat Management (delegated)
|
||||
|
||||
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)
|
||||
if (success) {
|
||||
// Notify notification manager about current private chat
|
||||
@@ -259,6 +305,66 @@ class ChatViewModel(
|
||||
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
|
||||
|
||||
fun sendMessage(content: String) {
|
||||
@@ -270,7 +376,7 @@ class ChatViewModel(
|
||||
commandProcessor.processCommand(content, meshService, meshService.myPeerID, { messageContent, mentions, channel ->
|
||||
if (selectedLocationForCommand is com.bitchat.android.geohash.ChannelID.Location) {
|
||||
// Route command-generated public messages via Nostr in geohash channels
|
||||
nostrGeohashService.sendGeohashMessage(
|
||||
geohashViewModel.sendGeohashMessage(
|
||||
messageContent,
|
||||
selectedLocationForCommand.channel,
|
||||
meshService.myPeerID,
|
||||
@@ -298,7 +404,7 @@ class ChatViewModel(
|
||||
connectedPeers = state.getConnectedPeersValue(),
|
||||
meshNoiseKeyForPeer = { pid -> meshService.getPeerInfo(pid)?.noisePublicKey },
|
||||
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) }
|
||||
).also { canonical ->
|
||||
if (canonical != state.getSelectedPrivateChatPeerValue()) {
|
||||
@@ -323,7 +429,7 @@ class ChatViewModel(
|
||||
val selectedLocationChannel = state.selectedLocationChannel.value
|
||||
if (selectedLocationChannel is com.bitchat.android.geohash.ChannelID.Location) {
|
||||
// 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 {
|
||||
// Send public/channel message via mesh
|
||||
val message = BitchatMessage(
|
||||
@@ -376,17 +482,36 @@ class ChatViewModel(
|
||||
Log.d("ChatViewModel", "toggleFavorite called for peerID: $peerID")
|
||||
privateChatManager.toggleFavorite(peerID)
|
||||
|
||||
// Persist relationship in FavoritesPersistenceService when we have Noise key
|
||||
// Persist relationship in FavoritesPersistenceService
|
||||
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 noiseKey = peerInfo?.noisePublicKey
|
||||
val nickname = peerInfo?.nickname ?: (meshService.getPeerNicknames()[peerID] ?: peerID)
|
||||
if (peerInfo?.noisePublicKey != null) {
|
||||
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) {
|
||||
val isNowFavorite = dataManager.favoritePeers.contains(
|
||||
com.bitchat.android.mesh.PeerFingerprintManager.getInstance().getFingerprintForPeer(peerID) ?: ""
|
||||
)
|
||||
// Determine current favorite state from DataManager using fingerprint
|
||||
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(
|
||||
noisePublicKey = noiseKey,
|
||||
noisePublicKey = noiseKey!!,
|
||||
nickname = nickname,
|
||||
isFavorite = isNowFavorite
|
||||
)
|
||||
@@ -596,7 +721,7 @@ class ChatViewModel(
|
||||
|
||||
// Clear Nostr/geohash state, keys, connections, and reinitialize from scratch
|
||||
try {
|
||||
nostrGeohashService.panicResetNostrAndGeohash()
|
||||
geohashViewModel.panicReset()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to reset Nostr/geohash: ${e.message}")
|
||||
}
|
||||
@@ -638,70 +763,73 @@ class ChatViewModel(
|
||||
try {
|
||||
val identityManager = com.bitchat.android.identity.SecureIdentityStateManager(getApplication())
|
||||
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) {
|
||||
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")
|
||||
} catch (e: Exception) {
|
||||
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)
|
||||
*/
|
||||
fun geohashParticipantCount(geohash: String): Int {
|
||||
return nostrGeohashService.geohashParticipantCount(geohash)
|
||||
return geohashViewModel.geohashParticipantCount(geohash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Begin sampling multiple geohashes for participant activity
|
||||
*/
|
||||
fun beginGeohashSampling(geohashes: List<String>) {
|
||||
nostrGeohashService.beginGeohashSampling(geohashes)
|
||||
geohashViewModel.beginGeohashSampling(geohashes)
|
||||
}
|
||||
|
||||
/**
|
||||
* End geohash sampling
|
||||
*/
|
||||
fun endGeohashSampling() {
|
||||
nostrGeohashService.endGeohashSampling()
|
||||
// No-op in refactored architecture; sampling subscriptions are short-lived
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a geohash person is teleported (iOS-compatible)
|
||||
*/
|
||||
fun isPersonTeleported(pubkeyHex: String): Boolean {
|
||||
return nostrGeohashService.isPersonTeleported(pubkeyHex)
|
||||
return geohashViewModel.isPersonTeleported(pubkeyHex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start geohash DM with pubkey hex (iOS-compatible)
|
||||
*/
|
||||
fun startGeohashDM(pubkeyHex: String) {
|
||||
nostrGeohashService.startGeohashDM(pubkeyHex) { convKey ->
|
||||
geohashViewModel.startGeohashDM(pubkeyHex) { convKey ->
|
||||
startPrivateChat(convKey)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectLocationChannel(channel: com.bitchat.android.geohash.ChannelID) {
|
||||
nostrGeohashService.selectLocationChannel(channel)
|
||||
geohashViewModel.selectLocationChannel(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Block a user in geohash channels by their nickname
|
||||
*/
|
||||
fun blockUserInGeohash(targetNickname: String) {
|
||||
nostrGeohashService.blockUserInGeohash(targetNickname)
|
||||
geohashViewModel.blockUserInGeohash(targetNickname)
|
||||
}
|
||||
|
||||
// MARK: - Navigation Management
|
||||
@@ -774,6 +902,6 @@ class ChatViewModel(
|
||||
* Get consistent color for a Nostr pubkey (iOS-compatible)
|
||||
*/
|
||||
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_ *"
|
||||
|
||||
// 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
|
||||
|
||||
// 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.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Map
|
||||
import androidx.compose.material.icons.filled.PinDrop
|
||||
import androidx.compose.material3.*
|
||||
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.LocationChannelManager
|
||||
import java.util.*
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
|
||||
/**
|
||||
* Location Channels Sheet for selecting geohash-based location channels
|
||||
@@ -71,6 +74,18 @@ fun LocationChannelsSheet(
|
||||
// Scroll state for LazyColumn
|
||||
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)
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
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("#", "")
|
||||
|
||||
// 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)
|
||||
|
||||
// iOS-style teleport button
|
||||
|
||||
@@ -107,16 +107,21 @@ class MeshDelegateHandler(
|
||||
meshNoiseKeyForPeer = { pid -> getPeerInfo(pid)?.noisePublicKey },
|
||||
meshHasPeer = { pid -> peers.contains(pid) },
|
||||
nostrPubHexForAlias = { alias ->
|
||||
// Best-effort: derive pub hex from favorites mapping
|
||||
val prefix = alias.removePrefix("nostr_")
|
||||
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
|
||||
// Use GeohashAliasRegistry for geohash aliases, but for mesh favorites, derive from favorites mapping
|
||||
if (com.bitchat.android.nostr.GeohashAliasRegistry.contains(alias)) {
|
||||
com.bitchat.android.nostr.GeohashAliasRegistry.get(alias)
|
||||
} else {
|
||||
// Best-effort: derive pub hex from favorites mapping for mesh nostr_ aliases
|
||||
val prefix = alias.removePrefix("nostr_")
|
||||
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) }
|
||||
)
|
||||
|
||||
@@ -52,8 +52,18 @@ class MessageManager(private val state: ChatState) {
|
||||
currentChannelMessages[channel] = channelMessageList
|
||||
state.setChannelMessages(currentChannelMessages)
|
||||
|
||||
// Update unread count if not currently in this channel
|
||||
if (state.getCurrentChannelValue() != channel) {
|
||||
// Update unread count if not currently viewing this 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()
|
||||
currentUnread[channel] = (currentUnread[channel] ?: 0) + 1
|
||||
state.setUnreadChannelMessages(currentUnread)
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.bitchat.android.ui
|
||||
import com.bitchat.android.model.BitchatMessage
|
||||
import com.bitchat.android.model.DeliveryStatus
|
||||
import com.bitchat.android.mesh.PeerFingerprintManager
|
||||
import java.security.MessageDigest
|
||||
|
||||
import com.bitchat.android.mesh.BluetoothMeshService
|
||||
import java.util.*
|
||||
import android.util.Log
|
||||
@@ -123,21 +125,40 @@ class PrivateChatManager(
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
val wasFavorite = dataManager.isFavorite(fingerprint)
|
||||
val wasFavorite = dataManager.isFavorite(fingerprint!!)
|
||||
Log.d(TAG, "Current favorite status: $wasFavorite")
|
||||
|
||||
val currentFavorites = state.getFavoritePeersValue()
|
||||
Log.d(TAG, "Current UI state favorites: $currentFavorites")
|
||||
|
||||
if (wasFavorite) {
|
||||
dataManager.removeFavorite(fingerprint)
|
||||
dataManager.removeFavorite(fingerprint!!)
|
||||
Log.d(TAG, "Removed from favorites: $fingerprint")
|
||||
} else {
|
||||
dataManager.addFavorite(fingerprint)
|
||||
dataManager.addFavorite(fingerprint!!)
|
||||
Log.d(TAG, "Added to favorites: $fingerprint")
|
||||
}
|
||||
|
||||
@@ -149,6 +170,7 @@ class PrivateChatManager(
|
||||
Log.d(TAG, "All peer fingerprints: ${fingerprintManager.getAllPeerFingerprints()}")
|
||||
}
|
||||
|
||||
|
||||
fun isFavorite(peerID: String): Boolean {
|
||||
val fingerprint = fingerprintManager.getFingerprintForPeer(peerID) ?: return false
|
||||
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"
|
||||
)
|
||||
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
|
||||
|
||||
private fun getPeerIDForNickname(nickname: String, meshService: BluetoothMeshService): String? {
|
||||
|
||||
@@ -413,6 +413,22 @@ fun PeopleSection(
|
||||
val isMappedToConnected = noiseHexByPeerID.values.any { it.equals(favPeerID, ignoreCase = true) }
|
||||
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,
|
||||
// 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
|
||||
@@ -420,13 +436,20 @@ fun PeopleSection(
|
||||
val (bName, _) = com.bitchat.android.ui.splitSuffix(dn)
|
||||
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(
|
||||
peerID = favPeerID,
|
||||
displayName = dn,
|
||||
isDirect = false,
|
||||
isSelected = (mappedConnectedPeerID ?: favPeerID) == selectedPrivatePeer,
|
||||
isFavorite = true,
|
||||
hasUnreadDM = hasUnreadPrivateMessages.contains(favPeerID),
|
||||
hasUnreadDM = hasUnread,
|
||||
colorScheme = colorScheme,
|
||||
viewModel = viewModel,
|
||||
onItemClick = { onPrivateChatStart(mappedConnectedPeerID ?: favPeerID) },
|
||||
@@ -434,21 +457,23 @@ fun PeopleSection(
|
||||
Log.d("SidebarComponents", "Sidebar toggle favorite (offline): peerID=$favPeerID")
|
||||
viewModel.toggleFavorite(favPeerID)
|
||||
},
|
||||
unreadCount = privateChats[favPeerID]?.count { msg ->
|
||||
msg.sender != nickname && hasUnreadPrivateMessages.contains(favPeerID)
|
||||
} ?: if (hasUnreadPrivateMessages.contains(favPeerID)) 1 else 0,
|
||||
unreadCount = if (unreadCount > 0) unreadCount else if (hasUnread) 1 else 0,
|
||||
showNostrGlobe = (fav.isMutual && fav.peerNostrPublicKey != null),
|
||||
showHashSuffix = showHash
|
||||
)
|
||||
appendedOfflineIds.add(favPeerID)
|
||||
}
|
||||
|
||||
// Also show any incoming Nostr chats that exist locally but are not in connected peers or favorites yet
|
||||
// This ensures a user can open and read Nostr messages while the sender remains offline
|
||||
// NOTE: Do NOT append Nostr-only (nostr_*) conversations to the mesh people list.
|
||||
// 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
|
||||
privateChats.keys
|
||||
.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) &&
|
||||
// Skip if this key maps to a connected peer via noiseHex mapping
|
||||
!noiseHexByPeerID.values.any { it.equals(key, ignoreCase = true) }
|
||||
@@ -460,24 +485,27 @@ fun PeopleSection(
|
||||
val (bName, _) = com.bitchat.android.ui.splitSuffix(dn)
|
||||
val showHash = (baseNameCounts[bName] ?: 0) > 1
|
||||
|
||||
PeerItem(
|
||||
peerID = convKey,
|
||||
displayName = dn,
|
||||
isDirect = false,
|
||||
isSelected = convKey == selectedPrivatePeer,
|
||||
isFavorite = false,
|
||||
hasUnreadDM = hasUnreadPrivateMessages.contains(convKey),
|
||||
colorScheme = colorScheme,
|
||||
viewModel = viewModel,
|
||||
PeerItem(
|
||||
peerID = convKey,
|
||||
displayName = dn,
|
||||
isDirect = false,
|
||||
isSelected = convKey == selectedPrivatePeer,
|
||||
isFavorite = false,
|
||||
hasUnreadDM = hasUnreadPrivateMessages.contains(convKey),
|
||||
colorScheme = colorScheme,
|
||||
viewModel = viewModel,
|
||||
onItemClick = { onPrivateChatStart(convKey) },
|
||||
onToggleFavorite = { viewModel.toggleFavorite(convKey) },
|
||||
unreadCount = privateChats[convKey]?.count { msg ->
|
||||
msg.sender != nickname && hasUnreadPrivateMessages.contains(convKey)
|
||||
} ?: if (hasUnreadPrivateMessages.contains(convKey)) 1 else 0,
|
||||
showNostrGlobe = true,
|
||||
showNostrGlobe = false,
|
||||
showHashSuffix = showHash
|
||||
)
|
||||
}
|
||||
*/
|
||||
// End intentional removal
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,6 +566,15 @@ private fun PeerItem(
|
||||
modifier = Modifier.size(16.dp),
|
||||
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 {
|
||||
Icon(
|
||||
imageVector = if (isDirect) Icons.Outlined.SettingsInputAntenna else Icons.Filled.Route,
|
||||
|
||||
@@ -262,6 +262,8 @@ fun DebugSettingsSheet(
|
||||
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) {
|
||||
Text("max overall", fontFamily = FontFamily.Monospace, modifier = Modifier.width(90.dp))
|
||||
Slider(
|
||||
@@ -307,29 +309,35 @@ fun DebugSettingsSheet(
|
||||
}
|
||||
val maxValRaw = series.maxOrNull() ?: 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()) {
|
||||
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 availW = (size.width - axisPx).coerceAtLeast(1f)
|
||||
val w = availW / barCount
|
||||
val h = size.height
|
||||
// draw baseline at y=0 (bottom)
|
||||
// Baseline at bottom (y = 0)
|
||||
drawLine(
|
||||
color = Color(0x33888888),
|
||||
start = androidx.compose.ui.geometry.Offset(axisPx, h - 1f),
|
||||
end = androidx.compose.ui.geometry.Offset(size.width, h - 1f),
|
||||
strokeWidth = 1f
|
||||
)
|
||||
// Bars from bottom-up; skip zeros entirely
|
||||
series.forEachIndexed { i, value ->
|
||||
val ratio = if (maxVal > 0f) (value / maxVal).coerceIn(0f, 1f) else 0f // min always 0
|
||||
val barHeight = h * ratio
|
||||
// Draw bars from bottom up, starting after left axis area
|
||||
drawRect(
|
||||
color = Color(0xFF00C851),
|
||||
topLeft = androidx.compose.ui.geometry.Offset(x = axisPx + i * w, y = h - barHeight),
|
||||
size = androidx.compose.ui.geometry.Size(w, barHeight)
|
||||
)
|
||||
if (value > 0f && maxVal > 0f) {
|
||||
val ratio = (value / maxVal).coerceIn(0f, 1f)
|
||||
val barHeight = (h * ratio).coerceAtLeast(0f)
|
||||
if (barHeight > 0.5f) {
|
||||
drawRect(
|
||||
color = Color(0xFF00C851),
|
||||
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
|
||||
|
||||
@@ -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