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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions mobile_app/.env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
# Optional local Reticulum/LXMF TCP bridge. Use a real LAN IP for a physical
# phone. `localhost` and placeholder hosts are ignored to avoid retry storms.
EXPO_PUBLIC_LOCAL_LXMF_HOST=192.168.x.x
EXPO_PUBLIC_LOCAL_LXMF_PORT=4243
EXPO_PUBLIC_LXMF_LOG_LEVEL=1
# Copy to `.env` (gitignored) and fill in before running.
# All values with EXPO_PUBLIC_ prefix are inlined at build time — never put
# secrets here that you wouldn't ship in a public bundle.
Expand Down
1 change: 1 addition & 0 deletions mobile_app/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
],
"expo-secure-store",
"expo-local-authentication",
"./plugins/withDisableAndroidContentCapture",
[
"expo-notifications",
{
Expand Down
11 changes: 8 additions & 3 deletions mobile_app/app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ const E = StyleSheet.create({
text: { flex: 1, fontSize: 12, lineHeight: 17 },
});

function NotificationBridge({ onInApp }: { readonly onInApp: (n: NotificationPayload) => void }) {
const [notifsEnabled] = useNotificationEnabled();
useMessageNotifications(onInApp, notifsEnabled);
usePeerCountNotification(notifsEnabled);
return null;
}

function AppShell() {
const router = useRouter();
const { colors } = useTheme();
Expand All @@ -77,9 +84,6 @@ function AppShell() {
}, []);

useBackgroundService();
const [notifsEnabled] = useNotificationEnabled();
useMessageNotifications(handleInApp, notifsEnabled);
usePeerCountNotification(notifsEnabled);

return (
<View style={[R.appRoot, { backgroundColor: colors.background }]}>
Expand All @@ -98,6 +102,7 @@ function AppShell() {
</NavThemeProvider>

<LxmfErrorBanner />
<NotificationBridge onInApp={handleInApp} />

<InAppNotificationBanner
notification={activeNotif}
Expand Down
7 changes: 3 additions & 4 deletions mobile_app/components/home/NearbyPeersCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,9 @@ function initialOf(alias: string | undefined): string {

// Peer presence strip above Recent.
//
// LxmfContext.peers is a persistent accumulator (keyed by destHash) that
// monotonically grows as announces arrive — so on mount it's briefly
// empty before events flush through. To avoid "0 → 6" flicker we use
// `useMemo` + filter for stability.
// LxmfContext.peers is keyed by destHash and pruned to recent announces.
// On mount it can still be briefly empty before events flush through, so
// `useMemo` + filtering keeps the visible state stable.
//
// Tap opens teammate's MeshMap on the Nodes tab — he owns peer
// visualization; we don't duplicate.
Expand Down
124 changes: 103 additions & 21 deletions mobile_app/context/LxmfContext.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import * as FileSystem from 'expo-file-system/legacy';
import { InteractionManager } from 'react-native';
import {
SecureKeys, LegacySecureKeys, PrefKeys,
secureGet, secureSet, secureDelete, secureDeleteAll,
Expand All @@ -20,6 +21,9 @@ import { requestBLEPermissions } from '@/src/utils/blePermissions';
import * as ExpoCrypto from 'expo-crypto';

const IDENTITY_SCHEMA_VERSION = 1;
const PEER_FRESH_WINDOW_SEC = 10 * 60;
const MAX_TRACKED_PEERS = 300;
const EPOCH_MS_THRESHOLD = 10_000_000_000;

type StoredIdentity = {
version: number;
Expand Down Expand Up @@ -137,6 +141,7 @@ function sliceNewEvents(
const first = events[0] ?? null;
if (prevFirst !== null && first !== prevFirst) {
const oldIdx = events.indexOf(prevFirst);
if (oldIdx === -1) return events;
return oldIdx > 0 ? events.slice(0, oldIdx) : [];
}
return [];
Expand Down Expand Up @@ -231,7 +236,9 @@ function mergeBeacon(
if (b.destHash === ownHash) return false;
const existing = map.get(b.destHash);
const isOnline = b.state === 'active';
const lastSeen = b.lastAnnounce > 0 ? b.lastAnnounce : (existing?.lastSeen ?? now);
const lastSeen = b.lastAnnounce > 0
? (b.lastAnnounce > EPOCH_MS_THRESHOLD ? b.lastAnnounce / 1000 : b.lastAnnounce)
: (existing?.lastSeen ?? now);
const dispName = names[b.destHash] ?? existing?.displayName ?? b.destHash.slice(0, 8);
if (existing?.online === isOnline && existing.lastSeen === lastSeen && existing.displayName === dispName)
return false;
Expand All @@ -248,6 +255,43 @@ function mergeBeacon(
return true;
}

function prunePeerMap(map: PeerMap, now: number, ownHash: string | undefined): boolean {
let changed = false;

if (ownHash && map.delete(ownHash)) changed = true;

for (const [hash, peer] of map) {
let lastSeen = peer.lastSeen;
if (lastSeen > EPOCH_MS_THRESHOLD) {
lastSeen = lastSeen / 1000;
map.set(hash, { ...peer, lastSeen });
changed = true;
}
if (now - lastSeen > PEER_FRESH_WINDOW_SEC) {
map.delete(hash);
changed = true;
}
}

if (map.size <= MAX_TRACKED_PEERS) return changed;

const keep = new Set(
Array.from(map.values())
.sort((a, b) => b.lastSeen - a.lastSeen)
.slice(0, MAX_TRACKED_PEERS)
.map((peer) => peer.destHash),
);

for (const hash of map.keys()) {
if (!keep.has(hash)) {
map.delete(hash);
changed = true;
}
}

return changed;
}

export const G00N_HUB: TcpInterface = { host: 'dfw.us.g00n.cloud', port: 6969 };
export const BELETH_HUB: TcpInterface = { host: 'rns.beleth.net', port: 4242 };

Expand Down Expand Up @@ -285,6 +329,28 @@ export interface StoredMessage {
files?: { name: string; data: string }[];
}

const LXMF_LOG_LEVEL = Number(process.env.EXPO_PUBLIC_LXMF_LOG_LEVEL ?? 1);
const LXMF_AUTOSTART_DELAY_MS = 1_500;

function isUsableTcpHost(host: string): boolean {
const normalized = host.trim().toLowerCase();
if (!normalized) return false;
if (normalized === 'localhost') return false;
if (normalized === '0.0.0.0') return false;
if (normalized === '::1') return false;
if (normalized.startsWith('127.')) return false;
if (normalized.includes('x.x')) return false;
return true;
}

function configuredTcpInterfaces(): TcpInterface[] {
const interfaces = [G00N_HUB, BELETH_HUB];
if (MY_PC && isUsableTcpHost(MY_PC.host) && Number.isFinite(MY_PC.port) && MY_PC.port > 0) {
interfaces.unshift(MY_PC);
}
return interfaces;
}

export interface LxmfPeer {
destHash: string;
displayName: string;
Expand Down Expand Up @@ -381,31 +447,45 @@ export function LxmfProvider({ children }: { readonly children: React.ReactNode
const lxmf = useLxmf({
identityHex: storedIdentity?.identity_hex ?? 'new',
lxmfAddressHex: storedIdentity?.address_hex ?? 'new',
logLevel: __DEV__ ? 2 : 1,
logLevel: Number.isFinite(LXMF_LOG_LEVEL) ? LXMF_LOG_LEVEL : 1,
dbPath: (FileSystem.documentDirectory ?? '') + 'lxmf.db',
});

const { isNativeAvailable, isRunning, start, stop, getIdentityHex } = lxmf;
const startingRef = useRef(false);
const autostartTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
if (!isNativeAvailable || isRunning || startingRef.current || displayName === null || !identityHydrated) return;
startingRef.current = true;
// Request BLE permissions first — start() auto-activates BLE hardware
requestBLEPermissions().then(perm => {
if (perm !== 'granted' && perm !== 'not_required') {
startingRef.current = false;
return;
let cancelled = false;
const interaction = InteractionManager.runAfterInteractions(() => {
autostartTimerRef.current = setTimeout(() => {
if (cancelled || isRunning || startingRef.current) return;
startingRef.current = true;
requestBLEPermissions().then(perm => {
if (cancelled || (perm !== 'granted' && perm !== 'not_required')) return false;
return start({
mode: LxmfNodeMode.ReticulumAndBle,
tcpInterfaces: configuredTcpInterfaces(),
displayName,
identityHex: storedIdentity?.identity_hex ?? 'new',
lxmfAddressHex: storedIdentity?.address_hex ?? 'new',
isBeacon,
});
}).then(ok => {
if (ok && !cancelled) setBleActive(true);
}).finally(() => { startingRef.current = false; });
}, LXMF_AUTOSTART_DELAY_MS);
});

return () => {
cancelled = true;
if (autostartTimerRef.current) {
clearTimeout(autostartTimerRef.current);
autostartTimerRef.current = null;
}
return start({
mode: LxmfNodeMode.ReticulumAndBle,
tcpInterfaces: [MY_PC, G00N_HUB, BELETH_HUB].filter(Boolean) as TcpInterface[],
displayName,
identityHex: storedIdentity?.identity_hex ?? 'new',
lxmfAddressHex: storedIdentity?.address_hex ?? 'new',
isBeacon,
}).then(ok => { if (ok) setBleActive(true); });
}).finally(() => { startingRef.current = false; });
interaction.cancel();
};
}, [isNativeAvailable, isRunning, start, displayName, identityHydrated, storedIdentity, isBeacon]);

// Persist identity after node starts (using getIdentityHex() per new API)
Expand Down Expand Up @@ -461,12 +541,14 @@ export function LxmfProvider({ children }: { readonly children: React.ReactNode
prefGetJson<LxmfPeer[]>(PrefKeys.PEERS_CACHE).then(cached => {
if (!cached) return;
const map = knownPeersRef.current;
const now = Date.now() / 1000;
for (const p of cached) {
if (!map.has(p.destHash)) map.set(p.destHash, { ...p, online: false, isBeaconNode: p.isBeaconNode ?? false });
}
prunePeerMap(map, now, lxmf.status?.addressHex);
setPeers(Array.from(map.values()));
});
}, []);
}, [lxmf.status?.addressHex]);

useEffect(() => {
const map = knownPeersRef.current;
Expand All @@ -491,12 +573,12 @@ export function LxmfProvider({ children }: { readonly children: React.ReactNode

let { peerChanged, nameChanged } = processNewEvents(newEvts, map, names, now, ownHash, bleActive);

if (ownHash) map.delete(ownHash);

for (const b of lxmf.beacons) {
if (mergeBeacon(b, map, names, now, ownHash)) peerChanged = true;
}

if (prunePeerMap(map, now, ownHash)) peerChanged = true;

if (nameChanged) setNameMap({ ...names });

if (!peerChanged) return;
Expand Down Expand Up @@ -613,7 +695,7 @@ export function LxmfProvider({ children }: { readonly children: React.ReactNode
if (bleActive) return;
const ok = await start({
mode: LxmfNodeMode.ReticulumAndBle,
tcpInterfaces: [MY_PC, G00N_HUB, BELETH_HUB].filter(Boolean) as TcpInterface[],
tcpInterfaces: configuredTcpInterfaces(),
displayName: displayName ?? '',
identityHex: storedIdentity?.identity_hex ?? 'new',
lxmfAddressHex: storedIdentity?.address_hex ?? 'new',
Expand Down
31 changes: 31 additions & 0 deletions mobile_app/plugins/withDisableAndroidContentCapture.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
const { withMainActivity } = require("@expo/config-plugins");

const IMPORT_VIEW = "import android.view.View";
const CONTENT_CAPTURE_BLOCK = `
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.decorView.importantForContentCapture =
View.IMPORTANT_FOR_CONTENT_CAPTURE_NO_EXCLUDE_DESCENDANTS
}
`;

function withDisableAndroidContentCapture(config) {
return withMainActivity(config, (mod) => {
if (mod.modResults.language !== "kt") {
return mod;
}

let contents = mod.modResults.contents;
if (!contents.includes(IMPORT_VIEW)) {
contents = contents.replace("import android.os.Bundle", `import android.os.Bundle\n${IMPORT_VIEW}`);
}

if (!contents.includes("IMPORTANT_FOR_CONTENT_CAPTURE_NO_EXCLUDE_DESCENDANTS")) {
contents = contents.replace(" super.onCreate(null)", ` super.onCreate(null)\n${CONTENT_CAPTURE_BLOCK}`);
}

mod.modResults.contents = contents;
return mod;
});
}

module.exports = withDisableAndroidContentCapture;