Base del merge a tres bandas: git 0.9.5 de epsylon, esta rama y el src de la
APK oficial 1.3.1 (la variante movil: OASIS_MOBILE, regex sin \p{}, audio y
video de Android). Corre en el mismo nodejs-mobile 18.20.4 que ya usabamos.
Se conserva de la rama: Karvan, identidades y multicuenta, topbar y barra
inferior, borrado de EXIF sin sharp, filtro hot del foro, RTC sin STUN y los
modulos apagados en movil (wallet, banking, torrents).
Se toma de upstream lo que ya hace de serie: respuesta citada en chats, ssb-lan
en movil, configuracion en ~/.ssb/oasis (sobrevive a reinstalar el backend).
El asistente de bienvenida deja elegir workflow tambien en movil.
325 lines
13 KiB
JavaScript
325 lines
13 KiB
JavaScript
const fs = require('fs');
|
|
const pull = require('../server/node_modules/pull-stream');
|
|
const sharedState = require('../configs/shared-state');
|
|
const { notify: desktopNotify, i18nNow, displayName } = require('../backend/desktopNotify');
|
|
const { statePath } = require('../configs/state-manager');
|
|
|
|
const HISTORY_MAX = 200;
|
|
const HEARD_MAX = 1000;
|
|
const LATEST_MAX = 5;
|
|
const PUBS_TTL_MS = 5 * 60 * 1000;
|
|
const RECOUNT_DELAY_MS = 500;
|
|
const FEED_ID = /^@[A-Za-z0-9+/]{43}=\.ed25519$/;
|
|
|
|
const readJson = (p, fallback) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (_) { return fallback; } };
|
|
const writeJson = (p, data) => { try { fs.writeFileSync(p, JSON.stringify(data, null, 2)); } catch (_) {} };
|
|
const asP = (fn, ...args) => new Promise((resolve, reject) => fn(...args, (err, v) => err ? reject(err) : resolve(v)));
|
|
const rpcValue = (fn, ...args) => new Promise((resolve) => {
|
|
try {
|
|
const r = fn(...args, (err, v) => resolve(err ? null : v));
|
|
if (r && typeof r.then === 'function') r.then(resolve, () => resolve(null));
|
|
else if (r !== undefined) resolve(r);
|
|
} catch (_) { resolve(null); }
|
|
});
|
|
|
|
module.exports = ({ cooler, pmModel, nameOf, isPublic, encryptFile, isAvailable, prefetch }) => {
|
|
const historyFile = statePath('phone-history.json');
|
|
const seenFile = statePath('phone-seen.json');
|
|
let ssb = null;
|
|
let subscribed = false;
|
|
let pubsAt = 0;
|
|
let pubsTimer = null;
|
|
|
|
const open = async () => { if (!ssb) ssb = await cooler.open(); return ssb; };
|
|
const phone = async () => { const s = await open(); return s && s.phone ? s.phone : null; };
|
|
const history = () => { const h = readJson(historyFile, []); return Array.isArray(h) ? h : []; };
|
|
const seen = () => {
|
|
const s = readJson(seenFile, {});
|
|
return { missedSeenAt: Number(s.missedSeenAt) || 0, heard: Array.isArray(s.heard) ? s.heard : [] };
|
|
};
|
|
|
|
const refreshCount = async () => {
|
|
let n = 0;
|
|
const st = sharedState.getPhoneCall ? sharedState.getPhoneCall() : null;
|
|
if (st && st.dir === 'in' && st.phase === 'incoming') n += 1;
|
|
const sn = seen();
|
|
n += history().filter(h => h && h.outcome === 'missed' && h.endedAt > sn.missedSeenAt).length;
|
|
try {
|
|
const me = (await open()).id;
|
|
const heard = new Set(sn.heard);
|
|
n += (await pmModel.listPams()).filter(m => m.value.author !== me && !heard.has(m.key)).length;
|
|
} catch (_) {}
|
|
sharedState.setPhoneCount(n);
|
|
return n;
|
|
};
|
|
|
|
const notify = async (peer, callId) => {
|
|
const i18n = i18nNow();
|
|
let name = '';
|
|
try { name = await nameOf(peer); } catch (_) {}
|
|
desktopNotify(i18n.phoneTitle, `${displayName(peer, name)} ${i18n.phoneIsCalling}`, callId ? `call:${callId}` : null, {
|
|
timeoutMs: require('../server/phone_module').RING_MS,
|
|
actions: [{ key: 'answer', label: i18n.phoneAnswer }, { key: 'reject', label: i18n.phoneReject }],
|
|
onAction: async (chosen) => {
|
|
try {
|
|
const ph = await phone();
|
|
const st = ph ? await rpcValue(ph.state) : null;
|
|
if (!st || st.id !== callId || st.phase !== 'incoming') return;
|
|
await act(chosen === 'answer' ? 'accept' : 'reject');
|
|
} catch (_) {}
|
|
}
|
|
});
|
|
};
|
|
|
|
const ROOM_EVENT_TEXT = { join: 'roomEventJoined', leave: 'roomEventLeft', recStart: 'roomEventRecStart', recStop: 'roomEventRecStop', mute: 'roomEventMuted', unmute: 'roomEventUnmuted', hand: 'roomEventHand' };
|
|
const roomNotices = { rid: null, since: 0, seen: new Set() };
|
|
const announceRoom = async (room) => {
|
|
if (!room || !room.rid) { roomNotices.rid = null; roomNotices.seen = new Set(); return; }
|
|
if (roomNotices.rid !== room.rid) { roomNotices.rid = room.rid; roomNotices.since = Number(room.joinedAt) || Date.now(); roomNotices.seen = new Set(); }
|
|
if (!room.notify) { roomNotices.since = Date.now() + 1; return; }
|
|
const events = Array.isArray(room.events) ? room.events : [];
|
|
const keyOf = (e) => `${e.ts}:${e.t}:${e.id}`;
|
|
const fresh = events.filter(e => e && ROOM_EVENT_TEXT[e.t] && !roomNotices.seen.has(keyOf(e)) && Number(e.ts) >= roomNotices.since);
|
|
roomNotices.seen = new Set(events.filter(Boolean).map(keyOf));
|
|
if (!fresh.length) return;
|
|
let me = null;
|
|
try { me = (await open()).id; } catch (_) {}
|
|
const i18n = i18nNow();
|
|
for (const e of fresh) {
|
|
if (e.id === me) continue;
|
|
let name = '';
|
|
try { name = await nameOf(e.id); } catch (_) {}
|
|
desktopNotify(i18n.roomsTitle, String(i18n[ROOM_EVENT_TEXT[e.t]] || '{name}').replace('{name}', displayName(e.id, name)));
|
|
}
|
|
};
|
|
|
|
const subscribe = async () => {
|
|
if (subscribed) return;
|
|
const ph = await phone();
|
|
if (!ph || subscribed) return;
|
|
subscribed = true;
|
|
pull(ph.events(), pull.drain((ev) => {
|
|
if (!ev) return;
|
|
if (ev.type === 'state') {
|
|
sharedState.setPhoneCall(ev.state || null);
|
|
refreshCount();
|
|
} else if (ev.type === 'room') {
|
|
sharedState.setPhoneRoom(ev.room || null);
|
|
announceRoom(ev.room).catch(() => {});
|
|
} else if (ev.type === 'recordLimit') {
|
|
finishPam().catch(() => {});
|
|
} else if (ev.type === 'incoming') {
|
|
notify(ev.peer, ev.id);
|
|
} else if (ev.type === 'ended' && ev.call) {
|
|
const h = history();
|
|
h.unshift(ev.call);
|
|
writeJson(historyFile, h.slice(0, HISTORY_MAX));
|
|
refreshCount();
|
|
}
|
|
}, () => {
|
|
subscribed = false;
|
|
const retry = setTimeout(() => { subscribe().catch(() => {}); }, 5000);
|
|
if (retry.unref) retry.unref();
|
|
}));
|
|
try { sharedState.setPhoneCall((await rpcValue(ph.state)) || null); } catch (_) {}
|
|
try { if (typeof ph.roomState === 'function') sharedState.setPhoneRoom((await rpcValue(ph.roomState)) || null); } catch (_) {}
|
|
};
|
|
|
|
let arrivalsWatched = false;
|
|
let recountTimer = null;
|
|
const scheduleRecount = () => {
|
|
if (recountTimer) return;
|
|
recountTimer = setTimeout(() => { recountTimer = null; refreshCount().catch(() => {}); }, RECOUNT_DELAY_MS);
|
|
if (recountTimer.unref) recountTimer.unref();
|
|
};
|
|
const watchArrivals = async () => {
|
|
if (arrivalsWatched) return;
|
|
const s = await open();
|
|
if (!s || !s.db || typeof s.db.onMsgAdded !== 'function') return;
|
|
arrivalsWatched = true;
|
|
s.db.onMsgAdded((ev) => {
|
|
const v = ev && ev.kvt && ev.kvt.value;
|
|
if (!v || v.author === s.id || typeof v.content !== 'string') return;
|
|
scheduleRecount();
|
|
}, false);
|
|
};
|
|
|
|
const act = async (method, ...args) => {
|
|
await subscribe();
|
|
const ph = await phone();
|
|
if (!ph) throw new Error('unavailable');
|
|
return asP(ph[method], ...args);
|
|
};
|
|
|
|
const finishPam = async () => {
|
|
const ph = await phone();
|
|
if (!ph) return false;
|
|
const out = await asP(ph.recordStop);
|
|
if (!out || !out.file) return false;
|
|
try {
|
|
const enc = await encryptFile(out.file);
|
|
await pmModel.sendPam(out.peer, enc.cipher, Number(out.durationMs) / 1000);
|
|
return true;
|
|
} finally {
|
|
fs.unlink(out.file, () => {});
|
|
}
|
|
};
|
|
|
|
const findPam = async (key) => (await pmModel.listPams()).find(m => m.key === key) || null;
|
|
|
|
const byType = (s, type) => new Promise((resolve) => {
|
|
if (!s || typeof s.messagesByType !== 'function') return resolve([]);
|
|
try { pull(s.messagesByType({ type }), pull.collect((err, msgs) => resolve(err ? [] : msgs))); } catch (_) { resolve([]); }
|
|
});
|
|
const defaultPubId = () => {
|
|
const m = String((require('../configs/config-manager').defaultInvite() || {}).code || '').match(/(@[A-Za-z0-9+/]{43}=\.ed25519)/);
|
|
return m ? m[1] : '';
|
|
};
|
|
let announcedPubs = new Set();
|
|
const refreshPubs = async (force = false) => {
|
|
const ids = new Set();
|
|
const add = (id) => { if (FEED_ID.test(String(id || ''))) ids.add(String(id)); };
|
|
try {
|
|
const s = await open();
|
|
if (force || Date.now() - pubsAt >= PUBS_TTL_MS) {
|
|
pubsAt = Date.now();
|
|
const found = new Set();
|
|
for (const m of await byType(s, 'pub')) { const v = m && m.value; const k = v && v.content && v.content.address && v.content.address.key; if (FEED_ID.test(String(k || '')) && (v.author === k || v.author === s.id)) found.add(String(k)); }
|
|
for (const m of await byType(s, 'pubAvailability')) { const k = m && m.value && m.value.author; if (FEED_ID.test(String(k || ''))) found.add(String(k)); }
|
|
announcedPubs = found;
|
|
}
|
|
add(defaultPubId());
|
|
for (const id of announcedPubs) add(id);
|
|
try { const ph = s.phone; if (ph && typeof ph.pubs === 'function') for (const id of (await rpcValue(ph.pubs)) || []) add(id); } catch (_) {}
|
|
ids.delete(s.id);
|
|
} catch (_) { add(defaultPubId()); }
|
|
sharedState.setPubIds(ids);
|
|
return ids;
|
|
};
|
|
const isPub = (id) => sharedState.isPubId(id);
|
|
|
|
return {
|
|
async start() {
|
|
await subscribe();
|
|
await watchArrivals().catch(() => {});
|
|
await refreshCount();
|
|
await refreshPubs(true);
|
|
if (!pubsTimer) { pubsTimer = setInterval(() => { refreshPubs(true).catch(() => {}); }, PUBS_TTL_MS); if (pubsTimer.unref) pubsTimer.unref(); }
|
|
},
|
|
refreshCount,
|
|
refreshPubs,
|
|
isPub,
|
|
async available() {
|
|
if (isPublic) return false;
|
|
const ph = await phone();
|
|
return !!(ph && await rpcValue(ph.available));
|
|
},
|
|
async state() {
|
|
await subscribe();
|
|
const ph = await phone();
|
|
return ph ? rpcValue(ph.state) : null;
|
|
},
|
|
async call(to, opts = {}) {
|
|
await refreshPubs();
|
|
const list = Array.isArray(to) ? to : [to];
|
|
if (list.some(isPub)) throw new Error('pub');
|
|
return act('call', list.length === 1 ? list[0] : list, { legacy: Array.isArray(opts.legacy) ? opts.legacy : [] });
|
|
},
|
|
accept: () => act('accept'),
|
|
reject: () => act('reject'),
|
|
hangup: () => act('end'),
|
|
mute: (flag) => act('mute', !!flag),
|
|
camera: (flag) => act('camera', !!flag),
|
|
async videoFrames(peer) {
|
|
const ph = await phone();
|
|
return ph && typeof ph.videoFrames === 'function' ? ph.videoFrames({ peer: String(peer || '') }) : null;
|
|
},
|
|
silence: (id, flag) => act('silence', { id, on: !!flag }),
|
|
dismiss: () => act('dismiss'),
|
|
recordCancel: () => act('recordCancel'),
|
|
async sendPam() { await subscribe(); return finishPam(); },
|
|
history,
|
|
async markAllHeard(keys) {
|
|
const s = seen();
|
|
const set = new Set(s.heard);
|
|
for (const k of keys || []) if (typeof k === 'string' && k) set.add(k);
|
|
s.heard = [...set].slice(-HEARD_MAX);
|
|
writeJson(seenFile, s);
|
|
refreshCount();
|
|
},
|
|
deleteHistory(ids) {
|
|
const drop = new Set((ids || []).map(String));
|
|
if (!drop.size) return;
|
|
writeJson(historyFile, history().filter(h => !drop.has(String(h && h.id))));
|
|
},
|
|
latestCalls(items) {
|
|
const me = ssb && ssb.id;
|
|
const out = [];
|
|
const seenPeers = new Set();
|
|
for (const h of items || history()) {
|
|
for (const peer of (Array.isArray(h.peers) && h.peers.length ? h.peers : [h.peer])) {
|
|
if (!peer || peer === me || seenPeers.has(peer) || isPub(peer)) continue;
|
|
seenPeers.add(peer);
|
|
out.push({ id: peer, dir: h.dir, outcome: h.outcome, startedAt: h.startedAt });
|
|
if (out.length >= LATEST_MAX) return out;
|
|
}
|
|
}
|
|
return out;
|
|
},
|
|
async pams() {
|
|
const me = (await open()).id;
|
|
const heard = new Set(seen().heard);
|
|
const validShare = (s) => !!s && typeof s === 'object' && typeof s.key === 'string' && typeof s.manifestBlobId === 'string';
|
|
const list = (await pmModel.listPams())
|
|
.filter(m => m.value.author !== me && validShare(m.value.content && m.value.content.share))
|
|
.map(m => ({ key: m.key, from: m.value.author, sentAt: m.value.content.sentAt || new Date(m.timestamp || 0).toISOString(), durationSec: Number(m.value.content.durationSec) || 0, heard: heard.has(m.key), share: m.value.content.share, ready: true }))
|
|
.sort((a, b) => String(b.sentAt).localeCompare(String(a.sentAt)));
|
|
if (typeof isAvailable === 'function') {
|
|
for (const item of list) {
|
|
item.ready = await isAvailable(item.share).catch(() => false);
|
|
if (!item.ready && typeof prefetch === 'function') prefetch(item.share).catch(() => {});
|
|
}
|
|
}
|
|
return list.map(({ share, ...rest }) => rest);
|
|
},
|
|
async pamCipher(key) {
|
|
const m = await findPam(key);
|
|
const share = m && m.value && m.value.content && m.value.content.share;
|
|
if (!share || typeof share.key !== 'string' || typeof share.manifestBlobId !== 'string') return null;
|
|
return share;
|
|
},
|
|
markHeard(key) {
|
|
const s = seen();
|
|
if (s.heard.includes(key)) return false;
|
|
s.heard.push(key);
|
|
s.heard = s.heard.slice(-HEARD_MAX);
|
|
writeJson(seenFile, s);
|
|
refreshCount();
|
|
return true;
|
|
},
|
|
async deletePam(key) {
|
|
const m = await findPam(key);
|
|
if (!m) return null;
|
|
return pmModel.deleteMessageById(key);
|
|
},
|
|
async knownFeeds() {
|
|
const s = await open();
|
|
await refreshPubs();
|
|
const hops = await new Promise((resolve) => {
|
|
try { s.friends.hops({ start: s.id, max: 2 }, (err, h) => resolve(err ? {} : (h || {}))); } catch (_) { resolve({}); }
|
|
});
|
|
const out = new Map();
|
|
for (const [id, hop] of Object.entries(hops)) {
|
|
if (id === s.id || isPub(id) || !(Number(hop) >= 0)) continue;
|
|
out.set(id, Number(hop));
|
|
}
|
|
return out;
|
|
},
|
|
markMissedSeen() {
|
|
const s = seen();
|
|
s.missedSeenAt = Date.now();
|
|
writeJson(seenFile, s);
|
|
refreshCount();
|
|
}
|
|
};
|
|
};
|