@magicred-1/react-native-lxmf 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/main/jniLibs/arm64-v8a/liblxmf_rn.so +0 -0
- package/android/src/main/jniLibs/armeabi-v7a/liblxmf_rn.so +0 -0
- package/android/src/main/jniLibs/x86_64/liblxmf_rn.so +0 -0
- package/android/src/main/kotlin/expo/modules/lxmf/BleManager.kt +123 -54
- package/android/src/main/kotlin/expo/modules/lxmf/LxmfModule.kt +1 -0
- package/example/README.md +50 -0
- package/example/app/(tabs)/_layout.tsx +28 -0
- package/example/app/(tabs)/index.tsx +874 -0
- package/example/app/_layout.tsx +14 -0
- package/example/app/modal.tsx +29 -0
- package/example/app.json +72 -0
- package/example/assets/images/android-icon-background.png +0 -0
- package/example/assets/images/android-icon-foreground.png +0 -0
- package/example/assets/images/android-icon-monochrome.png +0 -0
- package/example/assets/images/favicon.png +0 -0
- package/example/assets/images/icon.png +0 -0
- package/example/assets/images/partial-react-logo.png +0 -0
- package/example/assets/images/react-logo.png +0 -0
- package/example/assets/images/react-logo@2x.png +0 -0
- package/example/assets/images/react-logo@3x.png +0 -0
- package/example/assets/images/splash-icon.png +0 -0
- package/example/components/external-link.tsx +25 -0
- package/example/components/haptic-tab.tsx +18 -0
- package/example/components/hello-wave.tsx +19 -0
- package/example/components/parallax-scroll-view.tsx +79 -0
- package/example/components/themed-text.tsx +60 -0
- package/example/components/themed-view.tsx +14 -0
- package/example/components/ui/collapsible.tsx +45 -0
- package/example/components/ui/icon-symbol.ios.tsx +32 -0
- package/example/components/ui/icon-symbol.tsx +41 -0
- package/example/constants/theme.ts +53 -0
- package/example/eslint.config.js +10 -0
- package/example/expo-env.d.ts +3 -0
- package/example/hooks/use-color-scheme.ts +1 -0
- package/example/hooks/use-color-scheme.web.ts +21 -0
- package/example/hooks/use-theme-color.ts +21 -0
- package/example/metro.config.js +37 -0
- package/example/package.json +52 -0
- package/example/scripts/reset-project.js +112 -0
- package/example/tsconfig.json +17 -0
- package/ios/BLEManager.swift +41 -6
- package/ios/LxmfModule.swift +13 -2
- package/package.json +15 -2
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
PermissionsAndroid,
|
|
4
|
+
Platform,
|
|
5
|
+
Pressable,
|
|
6
|
+
ScrollView,
|
|
7
|
+
Share,
|
|
8
|
+
StyleSheet,
|
|
9
|
+
Text,
|
|
10
|
+
TextInput,
|
|
11
|
+
View,
|
|
12
|
+
} from 'react-native';
|
|
13
|
+
import * as SecureStore from 'expo-secure-store';
|
|
14
|
+
import { LxmfModule, LxmfNodeMode, type LxmfEvent, useLxmf } from '@magicred-1/react-native-lxmf';
|
|
15
|
+
|
|
16
|
+
// Persisted identity blob schema (versioned). Stored in expo-secure-store under
|
|
17
|
+
// IDENTITY_KEY — encrypted at rest on iOS (Keychain) and Android (Keystore-backed).
|
|
18
|
+
// Schema version bumps allow forward-compatible migrations if the FFI changes.
|
|
19
|
+
const IDENTITY_KEY = 'lxmf.identity.v1';
|
|
20
|
+
const IDENTITY_SCHEMA_VERSION = 1;
|
|
21
|
+
type StoredIdentity = {
|
|
22
|
+
version: number;
|
|
23
|
+
identity_hex: string; // 128 hex chars (private key)
|
|
24
|
+
address_hex: string; // 32 hex chars (LXMF address)
|
|
25
|
+
created_at: string; // ISO8601
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function isValidIdentity(blob: unknown): blob is StoredIdentity {
|
|
29
|
+
if (!blob || typeof blob !== 'object') return false;
|
|
30
|
+
const b = blob as Record<string, unknown>;
|
|
31
|
+
return (
|
|
32
|
+
typeof b.version === 'number' &&
|
|
33
|
+
typeof b.identity_hex === 'string' && /^[0-9a-fA-F]{128}$/.test(b.identity_hex) &&
|
|
34
|
+
typeof b.address_hex === 'string' && /^[0-9a-fA-F]{32}$/.test(b.address_hex) &&
|
|
35
|
+
typeof b.created_at === 'string'
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
42
|
+
|
|
43
|
+
function bytesToBase64(bytes: Uint8Array): string {
|
|
44
|
+
let out = '';
|
|
45
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
46
|
+
const b0 = bytes[i] ?? 0, b1 = bytes[i + 1] ?? 0, b2 = bytes[i + 2] ?? 0;
|
|
47
|
+
const t = (b0 << 16) | (b1 << 8) | b2;
|
|
48
|
+
out += B64[(t >> 18) & 0x3f];
|
|
49
|
+
out += B64[(t >> 12) & 0x3f];
|
|
50
|
+
out += i + 1 < bytes.length ? B64[(t >> 6) & 0x3f] : '=';
|
|
51
|
+
out += i + 2 < bytes.length ? B64[t & 0x3f] : '=';
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function utf8ToBase64(s: string): string {
|
|
57
|
+
if (typeof globalThis.btoa === 'function') return globalThis.btoa(s);
|
|
58
|
+
if (typeof TextEncoder !== 'undefined') return bytesToBase64(new TextEncoder().encode(s));
|
|
59
|
+
return s;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function shortHex(v: string): string {
|
|
63
|
+
if (!v) return '—';
|
|
64
|
+
return v.length <= 12 ? v : `${v.slice(0, 6)}…${v.slice(-6)}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function ts(e: LxmfEvent): number | null {
|
|
68
|
+
const r = e.timestamp ?? e.ts ?? e.time ?? e.epoch;
|
|
69
|
+
if (typeof r === 'number' && Number.isFinite(r)) return r;
|
|
70
|
+
if (typeof r === 'string') { const n = Number(r); return Number.isFinite(n) ? n : null; }
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function fmtTime(e: LxmfEvent): string {
|
|
75
|
+
const t = ts(e);
|
|
76
|
+
if (!t) return 'now';
|
|
77
|
+
return new Date(t > 10_000_000_000 ? t : t * 1000)
|
|
78
|
+
.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Extract message text from raw LXMF hex content.
|
|
82
|
+
* Skips 96B header (dest+src+sig), scans for longest printable run. */
|
|
83
|
+
function decodeLxmfContent(hexOrStr: string | undefined): string {
|
|
84
|
+
if (!hexOrStr) return '';
|
|
85
|
+
const hex = String(hexOrStr);
|
|
86
|
+
if (!/^[0-9a-fA-F]+$/.test(hex)) return hex;
|
|
87
|
+
const len = hex.length / 2;
|
|
88
|
+
if (len <= 96) return `[${len}B raw]`;
|
|
89
|
+
try {
|
|
90
|
+
const payload = new Uint8Array(len - 96);
|
|
91
|
+
for (let i = 0; i < payload.length; i++) {
|
|
92
|
+
payload[i] = Number.parseInt(hex.slice((i + 96) * 2, (i + 96) * 2 + 2), 16);
|
|
93
|
+
}
|
|
94
|
+
const decoded = new TextDecoder('utf-8', { fatal: false }).decode(payload);
|
|
95
|
+
const runs = decoded.match(/[\x20-\x7E\n\r\t]{4,}/g) ?? [];
|
|
96
|
+
if (runs.length === 0) return `[${len}B binary]`;
|
|
97
|
+
return runs.reduce((a, b) => a.length >= b.length ? a : b).trim();
|
|
98
|
+
} catch {
|
|
99
|
+
return `[${len}B]`;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function evtSummary(e: LxmfEvent): string {
|
|
104
|
+
if (e.type === 'announceReceived') {
|
|
105
|
+
const from = shortHex(String(e.destHash ?? e.address ?? e.source ?? '?'));
|
|
106
|
+
const hops = e.hops ?? e.hopCount;
|
|
107
|
+
return hops === undefined ? `Announce ${from}` : `Announce ${from} (${hops} hop)`;
|
|
108
|
+
}
|
|
109
|
+
if (e.type === 'messageReceived') return `Msg from ${shortHex(String(e.source ?? e.from ?? '?'))}`;
|
|
110
|
+
if (e.type === 'log') return String(e.message ?? e.msg ?? 'log');
|
|
111
|
+
if (e.type === 'error') return String(e.message ?? 'error');
|
|
112
|
+
return e.type;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function evtKey(e: LxmfEvent, prefix = ''): string {
|
|
116
|
+
const t = ts(e) ?? 'na';
|
|
117
|
+
const m = String(e.id ?? e.receipt ?? e.destHash ?? e.source ?? e.message ?? 'ev');
|
|
118
|
+
return `${prefix}${e.type}-${t}-${m}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function copyToClipboard(text: string) {
|
|
122
|
+
try {
|
|
123
|
+
await Share.share({ message: text });
|
|
124
|
+
} catch {}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Accordion ─────────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
function Accordion({
|
|
130
|
+
title,
|
|
131
|
+
badge,
|
|
132
|
+
defaultOpen = false,
|
|
133
|
+
children,
|
|
134
|
+
}: Readonly<{
|
|
135
|
+
title: string;
|
|
136
|
+
badge?: string | number;
|
|
137
|
+
defaultOpen?: boolean;
|
|
138
|
+
children: React.ReactNode;
|
|
139
|
+
}>) {
|
|
140
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
141
|
+
return (
|
|
142
|
+
<View style={S.accordion}>
|
|
143
|
+
<Pressable
|
|
144
|
+
style={({ pressed }) => [S.accordionHeader, pressed && S.accordionHeaderPressed]}
|
|
145
|
+
onPress={() => setOpen(o => !o)}>
|
|
146
|
+
<Text style={S.accordionChevron}>{open ? '▾' : '▸'}</Text>
|
|
147
|
+
<Text style={S.accordionTitle}>{title}</Text>
|
|
148
|
+
{badge === undefined ? null : (
|
|
149
|
+
<View style={S.accordionBadge}>
|
|
150
|
+
<Text style={S.accordionBadgeText}>{badge}</Text>
|
|
151
|
+
</View>
|
|
152
|
+
)}
|
|
153
|
+
</Pressable>
|
|
154
|
+
{open ? <View style={S.accordionBody}>{children}</View> : null}
|
|
155
|
+
</View>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Tiny components ──────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
function Btn({
|
|
162
|
+
label, onPress, disabled, danger, small,
|
|
163
|
+
}: Readonly<{ label: string; onPress: () => void; disabled?: boolean; danger?: boolean; small?: boolean }>) {
|
|
164
|
+
return (
|
|
165
|
+
<Pressable
|
|
166
|
+
style={({ pressed }) => [
|
|
167
|
+
S.btn, danger && S.btnDanger, disabled && S.btnDisabled, small && S.btnSmall,
|
|
168
|
+
pressed && !disabled && S.btnPressed,
|
|
169
|
+
]}
|
|
170
|
+
onPress={onPress}
|
|
171
|
+
disabled={disabled}>
|
|
172
|
+
<Text style={[S.btnText, small && S.btnTextSmall]}>{label}</Text>
|
|
173
|
+
</Pressable>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function Row({ label, value, onCopy }: Readonly<{ label: string; value: string; onCopy?: () => void }>) {
|
|
178
|
+
return (
|
|
179
|
+
<View style={S.statRow}>
|
|
180
|
+
<Text style={S.statLabel}>{label}</Text>
|
|
181
|
+
<View style={S.statValueRow}>
|
|
182
|
+
<Text selectable style={S.statValue}>{value}</Text>
|
|
183
|
+
{onCopy ? (
|
|
184
|
+
<Pressable onPress={onCopy} style={S.copyBtn}>
|
|
185
|
+
<Text style={S.copyBtnText}>⎘</Text>
|
|
186
|
+
</Pressable>
|
|
187
|
+
) : null}
|
|
188
|
+
</View>
|
|
189
|
+
</View>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function Pill({ label, active }: Readonly<{ label: string; active: boolean }>) {
|
|
194
|
+
return (
|
|
195
|
+
<View style={[S.pill, active && S.pillActive]}>
|
|
196
|
+
<Text style={[S.pillText, active && S.pillTextActive]}>{label}</Text>
|
|
197
|
+
</View>
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Main screen ──────────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
export default function HomeScreen() {
|
|
204
|
+
// Transport state
|
|
205
|
+
const [tcpHost, setTcpHost] = useState('192.168.1.135');
|
|
206
|
+
const [tcpPort, setTcpPort] = useState('4243');
|
|
207
|
+
const [displayName, setDisplayName] = useState('lxmf-mobile');
|
|
208
|
+
const [bleActive, setBleActive] = useState(false);
|
|
209
|
+
const [tcpActive, setTcpActive] = useState(false);
|
|
210
|
+
const [transportMsg, setTransportMsg] = useState('');
|
|
211
|
+
|
|
212
|
+
// Send state
|
|
213
|
+
const [dest, setDest] = useState('');
|
|
214
|
+
const [msgText, setMsgText] = useState('Hello from LXMF');
|
|
215
|
+
const [sendResult, setSendResult] = useState('');
|
|
216
|
+
|
|
217
|
+
const [unpairedRNodes, setUnpairedRNodes] = useState(0);
|
|
218
|
+
const [liveBleCount, setLiveBleCount] = useState(0);
|
|
219
|
+
|
|
220
|
+
// Identity hydration: read once from secure store on mount. Until hydrated,
|
|
221
|
+
// we pass 'new' so Rust generates a fresh identity (which we'll then persist
|
|
222
|
+
// after start succeeds, see effect below).
|
|
223
|
+
const [storedIdentity, setStoredIdentity] = useState<StoredIdentity | null>(null);
|
|
224
|
+
const [identityHydrated, setIdentityHydrated] = useState(false);
|
|
225
|
+
useEffect(() => {
|
|
226
|
+
let cancelled = false;
|
|
227
|
+
(async () => {
|
|
228
|
+
try {
|
|
229
|
+
const raw = await SecureStore.getItemAsync(IDENTITY_KEY);
|
|
230
|
+
// TODO(sentinel): remove debug logs before merge — lengths/booleans only, no key material
|
|
231
|
+
console.log('[persist] hydrate', { hasRaw: !!raw, rawLen: raw?.length ?? 0 });
|
|
232
|
+
if (cancelled) return;
|
|
233
|
+
if (raw) {
|
|
234
|
+
const parsed = JSON.parse(raw);
|
|
235
|
+
const valid = isValidIdentity(parsed);
|
|
236
|
+
console.log('[persist] parsed', { valid, hasIdHex: typeof parsed?.identity_hex === 'string', hasAddrHex: typeof parsed?.address_hex === 'string' });
|
|
237
|
+
if (valid) {
|
|
238
|
+
setStoredIdentity(parsed);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
} catch (e: any) {
|
|
242
|
+
console.log('[persist] hydrate FAIL', e?.message ?? 'unknown');
|
|
243
|
+
// Corrupt blob or storage error — fall through; we'll generate fresh.
|
|
244
|
+
} finally {
|
|
245
|
+
if (!cancelled) setIdentityHydrated(true);
|
|
246
|
+
}
|
|
247
|
+
})();
|
|
248
|
+
return () => { cancelled = true; };
|
|
249
|
+
}, []);
|
|
250
|
+
|
|
251
|
+
const {
|
|
252
|
+
isNativeAvailable, isRunning, status, error, events, beacons,
|
|
253
|
+
start, stop, send, getStatus, getIdentityHex,
|
|
254
|
+
startBLE, stopBLE, bleUnpairedRNodeCount,
|
|
255
|
+
} = useLxmf({
|
|
256
|
+
identityHex: storedIdentity?.identity_hex ?? 'new',
|
|
257
|
+
lxmfAddressHex: storedIdentity?.address_hex ?? 'new',
|
|
258
|
+
logLevel: 3,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Persist identity after node starts successfully (only if not already stored,
|
|
262
|
+
// or if the running identity differs — defensive against schema migrations).
|
|
263
|
+
useEffect(() => {
|
|
264
|
+
if (!isRunning) return;
|
|
265
|
+
const idHex = getIdentityHex();
|
|
266
|
+
const addrHex = status?.addressHex;
|
|
267
|
+
// TODO(sentinel): remove debug logs before merge — lengths/booleans only, no key material
|
|
268
|
+
console.log('[persist] save check', {
|
|
269
|
+
idHexLen: idHex?.length ?? 0,
|
|
270
|
+
addrHexLen: addrHex?.length ?? 0,
|
|
271
|
+
alreadyStoredSame: storedIdentity?.identity_hex === idHex && storedIdentity?.address_hex === addrHex,
|
|
272
|
+
});
|
|
273
|
+
if (!idHex || idHex.length !== 128) return;
|
|
274
|
+
if (!addrHex || !/^[0-9a-fA-F]{32}$/.test(addrHex)) return;
|
|
275
|
+
if (storedIdentity?.identity_hex === idHex && storedIdentity?.address_hex === addrHex) return;
|
|
276
|
+
|
|
277
|
+
const blob: StoredIdentity = {
|
|
278
|
+
version: IDENTITY_SCHEMA_VERSION,
|
|
279
|
+
identity_hex: idHex,
|
|
280
|
+
address_hex: addrHex,
|
|
281
|
+
created_at: new Date().toISOString(),
|
|
282
|
+
};
|
|
283
|
+
SecureStore.setItemAsync(IDENTITY_KEY, JSON.stringify(blob))
|
|
284
|
+
.then(() => {
|
|
285
|
+
console.log('[persist] save OK');
|
|
286
|
+
setStoredIdentity(blob);
|
|
287
|
+
})
|
|
288
|
+
.catch((e: any) => {
|
|
289
|
+
console.log('[persist] save FAIL', e?.message ?? 'unknown');
|
|
290
|
+
/* persistence failure is non-fatal for the running session */
|
|
291
|
+
});
|
|
292
|
+
}, [isRunning, status?.addressHex, storedIdentity, getIdentityHex]);
|
|
293
|
+
|
|
294
|
+
// ── Derived ───────────────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
const counts = useMemo(() => {
|
|
297
|
+
let announces = 0, logs = 0, messages = 0, errors = 0;
|
|
298
|
+
for (const e of events) {
|
|
299
|
+
if (e.type === 'announceReceived') announces++;
|
|
300
|
+
if (e.type === 'log') logs++;
|
|
301
|
+
if (e.type === 'messageReceived') messages++;
|
|
302
|
+
if (e.type === 'error') errors++;
|
|
303
|
+
}
|
|
304
|
+
return { announces, logs, messages, errors };
|
|
305
|
+
}, [events]);
|
|
306
|
+
|
|
307
|
+
const announceEvts = useMemo(() => events.filter(e => e.type === 'announceReceived').slice(0, 20), [events]);
|
|
308
|
+
const msgEvts = useMemo(() => events.filter(e => e.type === 'messageReceived').slice(0, 20), [events]);
|
|
309
|
+
const logEvts = useMemo(() => events.filter(e => e.type === 'log').slice(0, 100), [events]);
|
|
310
|
+
|
|
311
|
+
// Deduped peer identity hashes from all announce events (any interface)
|
|
312
|
+
const knownPeerHashes = useMemo(() => {
|
|
313
|
+
const map = new Map<string, { hash: string; name: string; lastSeen: string }>();
|
|
314
|
+
for (const e of events) {
|
|
315
|
+
if (e.type !== 'announceReceived') continue;
|
|
316
|
+
const hash = String(e.destHash ?? e.address ?? '');
|
|
317
|
+
if (!hash) continue;
|
|
318
|
+
if (!map.has(hash)) {
|
|
319
|
+
map.set(hash, { hash, name: e.appData ? String(e.appData) : '', lastSeen: fmtTime(e) });
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return Array.from(map.values());
|
|
323
|
+
}, [events]);
|
|
324
|
+
const allEvts = useMemo(() => events.slice(0, 30), [events]);
|
|
325
|
+
|
|
326
|
+
// ── Actions ───────────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
const onStartTcp = useCallback(async () => {
|
|
329
|
+
setTransportMsg('');
|
|
330
|
+
const host = tcpHost.trim();
|
|
331
|
+
const port = Number(tcpPort);
|
|
332
|
+
if (!host) { setTransportMsg('Host required.'); return; }
|
|
333
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) { setTransportMsg('Port 1–65535.'); return; }
|
|
334
|
+
const ok = await start({
|
|
335
|
+
mode: LxmfNodeMode.ReticulumAndBle,
|
|
336
|
+
tcpInterfaces: [{ host, port }],
|
|
337
|
+
displayName: displayName.trim() || 'lxmf-mobile',
|
|
338
|
+
});
|
|
339
|
+
if (ok) {
|
|
340
|
+
setTcpActive(true);
|
|
341
|
+
startBLE();
|
|
342
|
+
setBleActive(true);
|
|
343
|
+
}
|
|
344
|
+
}, [tcpHost, tcpPort, displayName, start, startBLE]);
|
|
345
|
+
|
|
346
|
+
const onStopTcp = useCallback(async () => {
|
|
347
|
+
stopBLE();
|
|
348
|
+
await stop();
|
|
349
|
+
setTcpActive(false);
|
|
350
|
+
setBleActive(false);
|
|
351
|
+
}, [stop, stopBLE]);
|
|
352
|
+
|
|
353
|
+
const onStartBle = useCallback(async () => {
|
|
354
|
+
setTransportMsg('');
|
|
355
|
+
if (Platform.OS === 'android') {
|
|
356
|
+
const perms = Platform.Version >= 31
|
|
357
|
+
? [
|
|
358
|
+
PermissionsAndroid.PERMISSIONS.BLUETOOTH_SCAN,
|
|
359
|
+
PermissionsAndroid.PERMISSIONS.BLUETOOTH_ADVERTISE,
|
|
360
|
+
PermissionsAndroid.PERMISSIONS.BLUETOOTH_CONNECT,
|
|
361
|
+
]
|
|
362
|
+
: [PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION];
|
|
363
|
+
const results = await PermissionsAndroid.requestMultiple(perms);
|
|
364
|
+
const denied = Object.values(results).some(r => r !== PermissionsAndroid.RESULTS.GRANTED);
|
|
365
|
+
if (denied) {
|
|
366
|
+
setTransportMsg('BLE permissions denied.');
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// If node already running (started via TCP), just enable BLE hardware.
|
|
371
|
+
// Otherwise start mode 4 (TCP+BLE) using the configured TCP host/port.
|
|
372
|
+
if (!isRunning) {
|
|
373
|
+
const host = tcpHost.trim();
|
|
374
|
+
const port = Number(tcpPort);
|
|
375
|
+
if (!host) { setTransportMsg('Host required for TCP+BLE mode.'); return; }
|
|
376
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) { setTransportMsg('Port 1–65535.'); return; }
|
|
377
|
+
const ok = await start({
|
|
378
|
+
mode: LxmfNodeMode.ReticulumAndBle,
|
|
379
|
+
tcpInterfaces: [{ host, port }],
|
|
380
|
+
displayName: displayName.trim() || 'lxmf-mobile',
|
|
381
|
+
});
|
|
382
|
+
if (!ok) {
|
|
383
|
+
setTransportMsg('Failed to start node.');
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
setTcpActive(true);
|
|
387
|
+
}
|
|
388
|
+
startBLE();
|
|
389
|
+
setBleActive(true);
|
|
390
|
+
}, [isRunning, tcpHost, tcpPort, start, startBLE, displayName]);
|
|
391
|
+
|
|
392
|
+
const onStopBle = useCallback(async () => {
|
|
393
|
+
stopBLE();
|
|
394
|
+
setBleActive(false);
|
|
395
|
+
setUnpairedRNodes(0);
|
|
396
|
+
if (isRunning) {
|
|
397
|
+
await stop();
|
|
398
|
+
}
|
|
399
|
+
}, [stopBLE, stop, isRunning]);
|
|
400
|
+
|
|
401
|
+
// Poll for unpaired RNodes while BLE is active
|
|
402
|
+
useEffect(() => {
|
|
403
|
+
if (!bleActive) return;
|
|
404
|
+
const id = setInterval(() => {
|
|
405
|
+
try { setUnpairedRNodes(bleUnpairedRNodeCount()); } catch {}
|
|
406
|
+
}, 2000);
|
|
407
|
+
return () => clearInterval(id);
|
|
408
|
+
}, [bleActive, bleUnpairedRNodeCount]);
|
|
409
|
+
|
|
410
|
+
// Live BLE peer count — poll every second
|
|
411
|
+
useEffect(() => {
|
|
412
|
+
if (!bleActive) { setLiveBleCount(0); return; }
|
|
413
|
+
const tick = () => { try { setLiveBleCount(LxmfModule.blePeerCount()); } catch {} };
|
|
414
|
+
tick();
|
|
415
|
+
const id = setInterval(tick, 1000);
|
|
416
|
+
return () => clearInterval(id);
|
|
417
|
+
}, [bleActive]);
|
|
418
|
+
|
|
419
|
+
const onSend = useCallback(async () => {
|
|
420
|
+
const d = dest.trim().toLowerCase();
|
|
421
|
+
if (!/^[0-9a-f]{32}$/.test(d)) { setSendResult('Dest = 32 hex chars.'); return; }
|
|
422
|
+
const r = await send(d, utf8ToBase64(msgText));
|
|
423
|
+
setSendResult(r >= 0 ? `Receipt #${r}` : 'Send failed.');
|
|
424
|
+
}, [dest, msgText, send]);
|
|
425
|
+
|
|
426
|
+
const copyIdentity = useCallback(() => {
|
|
427
|
+
if (status?.identityHex) copyToClipboard(status.identityHex);
|
|
428
|
+
}, [status?.identityHex]);
|
|
429
|
+
|
|
430
|
+
const copyAddress = useCallback(() => {
|
|
431
|
+
if (status?.addressHex) copyToClipboard(status.addressHex);
|
|
432
|
+
}, [status?.addressHex]);
|
|
433
|
+
|
|
434
|
+
// ── Render ────────────────────────────────────────────────────────────────
|
|
435
|
+
|
|
436
|
+
return (
|
|
437
|
+
<ScrollView contentContainerStyle={S.scroll} contentInsetAdjustmentBehavior="automatic">
|
|
438
|
+
|
|
439
|
+
{/* Header */}
|
|
440
|
+
<View style={S.header}>
|
|
441
|
+
<Text style={S.headerTitle}>LXMF Console</Text>
|
|
442
|
+
<View style={S.headerPills}>
|
|
443
|
+
<Pill label="BLE" active={bleActive} />
|
|
444
|
+
<Pill label="TCP" active={tcpActive} />
|
|
445
|
+
<Pill label={isRunning ? 'Running' : 'Stopped'} active={isRunning} />
|
|
446
|
+
</View>
|
|
447
|
+
</View>
|
|
448
|
+
|
|
449
|
+
{/* Error banner */}
|
|
450
|
+
{error ? (
|
|
451
|
+
<View style={S.errorBanner}>
|
|
452
|
+
<Text style={S.errorBannerText}>{error}</Text>
|
|
453
|
+
</View>
|
|
454
|
+
) : null}
|
|
455
|
+
|
|
456
|
+
{/* ── Node Status ─────────────────────────────────────────────────── */}
|
|
457
|
+
<Accordion title="Node Status" defaultOpen>
|
|
458
|
+
<Row label="Native module" value={isNativeAvailable ? 'Loaded ✓' : 'Missing ✗'} />
|
|
459
|
+
<Row label="State" value={isRunning ? 'Running' : 'Stopped'} />
|
|
460
|
+
<Row
|
|
461
|
+
label="Identity"
|
|
462
|
+
value={status?.identityHex ? shortHex(status.identityHex) : '—'}
|
|
463
|
+
onCopy={status?.identityHex ? copyIdentity : undefined}
|
|
464
|
+
/>
|
|
465
|
+
<Row
|
|
466
|
+
label="Address"
|
|
467
|
+
value={status?.addressHex ? shortHex(status.addressHex) : '—'}
|
|
468
|
+
onCopy={status?.addressHex ? copyAddress : undefined}
|
|
469
|
+
/>
|
|
470
|
+
<Row label="Announces" value={String(status?.announcesReceived ?? 0)} />
|
|
471
|
+
<Row label="Messages" value={String(status?.lxmfMessagesReceived ?? 0)} />
|
|
472
|
+
<Row label="Outbound sent" value={String(status?.outboundSent ?? 0)} />
|
|
473
|
+
<Row label="Inbound accepted" value={String(status?.inboundAccepted ?? 0)} />
|
|
474
|
+
<View style={S.btnRow}>
|
|
475
|
+
<Btn label="Refresh" onPress={getStatus} small />
|
|
476
|
+
</View>
|
|
477
|
+
</Accordion>
|
|
478
|
+
|
|
479
|
+
{/* ── TCP / Reticulum ──────────────────────────────────────────────── */}
|
|
480
|
+
<Accordion title="TCP / Reticulum" defaultOpen>
|
|
481
|
+
<Text style={S.hint}>Connect to rnsd daemon. BLE can run simultaneously.</Text>
|
|
482
|
+
<TextInput
|
|
483
|
+
style={S.input}
|
|
484
|
+
placeholder="Host (e.g. 192.168.1.10)"
|
|
485
|
+
placeholderTextColor="#607080"
|
|
486
|
+
value={tcpHost}
|
|
487
|
+
onChangeText={setTcpHost}
|
|
488
|
+
autoCapitalize="none"
|
|
489
|
+
autoCorrect={false}
|
|
490
|
+
/>
|
|
491
|
+
<TextInput
|
|
492
|
+
style={S.input}
|
|
493
|
+
placeholder="Port (default 4242)"
|
|
494
|
+
placeholderTextColor="#607080"
|
|
495
|
+
value={tcpPort}
|
|
496
|
+
onChangeText={setTcpPort}
|
|
497
|
+
keyboardType="number-pad"
|
|
498
|
+
/>
|
|
499
|
+
<TextInput
|
|
500
|
+
style={S.input}
|
|
501
|
+
placeholder="Display name (e.g. lxmf-mobile)"
|
|
502
|
+
placeholderTextColor="#607080"
|
|
503
|
+
value={displayName}
|
|
504
|
+
onChangeText={setDisplayName}
|
|
505
|
+
autoCapitalize="none"
|
|
506
|
+
autoCorrect={false}
|
|
507
|
+
/>
|
|
508
|
+
{transportMsg ? <Text style={S.warn}>{transportMsg}</Text> : null}
|
|
509
|
+
<View style={S.btnRow}>
|
|
510
|
+
<Btn label="Start TCP" onPress={onStartTcp} disabled={!isNativeAvailable || isRunning || !identityHydrated} />
|
|
511
|
+
<Btn label="Stop TCP" onPress={onStopTcp} disabled={!isRunning} danger />
|
|
512
|
+
</View>
|
|
513
|
+
</Accordion>
|
|
514
|
+
|
|
515
|
+
{/* ── BLE Mesh ─────────────────────────────────────────────────────── */}
|
|
516
|
+
<Accordion title="BLE Mesh" defaultOpen>
|
|
517
|
+
<Text style={S.hint}>Pair RNodes in iOS Settings > Bluetooth first, then start BLE.</Text>
|
|
518
|
+
<Row label="BLE active" value={bleActive ? 'Yes' : 'No'} />
|
|
519
|
+
<Row label="Connected peers" value={String(liveBleCount)} />
|
|
520
|
+
{unpairedRNodes > 0 && (
|
|
521
|
+
<Text style={S.warn}>
|
|
522
|
+
Found {unpairedRNodes} unpaired RNode{unpairedRNodes > 1 ? 's' : ''}. Open Settings > Bluetooth, pair the device, then restart BLE.
|
|
523
|
+
</Text>
|
|
524
|
+
)}
|
|
525
|
+
<View style={S.btnRow}>
|
|
526
|
+
<Btn label="Start BLE" onPress={onStartBle} disabled={bleActive || !identityHydrated} />
|
|
527
|
+
<Btn label="Stop BLE" onPress={onStopBle} disabled={!bleActive} danger />
|
|
528
|
+
</View>
|
|
529
|
+
</Accordion>
|
|
530
|
+
|
|
531
|
+
{/* ── BLE Peers ────────────────────────────────────────────────────── */}
|
|
532
|
+
<Accordion title="BLE Peers" badge={liveBleCount} defaultOpen>
|
|
533
|
+
<Text style={S.hint}>
|
|
534
|
+
Live BLE connections: {liveBleCount}. LXMF identity hashes appear after peer announces.
|
|
535
|
+
</Text>
|
|
536
|
+
{knownPeerHashes.length === 0 ? (
|
|
537
|
+
<Text style={S.muted}>No peer announces received yet.</Text>
|
|
538
|
+
) : (
|
|
539
|
+
knownPeerHashes.map((p) => (
|
|
540
|
+
<View key={p.hash} style={S.itemCard}>
|
|
541
|
+
{p.name ? <Text style={S.itemTitle}>{p.name}</Text> : null}
|
|
542
|
+
<Text selectable style={S.itemBody}>{p.hash}</Text>
|
|
543
|
+
<Text style={S.itemMeta}>last seen: {p.lastSeen}</Text>
|
|
544
|
+
<View style={S.announceActions}>
|
|
545
|
+
<Pressable style={S.copyBtn} onPress={() => copyToClipboard(p.hash)}>
|
|
546
|
+
<Text style={S.copyBtnText}>⎘</Text>
|
|
547
|
+
</Pressable>
|
|
548
|
+
<Pressable style={S.sendToBtn} onPress={() => { setDest(p.hash); setSendResult(''); }}>
|
|
549
|
+
<Text style={S.sendToBtnText}>→ Send</Text>
|
|
550
|
+
</Pressable>
|
|
551
|
+
</View>
|
|
552
|
+
</View>
|
|
553
|
+
))
|
|
554
|
+
)}
|
|
555
|
+
</Accordion>
|
|
556
|
+
|
|
557
|
+
{/* ── Announces ────────────────────────────────────────────────────── */}
|
|
558
|
+
<Accordion title="Announces" badge={counts.announces} defaultOpen>
|
|
559
|
+
{announceEvts.length === 0 ? (
|
|
560
|
+
<Text style={S.muted}>No announces yet.</Text>
|
|
561
|
+
) : (
|
|
562
|
+
announceEvts.map((e: LxmfEvent, i: number) => {
|
|
563
|
+
const hash = String(e.destHash ?? e.address ?? '');
|
|
564
|
+
const name = e.appData ? String(e.appData) : '';
|
|
565
|
+
return (
|
|
566
|
+
<View key={`${evtKey(e, 'ann-')}-${i}`} style={S.itemCard}>
|
|
567
|
+
<View style={S.announceHeader}>
|
|
568
|
+
<View style={S.announceInfo}>
|
|
569
|
+
{name ? <Text style={S.itemTitle}>{name}</Text> : null}
|
|
570
|
+
<Text selectable style={S.itemBody}>{shortHex(hash)}</Text>
|
|
571
|
+
<Text style={S.itemMeta}>{fmtTime(e)}{e.hops !== undefined ? ` · ${e.hops} hop` : ''}</Text>
|
|
572
|
+
</View>
|
|
573
|
+
<View style={S.announceActions}>
|
|
574
|
+
<Pressable
|
|
575
|
+
style={S.copyBtn}
|
|
576
|
+
onPress={() => copyToClipboard(hash)}>
|
|
577
|
+
<Text style={S.copyBtnText}>⎘</Text>
|
|
578
|
+
</Pressable>
|
|
579
|
+
<Pressable
|
|
580
|
+
style={S.sendToBtn}
|
|
581
|
+
onPress={() => { setDest(hash); setSendResult(''); }}>
|
|
582
|
+
<Text style={S.sendToBtnText}>→ Send</Text>
|
|
583
|
+
</Pressable>
|
|
584
|
+
</View>
|
|
585
|
+
</View>
|
|
586
|
+
</View>
|
|
587
|
+
);
|
|
588
|
+
})
|
|
589
|
+
)}
|
|
590
|
+
</Accordion>
|
|
591
|
+
|
|
592
|
+
{/* ── Send Message ─────────────────────────────────────────────────── */}
|
|
593
|
+
<Accordion title="Send Message" defaultOpen>
|
|
594
|
+
{dest ? (
|
|
595
|
+
<Text style={S.destFilled}>→ {shortHex(dest)}</Text>
|
|
596
|
+
) : (
|
|
597
|
+
<Text style={S.hint}>{'Tap "→ Send" on an announce above to fill the destination.'}</Text>
|
|
598
|
+
)}
|
|
599
|
+
<TextInput
|
|
600
|
+
style={S.input}
|
|
601
|
+
placeholder="Destination (32 hex chars)"
|
|
602
|
+
placeholderTextColor="#607080"
|
|
603
|
+
value={dest}
|
|
604
|
+
onChangeText={setDest}
|
|
605
|
+
autoCapitalize="none"
|
|
606
|
+
autoCorrect={false}
|
|
607
|
+
/>
|
|
608
|
+
<TextInput
|
|
609
|
+
style={S.input}
|
|
610
|
+
placeholder="Message text"
|
|
611
|
+
placeholderTextColor="#607080"
|
|
612
|
+
value={msgText}
|
|
613
|
+
onChangeText={setMsgText}
|
|
614
|
+
/>
|
|
615
|
+
<View style={S.btnRow}>
|
|
616
|
+
<Btn label="Send" onPress={onSend} disabled={!isRunning} />
|
|
617
|
+
</View>
|
|
618
|
+
{sendResult ? <Text style={S.feedback}>{sendResult}</Text> : null}
|
|
619
|
+
</Accordion>
|
|
620
|
+
|
|
621
|
+
{/* ── Messages ─────────────────────────────────────────────────────── */}
|
|
622
|
+
<Accordion title="Messages" badge={counts.messages} defaultOpen>
|
|
623
|
+
{msgEvts.length === 0 ? (
|
|
624
|
+
<Text style={S.muted}>No messages yet.</Text>
|
|
625
|
+
) : (
|
|
626
|
+
msgEvts.map((e, i) => {
|
|
627
|
+
const body = decodeLxmfContent(e.content);
|
|
628
|
+
const sender = String(e.source ?? '');
|
|
629
|
+
return (
|
|
630
|
+
<View key={`${evtKey(e, 'msg-')}-${i}`} style={S.itemCard}>
|
|
631
|
+
<View style={S.announceHeader}>
|
|
632
|
+
<View style={S.announceInfo}>
|
|
633
|
+
<Text selectable style={S.itemTitle}>
|
|
634
|
+
From: {shortHex(sender)}
|
|
635
|
+
</Text>
|
|
636
|
+
{body ? <Text selectable style={S.itemBody}>{body}</Text> : null}
|
|
637
|
+
<Text style={S.itemMeta}>{fmtTime(e)}</Text>
|
|
638
|
+
</View>
|
|
639
|
+
{sender ? (
|
|
640
|
+
<View style={S.announceActions}>
|
|
641
|
+
<Pressable
|
|
642
|
+
style={S.copyBtn}
|
|
643
|
+
onPress={() => copyToClipboard(sender)}>
|
|
644
|
+
<Text style={S.copyBtnText}>⎘</Text>
|
|
645
|
+
</Pressable>
|
|
646
|
+
<Pressable
|
|
647
|
+
style={S.sendToBtn}
|
|
648
|
+
onPress={() => { setDest(sender); setSendResult(''); }}>
|
|
649
|
+
<Text style={S.sendToBtnText}>↩ Reply</Text>
|
|
650
|
+
</Pressable>
|
|
651
|
+
</View>
|
|
652
|
+
) : null}
|
|
653
|
+
</View>
|
|
654
|
+
</View>
|
|
655
|
+
);
|
|
656
|
+
})
|
|
657
|
+
)}
|
|
658
|
+
</Accordion>
|
|
659
|
+
|
|
660
|
+
{/* ── Event Log ────────────────────────────────────────────────────── */}
|
|
661
|
+
<Accordion title="Event Log" badge={allEvts.length} defaultOpen={false}>
|
|
662
|
+
{allEvts.length === 0 ? (
|
|
663
|
+
<Text style={S.muted}>No events yet.</Text>
|
|
664
|
+
) : (
|
|
665
|
+
allEvts.map((e, i) => (
|
|
666
|
+
<View key={`${evtKey(e, 'el-')}-${i}`} style={S.logRow}>
|
|
667
|
+
<Text style={S.logTag}>{e.type}</Text>
|
|
668
|
+
<Text selectable style={S.logText} numberOfLines={2}>{evtSummary(e)}</Text>
|
|
669
|
+
<Text style={S.logTime}>{fmtTime(e)}</Text>
|
|
670
|
+
</View>
|
|
671
|
+
))
|
|
672
|
+
)}
|
|
673
|
+
</Accordion>
|
|
674
|
+
|
|
675
|
+
{/* ── Debug Logs ───────────────────────────────────────────────────── */}
|
|
676
|
+
<Accordion title="Debug Logs" badge={counts.logs} defaultOpen>
|
|
677
|
+
{logEvts.length === 0 ? (
|
|
678
|
+
<Text style={S.muted}>No logs yet.</Text>
|
|
679
|
+
) : (
|
|
680
|
+
logEvts.map((e, i) => (
|
|
681
|
+
<View key={`${evtKey(e, 'lg-')}-${i}`} style={S.logRow}>
|
|
682
|
+
<Text style={S.logTime}>{fmtTime(e)}</Text>
|
|
683
|
+
<Text selectable style={S.logLine}>{String(e.message ?? e.msg ?? evtSummary(e))}</Text>
|
|
684
|
+
</View>
|
|
685
|
+
))
|
|
686
|
+
)}
|
|
687
|
+
</Accordion>
|
|
688
|
+
|
|
689
|
+
</ScrollView>
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// ── Styles ───────────────────────────────────────────────────────────────────
|
|
694
|
+
|
|
695
|
+
const C = {
|
|
696
|
+
bg: '#0c1218',
|
|
697
|
+
surface: '#131d26',
|
|
698
|
+
border: '#1e3040',
|
|
699
|
+
accent: '#1a7fc1',
|
|
700
|
+
accentBright: '#4fb3e8',
|
|
701
|
+
danger: '#c0392b',
|
|
702
|
+
text: '#d8ecf8',
|
|
703
|
+
textDim: '#7a9db5',
|
|
704
|
+
textMono: '#a8c8dc',
|
|
705
|
+
green: '#2ecc71',
|
|
706
|
+
warn: '#f0a500',
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
const S = StyleSheet.create({
|
|
710
|
+
scroll: {
|
|
711
|
+
paddingHorizontal: 14,
|
|
712
|
+
paddingTop: 14,
|
|
713
|
+
paddingBottom: 60,
|
|
714
|
+
gap: 10,
|
|
715
|
+
backgroundColor: C.bg,
|
|
716
|
+
},
|
|
717
|
+
|
|
718
|
+
// Header
|
|
719
|
+
header: {
|
|
720
|
+
backgroundColor: C.surface,
|
|
721
|
+
borderRadius: 14,
|
|
722
|
+
borderWidth: 1,
|
|
723
|
+
borderColor: C.border,
|
|
724
|
+
padding: 14,
|
|
725
|
+
gap: 10,
|
|
726
|
+
},
|
|
727
|
+
headerTitle: { color: C.text, fontSize: 26, fontWeight: '700' },
|
|
728
|
+
headerPills: { flexDirection: 'row', gap: 8 },
|
|
729
|
+
|
|
730
|
+
pill: {
|
|
731
|
+
borderRadius: 20,
|
|
732
|
+
borderWidth: 1,
|
|
733
|
+
borderColor: C.border,
|
|
734
|
+
paddingHorizontal: 10,
|
|
735
|
+
paddingVertical: 4,
|
|
736
|
+
backgroundColor: '#0e1923',
|
|
737
|
+
},
|
|
738
|
+
pillActive: { borderColor: C.accentBright, backgroundColor: '#0d3550' },
|
|
739
|
+
pillText: { color: C.textDim, fontSize: 12, fontWeight: '600' },
|
|
740
|
+
pillTextActive: { color: C.accentBright },
|
|
741
|
+
|
|
742
|
+
errorBanner: {
|
|
743
|
+
backgroundColor: '#3a1515',
|
|
744
|
+
borderRadius: 10,
|
|
745
|
+
borderWidth: 1,
|
|
746
|
+
borderColor: '#7a2020',
|
|
747
|
+
padding: 10,
|
|
748
|
+
},
|
|
749
|
+
errorBannerText: { color: '#ff9a9a', fontSize: 13 },
|
|
750
|
+
|
|
751
|
+
// Accordion
|
|
752
|
+
accordion: {
|
|
753
|
+
backgroundColor: C.surface,
|
|
754
|
+
borderRadius: 14,
|
|
755
|
+
borderWidth: 1,
|
|
756
|
+
borderColor: C.border,
|
|
757
|
+
overflow: 'hidden',
|
|
758
|
+
},
|
|
759
|
+
accordionHeader: {
|
|
760
|
+
flexDirection: 'row',
|
|
761
|
+
alignItems: 'center',
|
|
762
|
+
paddingHorizontal: 14,
|
|
763
|
+
paddingVertical: 13,
|
|
764
|
+
gap: 8,
|
|
765
|
+
},
|
|
766
|
+
accordionHeaderPressed: { backgroundColor: '#17232e' },
|
|
767
|
+
accordionChevron: { color: C.textDim, fontSize: 14, width: 14 },
|
|
768
|
+
accordionTitle: { color: C.text, fontSize: 16, fontWeight: '600', flex: 1 },
|
|
769
|
+
accordionBadge: {
|
|
770
|
+
backgroundColor: '#0d3550',
|
|
771
|
+
borderRadius: 10,
|
|
772
|
+
paddingHorizontal: 7,
|
|
773
|
+
paddingVertical: 2,
|
|
774
|
+
minWidth: 24,
|
|
775
|
+
alignItems: 'center',
|
|
776
|
+
},
|
|
777
|
+
accordionBadgeText: { color: C.accentBright, fontSize: 11, fontWeight: '700' },
|
|
778
|
+
accordionBody: {
|
|
779
|
+
paddingHorizontal: 14,
|
|
780
|
+
paddingBottom: 14,
|
|
781
|
+
gap: 8,
|
|
782
|
+
borderTopWidth: 1,
|
|
783
|
+
borderTopColor: C.border,
|
|
784
|
+
},
|
|
785
|
+
|
|
786
|
+
// Stat rows
|
|
787
|
+
statRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
|
|
788
|
+
statLabel: { color: C.textDim, fontSize: 13 },
|
|
789
|
+
statValueRow: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
|
790
|
+
statValue: { color: C.text, fontSize: 13, fontFamily: 'monospace' },
|
|
791
|
+
copyBtn: {
|
|
792
|
+
paddingHorizontal: 6,
|
|
793
|
+
paddingVertical: 2,
|
|
794
|
+
borderRadius: 6,
|
|
795
|
+
backgroundColor: '#0d3550',
|
|
796
|
+
borderWidth: 1,
|
|
797
|
+
borderColor: C.border,
|
|
798
|
+
},
|
|
799
|
+
copyBtnText: { color: C.accentBright, fontSize: 13 },
|
|
800
|
+
|
|
801
|
+
hint: { color: C.textDim, fontSize: 12, marginBottom: 2 },
|
|
802
|
+
|
|
803
|
+
// Input
|
|
804
|
+
input: {
|
|
805
|
+
borderWidth: 1,
|
|
806
|
+
borderColor: '#2a4050',
|
|
807
|
+
backgroundColor: '#0b1820',
|
|
808
|
+
color: C.text,
|
|
809
|
+
borderRadius: 10,
|
|
810
|
+
paddingHorizontal: 10,
|
|
811
|
+
paddingVertical: 10,
|
|
812
|
+
fontFamily: 'monospace',
|
|
813
|
+
fontSize: 13,
|
|
814
|
+
},
|
|
815
|
+
|
|
816
|
+
warn: { color: C.warn, fontSize: 12, fontFamily: 'monospace' },
|
|
817
|
+
feedback: { color: C.green, fontSize: 13, fontFamily: 'monospace' },
|
|
818
|
+
muted: { color: C.textDim, fontSize: 13 },
|
|
819
|
+
|
|
820
|
+
// Buttons
|
|
821
|
+
btnRow: { flexDirection: 'row', gap: 8, marginTop: 2 },
|
|
822
|
+
btn: {
|
|
823
|
+
flex: 1,
|
|
824
|
+
borderRadius: 10,
|
|
825
|
+
paddingVertical: 10,
|
|
826
|
+
alignItems: 'center',
|
|
827
|
+
backgroundColor: C.accent,
|
|
828
|
+
},
|
|
829
|
+
btnSmall: { paddingVertical: 7, flex: 0, paddingHorizontal: 16 },
|
|
830
|
+
btnDanger: { backgroundColor: C.danger },
|
|
831
|
+
btnDisabled: { opacity: 0.4 },
|
|
832
|
+
btnPressed: { opacity: 0.78 },
|
|
833
|
+
btnText: { color: '#e8f6ff', fontSize: 14, fontWeight: '600' },
|
|
834
|
+
btnTextSmall: { fontSize: 12 },
|
|
835
|
+
|
|
836
|
+
// Item cards (announces, messages, beacons)
|
|
837
|
+
itemCard: {
|
|
838
|
+
borderWidth: 1,
|
|
839
|
+
borderColor: '#1f3348',
|
|
840
|
+
borderRadius: 10,
|
|
841
|
+
padding: 10,
|
|
842
|
+
backgroundColor: '#0e1e2b',
|
|
843
|
+
gap: 3,
|
|
844
|
+
},
|
|
845
|
+
itemTitle: { color: C.text, fontSize: 13, fontWeight: '600' },
|
|
846
|
+
itemBody: { color: C.textMono, fontSize: 13, fontFamily: 'monospace' },
|
|
847
|
+
itemMeta: { color: C.textDim, fontSize: 11, fontFamily: 'monospace' },
|
|
848
|
+
|
|
849
|
+
// Log rows
|
|
850
|
+
logRow: { flexDirection: 'row', alignItems: 'flex-start', gap: 6 },
|
|
851
|
+
logTag: { color: C.accentBright, fontFamily: 'monospace', fontSize: 10, width: 100 },
|
|
852
|
+
logText: { color: C.textMono, flex: 1, fontSize: 11, fontFamily: 'monospace' },
|
|
853
|
+
logTime: { color: C.textDim, fontFamily: 'monospace', fontSize: 10 },
|
|
854
|
+
logLine: { color: C.textMono, fontSize: 11, fontFamily: 'monospace' },
|
|
855
|
+
|
|
856
|
+
// Announce card layout
|
|
857
|
+
announceHeader: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
|
858
|
+
announceInfo: { flex: 1, gap: 2 },
|
|
859
|
+
announceActions: { flexDirection: 'row', gap: 6, alignItems: 'center' },
|
|
860
|
+
|
|
861
|
+
// Send-to button on announce cards
|
|
862
|
+
sendToBtn: {
|
|
863
|
+
paddingHorizontal: 8,
|
|
864
|
+
paddingVertical: 4,
|
|
865
|
+
borderRadius: 6,
|
|
866
|
+
backgroundColor: '#0d3550',
|
|
867
|
+
borderWidth: 1,
|
|
868
|
+
borderColor: C.accentBright,
|
|
869
|
+
},
|
|
870
|
+
sendToBtnText: { color: C.accentBright, fontSize: 12, fontWeight: '600' },
|
|
871
|
+
|
|
872
|
+
// Destination pre-filled indicator
|
|
873
|
+
destFilled: { color: C.accentBright, fontSize: 12, fontFamily: 'monospace' },
|
|
874
|
+
});
|