feat(call): improve call handling by adding remote destination hash tracking, improving initiation status checks, and refining ringtone management in the frontend

This commit is contained in:
2026-01-04 17:16:23 -06:00
parent 5ef41b84d5
commit d836e7a2e8
13 changed files with 228 additions and 193 deletions

View File

@@ -1650,6 +1650,13 @@ class ReticulumMeshChat:
ctx = context or self.current_context ctx = context or self.current_context
if not ctx: if not ctx:
return return
if ctx.telephone_manager and ctx.telephone_manager.initiation_status:
print(
"on_incoming_telephone_call: Ignoring as we are currently initiating an outgoing call."
)
return
caller_hash = caller_identity.hash.hex() caller_hash = caller_identity.hash.hex()
# Check if caller is blocked # Check if caller is blocked
@@ -3872,6 +3879,9 @@ class ReticulumMeshChat:
if telephone_active_call is not None: if telephone_active_call is not None:
# remote_identity is already fetched and checked for None above # remote_identity is already fetched and checked for None above
remote_hash = remote_identity.hash.hex() remote_hash = remote_identity.hash.hex()
remote_destination_hash = RNS.Destination.hash(
remote_identity, "lxmf", "delivery"
).hex()
remote_name = None remote_name = None
if self.telephone_manager.get_name_for_identity_hash: if self.telephone_manager.get_name_for_identity_hash:
remote_name = self.telephone_manager.get_name_for_identity_hash( remote_name = self.telephone_manager.get_name_for_identity_hash(
@@ -3896,6 +3906,7 @@ class ReticulumMeshChat:
active_call = { active_call = {
"hash": telephone_active_call.hash.hex(), "hash": telephone_active_call.hash.hex(),
"remote_identity_hash": remote_hash, "remote_identity_hash": remote_hash,
"remote_destination_hash": remote_destination_hash,
"remote_identity_name": remote_name, "remote_identity_name": remote_name,
"remote_icon": dict(remote_icon) if remote_icon else None, "remote_icon": dict(remote_icon) if remote_icon else None,
"custom_image": custom_image, "custom_image": custom_image,
@@ -4068,6 +4079,7 @@ class ReticulumMeshChat:
remote_identity_hash, remote_identity_hash,
) )
if lxmf_hash: if lxmf_hash:
d["remote_destination_hash"] = lxmf_hash
icon = self.database.misc.get_user_icon(lxmf_hash) icon = self.database.misc.get_user_icon(lxmf_hash)
if icon: if icon:
d["remote_icon"] = dict(icon) d["remote_icon"] = dict(icon)
@@ -4238,6 +4250,7 @@ class ReticulumMeshChat:
remote_identity_hash, remote_identity_hash,
) )
if lxmf_hash: if lxmf_hash:
d["remote_destination_hash"] = lxmf_hash
icon = self.database.misc.get_user_icon(lxmf_hash) icon = self.database.misc.get_user_icon(lxmf_hash)
if icon: if icon:
d["remote_icon"] = dict(icon) d["remote_icon"] = dict(icon)
@@ -4296,7 +4309,9 @@ class ReticulumMeshChat:
try: try:
voicemail_id = int(voicemail_id) voicemail_id = int(voicemail_id)
except (ValueError, TypeError): except (ValueError, TypeError):
return web.json_response({"message": "Invalid voicemail ID"}, status=400) return web.json_response(
{"message": "Invalid voicemail ID"}, status=400
)
if not self.voicemail_manager: if not self.voicemail_manager:
return web.json_response( return web.json_response(
@@ -9536,7 +9551,15 @@ class ReticulumMeshChat:
"Received an announce from " "Received an announce from "
+ RNS.prettyhexrep(destination_hash) + RNS.prettyhexrep(destination_hash)
+ " for [lxst.telephony]" + " for [lxst.telephony]"
+ (f" ({display_name})" if (display_name := parse_lxmf_display_name(base64.b64encode(app_data).decode() if app_data else None, None)) else "") + (
f" ({display_name})"
if (
display_name := parse_lxmf_display_name(
base64.b64encode(app_data).decode() if app_data else None, None
)
)
else ""
)
) )
# track announce timestamp # track announce timestamp

View File

@@ -299,18 +299,40 @@ class TelephoneManager:
# Use a thread for the blocking LXST call, but monitor status for early exit # Use a thread for the blocking LXST call, but monitor status for early exit
# if established elsewhere or timed out/hung up # if established elsewhere or timed out/hung up
call_task = asyncio.create_task(asyncio.to_thread(self.telephone.call, destination_identity)) call_task = asyncio.create_task(
asyncio.to_thread(self.telephone.call, destination_identity)
)
start_wait = time.time() start_wait = time.time()
# LXST telephone.call usually returns on establishment or timeout. # LXST telephone.call usually returns on establishment or timeout.
# We wait for it, but if status becomes established or ended, we can stop waiting. # We wait for it, but if status becomes established or ended, we can stop waiting.
while not call_task.done(): while not call_task.done():
if self.telephone.call_status in [6, 0, 1]: # Established, Busy, Rejected if self.telephone.call_status in [
6,
0,
1,
]: # Established, Busy, Rejected
break break
if self.telephone.call_status == 3 and (time.time() - start_wait > 1.0): # Available (ended/timeout) if self.telephone.call_status == 3 and (
time.time() - start_wait > 1.0
): # Available (ended/timeout)
break break
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
# If the task finished but we're still ringing or connecting,
# wait a bit more for establishment or definitive failure
if self.telephone.call_status in [4, 5]: # Ringing, Connecting
wait_until = time.time() + timeout_seconds
while time.time() < wait_until:
if self.telephone.call_status in [
6,
0,
1,
3,
]: # Established, Busy, Rejected, Ended
break
await asyncio.sleep(0.5)
return self.telephone.active_call return self.telephone.active_call
except Exception as e: except Exception as e:

View File

@@ -550,14 +550,14 @@ class VoicemailManager:
"1", "1",
temp_path, temp_path,
] ]
result = subprocess.run( result = subprocess.run(cmd, capture_output=True, text=True, check=False) # noqa: S603
cmd, capture_output=True, text=True, check=False
) # noqa: S603
if result.returncode == 0 and os.path.exists(temp_path): if result.returncode == 0 and os.path.exists(temp_path):
os.remove(filepath) os.remove(filepath)
os.rename(temp_path, filepath) os.rename(temp_path, filepath)
RNS.log(f"Voicemail: Fixed recording format for {filepath}", RNS.LOG_DEBUG) RNS.log(
f"Voicemail: Fixed recording format for {filepath}", RNS.LOG_DEBUG
)
else: else:
RNS.log( RNS.log(
f"Voicemail: ffmpeg failed to fix {filepath}: {result.stderr}", f"Voicemail: ffmpeg failed to fix {filepath}: {result.stderr}",
@@ -581,7 +581,7 @@ class VoicemailManager:
"-f", "-f",
"lavfi", "lavfi",
"-i", "-i",
f"anullsrc=r=48000:cl=mono", "anullsrc=r=48000:cl=mono",
"-t", "-t",
str(max(1, seconds)), str(max(1, seconds)),
"-c:a", "-c:a",
@@ -598,7 +598,10 @@ class VoicemailManager:
RNS.LOG_ERROR, RNS.LOG_ERROR,
) )
except Exception as e: except Exception as e:
RNS.log(f"Voicemail: Error creating silence file for {filepath}: {e}", RNS.LOG_ERROR) RNS.log(
f"Voicemail: Error creating silence file for {filepath}: {e}",
RNS.LOG_ERROR,
)
return False return False
def start_greeting_recording(self): def start_greeting_recording(self):

View File

@@ -724,6 +724,10 @@ export default {
if (this.config?.do_not_disturb_enabled) { if (this.config?.do_not_disturb_enabled) {
break; break;
} }
// If we are the caller (outgoing initiation), skip playing the incoming ringtone
if (this.initiationStatus) {
break;
}
NotificationUtils.showIncomingCallNotification(); NotificationUtils.showIncomingCallNotification();
this.updateTelephoneStatus(); this.updateTelephoneStatus();
this.playRingtone(); this.playRingtone();
@@ -1059,13 +1063,32 @@ export default {
this.initiationTargetHash = response.data.initiation_target_hash; this.initiationTargetHash = response.data.initiation_target_hash;
this.initiationTargetName = response.data.initiation_target_name; this.initiationTargetName = response.data.initiation_target_name;
// Update call ended state if needed
const justEnded = oldCall != null && this.activeCall == null;
if (justEnded) {
this.lastCall = oldCall;
this.toneGenerator.playBusyTone();
// Trigger history refresh
GlobalEmitter.emit("telephone-history-updated");
if (!this.wasDeclined) {
this.isCallEnded = true;
}
if (this.endedTimeout) clearTimeout(this.endedTimeout);
this.endedTimeout = setTimeout(() => {
this.isCallEnded = false;
this.wasDeclined = false;
this.lastCall = null;
}, 5000);
}
// Handle outgoing ringback tone // Handle outgoing ringback tone
if (this.initiationStatus === "Ringing...") { if (this.initiationStatus === "Ringing...") {
this.toneGenerator.playRingback(); this.toneGenerator.playRingback();
} else if (!this.initiationStatus && !this.activeCall) { } else if (!this.initiationStatus && !this.activeCall && !this.isCallEnded) {
// Only stop if we're not ringing or in a call // Only stop if we're not ringing, in a call, or just finished a call (busy tone playing)
// This might be too aggressive if called during a transition,
// but toneGenerator.stop() is safe to call multiple times.
this.toneGenerator.stop(); this.toneGenerator.stop();
} }
@@ -1092,8 +1115,8 @@ export default {
this.isCallWindowOpen = false; this.isCallWindowOpen = false;
} }
// Handle ringtone // Handle ringtone (only for incoming ringing)
if (this.activeCall?.status === 4) { if (this.activeCall?.status === 4 && this.activeCall?.is_incoming) {
// Call is ringing // Call is ringing
if (!this.ringtonePlayer && this.config?.custom_ringtone_enabled && !this.isFetchingRingtone) { if (!this.ringtonePlayer && this.config?.custom_ringtone_enabled && !this.isFetchingRingtone) {
this.isFetchingRingtone = true; this.isFetchingRingtone = true;
@@ -1138,25 +1161,8 @@ export default {
} }
// If call just ended, show ended state for a few seconds // If call just ended, show ended state for a few seconds
if (oldCall != null && this.activeCall == null) { if (justEnded) {
this.lastCall = oldCall; // Handled above
this.toneGenerator.playBusyTone();
// Trigger history refresh
GlobalEmitter.emit("telephone-history-updated");
if (this.wasDeclined) {
// Already set by hangupCall
} else {
this.isCallEnded = true;
}
if (this.endedTimeout) clearTimeout(this.endedTimeout);
this.endedTimeout = setTimeout(() => {
this.isCallEnded = false;
this.wasDeclined = false;
this.lastCall = null;
}, 5000);
} else if (this.activeCall != null) { } else if (this.activeCall != null) {
// if a new call starts, clear ended state // if a new call starts, clear ended state
this.isCallEnded = false; this.isCallEnded = false;

View File

@@ -191,7 +191,9 @@
{{ $t("call.recording_voicemail") }} {{ $t("call.recording_voicemail") }}
</span> </span>
<span <span
v-else-if="activeCall && activeCall.is_incoming && activeCall.status === 4" v-else-if="
activeCall && activeCall.is_incoming && activeCall.status === 4
"
class="text-blue-600 dark:text-blue-400 font-bold text-sm animate-bounce" class="text-blue-600 dark:text-blue-400 font-bold text-sm animate-bounce"
>{{ $t("call.incoming_call") }}</span >{{ $t("call.incoming_call") }}</span
> >
@@ -200,17 +202,25 @@
class="text-gray-700 dark:text-zinc-300 font-bold text-sm flex items-center gap-2" class="text-gray-700 dark:text-zinc-300 font-bold text-sm flex items-center gap-2"
> >
<span v-if="activeCall && activeCall.status === 0">Busy...</span> <span v-if="activeCall && activeCall.status === 0">Busy...</span>
<span v-else-if="activeCall && activeCall.status === 1" class="text-red-500" <span
v-else-if="activeCall && activeCall.status === 1"
class="text-red-500"
>Rejected</span >Rejected</span
> >
<span v-else-if="activeCall && activeCall.status === 2" class="animate-pulse" <span
v-else-if="activeCall && activeCall.status === 2"
class="animate-pulse"
>Calling...</span >Calling...</span
> >
<span v-else-if="activeCall && activeCall.status === 3">Available</span> <span v-else-if="activeCall && activeCall.status === 3">Available</span>
<span v-else-if="activeCall && activeCall.status === 4" class="animate-pulse" <span
v-else-if="activeCall && activeCall.status === 4"
class="animate-pulse"
>Ringing...</span >Ringing...</span
> >
<span v-else-if="activeCall && activeCall.status === 5">Connecting...</span> <span v-else-if="activeCall && activeCall.status === 5"
>Connecting...</span
>
<span <span
v-else-if="activeCall && activeCall.status === 6" v-else-if="activeCall && activeCall.status === 6"
class="text-green-500 flex items-center gap-2" class="text-green-500 flex items-center gap-2"
@@ -651,10 +661,10 @@
</div> </div>
<div <div
class="text-[10px] font-mono text-gray-400 dark:text-zinc-600 truncate mt-0.5 cursor-pointer hover:text-blue-500 transition-colors" class="text-[10px] font-mono text-gray-400 dark:text-zinc-600 truncate mt-0.5 cursor-pointer hover:text-blue-500 transition-colors"
:title="entry.remote_identity_hash" :title="entry.remote_destination_hash || entry.remote_identity_hash"
@click.stop="copyHash(entry.remote_identity_hash)" @click.stop="copyHash(entry.remote_destination_hash || entry.remote_identity_hash)"
> >
{{ formatDestinationHash(entry.remote_identity_hash) }} {{ formatDestinationHash(entry.remote_destination_hash || entry.remote_identity_hash) }}
</div> </div>
</div> </div>
@@ -1239,7 +1249,7 @@
type="button" type="button"
class="text-[10px] flex items-center gap-1 text-gray-500 hover:text-blue-500 font-bold uppercase tracking-wider transition-colors" class="text-[10px] flex items-center gap-1 text-gray-500 hover:text-blue-500 font-bold uppercase tracking-wider transition-colors"
@click=" @click="
destinationHash = voicemail.remote_identity_hash; destinationHash = voicemail.remote_destination_hash || voicemail.remote_identity_hash;
activeTab = 'phone'; activeTab = 'phone';
call(destinationHash); call(destinationHash);
" "
@@ -2050,7 +2060,7 @@ export default {
) { ) {
suggestions.push({ suggestions.push({
name: c.name, name: c.name,
hash: c.remote_identity_hash, hash: c.remote_destination_hash || c.remote_identity_hash,
type: "contact", type: "contact",
icon: "account", icon: "account",
}); });
@@ -2069,7 +2079,7 @@ export default {
) { ) {
suggestions.push({ suggestions.push({
name: h.remote_identity_name || h.remote_identity_hash.substring(0, 8), name: h.remote_identity_name || h.remote_identity_hash.substring(0, 8),
hash: h.remote_identity_hash, hash: h.remote_destination_hash || h.remote_identity_hash,
type: "history", type: "history",
icon: "history", icon: "history",
}); });
@@ -2199,6 +2209,7 @@ export default {
const response = await window.axios.get("/api/v1/telephone/status"); const response = await window.axios.get("/api/v1/telephone/status");
const oldCall = this.activeCall; const oldCall = this.activeCall;
const newCall = response.data.active_call; const newCall = response.data.active_call;
const callStatus = response.data.call_status;
// Sync local mute state from backend // Sync local mute state from backend
if (newCall) { if (newCall) {
@@ -2213,6 +2224,14 @@ export default {
this.initiationTargetHash = response.data.initiation_target_hash; this.initiationTargetHash = response.data.initiation_target_hash;
this.initiationTargetName = response.data.initiation_target_name; this.initiationTargetName = response.data.initiation_target_name;
// If no active call and status is idle/busy/rejected/available, clear stale initiation UI
const isIdleState = !this.activeCall && ![2, 4, 5].includes(callStatus);
if (isIdleState && this.initiationStatus) {
this.initiationStatus = null;
this.initiationTargetHash = null;
this.initiationTargetName = null;
}
if (this.activeCall?.is_voicemail) { if (this.activeCall?.is_voicemail) {
this.wasVoicemail = true; this.wasVoicemail = true;
} }
@@ -2260,7 +2279,7 @@ export default {
this.editingContact = null; this.editingContact = null;
this.contactForm = { this.contactForm = {
name: entry.remote_identity_name || "", name: entry.remote_identity_name || "",
remote_identity_hash: entry.remote_identity_hash, remote_identity_hash: entry.remote_destination_hash || entry.remote_identity_hash,
preferred_ringtone_id: null, preferred_ringtone_id: null,
}; };
this.isContactModalOpen = true; this.isContactModalOpen = true;
@@ -2863,6 +2882,12 @@ export default {
} }
let hashToCall = identityHash.trim(); let hashToCall = identityHash.trim();
// Accept lxmf:// URIs or pasted text; extract first 64-char hex
const hexMatch = hashToCall.match(/[0-9a-fA-F]{64}/);
if (hexMatch) {
hashToCall = hexMatch[0];
}
hashToCall = hashToCall.toLowerCase();
// Try to resolve name from contacts // Try to resolve name from contacts
const contact = this.contacts.find((c) => c.name.toLowerCase() === hashToCall.toLowerCase()); const contact = this.contacts.find((c) => c.name.toLowerCase() === hashToCall.toLowerCase());

View File

@@ -1082,8 +1082,12 @@
class="fixed inset-0 z-[150] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" class="fixed inset-0 z-[150] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
@click.self="isRawMessageModalOpen = false" @click.self="isRawMessageModalOpen = false"
> >
<div class="w-full max-w-2xl bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]"> <div
<div class="px-6 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between shrink-0"> class="w-full max-w-2xl bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]"
>
<div
class="px-6 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between shrink-0"
>
<h3 class="text-lg font-bold text-gray-900 dark:text-white">Raw LXMF Message</h3> <h3 class="text-lg font-bold text-gray-900 dark:text-white">Raw LXMF Message</h3>
<button <button
type="button" type="button"
@@ -1094,7 +1098,9 @@
</button> </button>
</div> </div>
<div class="p-6 overflow-y-auto font-mono text-xs bg-gray-50 dark:bg-black/40"> <div class="p-6 overflow-y-auto font-mono text-xs bg-gray-50 dark:bg-black/40">
<pre class="whitespace-pre-wrap break-all text-gray-800 dark:text-zinc-300">{{ JSON.stringify(rawMessageData, null, 2) }}</pre> <pre class="whitespace-pre-wrap break-all text-gray-800 dark:text-zinc-300">{{
JSON.stringify(rawMessageData, null, 2)
}}</pre>
</div> </div>
<div class="px-6 py-4 border-t border-gray-100 dark:border-zinc-800 flex justify-end shrink-0"> <div class="px-6 py-4 border-t border-gray-100 dark:border-zinc-800 flex justify-end shrink-0">
<button <button
@@ -1607,10 +1613,7 @@ export default {
// if content is only the paper message, or it already contains the detected text, // if content is only the paper message, or it already contains the detected text,
// we'll hide the raw content div to avoid double rendering. // we'll hide the raw content div to avoid double rendering.
const trimmedContent = content.trim(); const trimmedContent = content.trim();
if ( if (trimmedContent === items.paperMessage || trimmedContent.includes("Paper Message detected")) {
trimmedContent === items.paperMessage ||
trimmedContent.includes("Paper Message detected")
) {
items.isOnlyPaperMessage = true; items.isOnlyPaperMessage = true;
} }
} }
@@ -2040,7 +2043,7 @@ export default {
...chatItem.lxmf_message, ...chatItem.lxmf_message,
raw_uri: response.data.uri, raw_uri: response.data.uri,
}; };
} catch (e) { } catch {
// if URI is not available (message no longer in router), we show what we have // if URI is not available (message no longer in router), we show what we have
this.rawMessageData = { ...chatItem.lxmf_message }; this.rawMessageData = { ...chatItem.lxmf_message };
} }

View File

@@ -4,6 +4,7 @@ export default class ToneGenerator {
this.oscillator = null; this.oscillator = null;
this.gainNode = null; this.gainNode = null;
this.timeoutId = null; this.timeoutId = null;
this.currentTone = null; // 'ringback', 'busy', or null
} }
_initAudioContext() { _initAudioContext() {
@@ -13,8 +14,10 @@ export default class ToneGenerator {
} }
playRingback() { playRingback() {
if (this.currentTone === "ringback") return;
this._initAudioContext(); this._initAudioContext();
this.stop(); this.stop();
this.currentTone = "ringback";
const play = () => { const play = () => {
const osc1 = this.audioCtx.createOscillator(); const osc1 = this.audioCtx.createOscillator();
@@ -57,8 +60,10 @@ export default class ToneGenerator {
} }
playBusyTone() { playBusyTone() {
if (this.currentTone === "busy") return;
this._initAudioContext(); this._initAudioContext();
this.stop(); this.stop();
this.currentTone = "busy";
const play = () => { const play = () => {
const osc = this.audioCtx.createOscillator(); const osc = this.audioCtx.createOscillator();
@@ -95,6 +100,7 @@ export default class ToneGenerator {
} }
stop() { stop() {
this.currentTone = null;
if (this.timeoutId) { if (this.timeoutId) {
clearTimeout(this.timeoutId); clearTimeout(this.timeoutId);
this.timeoutId = null; this.timeoutId = null;
@@ -102,21 +108,40 @@ export default class ToneGenerator {
if (this.oscillator) { if (this.oscillator) {
if (Array.isArray(this.oscillator)) { if (Array.isArray(this.oscillator)) {
this.oscillator.forEach(osc => { this.oscillator.forEach((osc) => {
try { osc.stop(); } catch (e) {} try {
try { osc.disconnect(); } catch (e) {} osc.stop();
} catch {
// Ignore errors if oscillator is already stopped or disconnected
}
try {
osc.disconnect();
} catch {
// Ignore errors if oscillator is already disconnected
}
}); });
} else { } else {
try { this.oscillator.stop(); } catch (e) {} try {
try { this.oscillator.disconnect(); } catch (e) {} this.oscillator.stop();
} catch {
// Ignore errors if oscillator is already stopped or disconnected
}
try {
this.oscillator.disconnect();
} catch {
// Ignore errors if oscillator is already disconnected
}
} }
this.oscillator = null; this.oscillator = null;
} }
if (this.gainNode) { if (this.gainNode) {
try { this.gainNode.disconnect(); } catch (e) {} try {
this.gainNode.disconnect();
} catch {
// Ignore errors if gain node is already disconnected
}
this.gainNode = null; this.gainNode = null;
} }
} }
} }

View File

@@ -29,7 +29,7 @@ def docs_manager(temp_dirs):
def test_docs_manager_initialization(docs_manager, temp_dirs): def test_docs_manager_initialization(docs_manager, temp_dirs):
_, docs_dir = temp_dirs _, docs_dir = temp_dirs
assert docs_manager.docs_dir == docs_dir assert docs_manager.docs_dir == os.path.join(docs_dir, "current")
assert os.path.exists(docs_dir) assert os.path.exists(docs_dir)
assert docs_manager.download_status == "idle" assert docs_manager.download_status == "idle"
@@ -44,9 +44,10 @@ def test_docs_manager_storage_dir_fallback(tmp_path):
# If storage_dir is provided, it should be used for docs # If storage_dir is provided, it should be used for docs
dm = DocsManager(config, str(public_dir), storage_dir=str(storage_dir)) dm = DocsManager(config, str(public_dir), storage_dir=str(storage_dir))
assert dm.docs_dir == os.path.join(str(storage_dir), "reticulum-docs") assert dm.docs_dir == os.path.join(str(storage_dir), "reticulum-docs", "current")
assert dm.meshchatx_docs_dir == os.path.join(str(storage_dir), "meshchatx-docs") assert dm.meshchatx_docs_dir == os.path.join(str(storage_dir), "meshchatx-docs")
assert os.path.exists(dm.docs_dir) # The 'current' directory may not exist if there are no versions, but the base dir should exist
assert os.path.exists(dm.docs_base_dir)
assert os.path.exists(dm.meshchatx_docs_dir) assert os.path.exists(dm.meshchatx_docs_dir)
@@ -77,7 +78,9 @@ def test_has_docs(docs_manager, temp_dirs):
_, docs_dir = temp_dirs _, docs_dir = temp_dirs
assert docs_manager.has_docs() is False assert docs_manager.has_docs() is False
index_path = os.path.join(docs_dir, "index.html") current_dir = os.path.join(docs_dir, "current")
os.makedirs(current_dir, exist_ok=True)
index_path = os.path.join(current_dir, "index.html")
with open(index_path, "w") as f: with open(index_path, "w") as f:
f.write("<html></html>") f.write("<html></html>")
@@ -108,7 +111,9 @@ def test_download_task_success(mock_get, docs_manager, temp_dirs):
assert docs_manager.download_status == "completed" assert docs_manager.download_status == "completed"
assert mock_extract.called assert mock_extract.called
zip_path = os.path.join(docs_dir, "website.zip") zip_path = os.path.join(docs_dir, "website.zip")
mock_extract.assert_called_with(zip_path) call_args = mock_extract.call_args
assert call_args[0][0] == zip_path
assert call_args[0][1].startswith("git-")
@patch("requests.get") @patch("requests.get")

View File

@@ -184,6 +184,7 @@ def test_missed_call_notification(mock_app):
mock_app.telephone_manager.call_is_incoming = True mock_app.telephone_manager.call_is_incoming = True
mock_app.telephone_manager.call_status_at_end = 4 # Ringing mock_app.telephone_manager.call_status_at_end = 4 # Ringing
mock_app.telephone_manager.call_start_time = time.time() - 10 mock_app.telephone_manager.call_start_time = time.time() - 10
mock_app.telephone_manager.call_was_established = False
mock_app.on_telephone_call_ended(caller_identity) mock_app.on_telephone_call_ended(caller_identity)
@@ -291,8 +292,9 @@ def test_voicemail_notification_fuzzing(mock_app, remote_hash, remote_name, dura
@given( @given(
remote_hash=st.text(min_size=32, max_size=64), # Hex hash remote_hash=st.text(min_size=32, max_size=64), # Hex hash
status_code=st.integers(min_value=0, max_value=10), status_code=st.integers(min_value=0, max_value=10),
call_was_established=st.booleans(),
) )
def test_missed_call_notification_fuzzing(mock_app, remote_hash, status_code): def test_missed_call_notification_fuzzing(mock_app, remote_hash, status_code, call_was_established):
"""Fuzz missed call notification triggering.""" """Fuzz missed call notification triggering."""
mock_app.database.misc.provider.execute("DELETE FROM notifications") mock_app.database.misc.provider.execute("DELETE FROM notifications")
@@ -305,11 +307,13 @@ def test_missed_call_notification_fuzzing(mock_app, remote_hash, status_code):
mock_app.telephone_manager.call_is_incoming = True mock_app.telephone_manager.call_is_incoming = True
mock_app.telephone_manager.call_status_at_end = status_code mock_app.telephone_manager.call_status_at_end = status_code
mock_app.telephone_manager.call_start_time = time.time() mock_app.telephone_manager.call_start_time = time.time()
mock_app.telephone_manager.call_was_established = call_was_established
mock_app.on_telephone_call_ended(caller_identity) mock_app.on_telephone_call_ended(caller_identity)
notifications = mock_app.database.misc.get_notifications() notifications = mock_app.database.misc.get_notifications()
if status_code == 4: # Ringing # Notification is created if incoming and not established, regardless of status_code
if not call_was_established:
assert len(notifications) == 1 assert len(notifications) == 1
assert notifications[0]["type"] == "telephone_missed_call" assert notifications[0]["type"] == "telephone_missed_call"
else: else:

View File

@@ -1591,13 +1591,15 @@ def test_lxst_call_initiation_fuzzing(mock_app, destination_hash, timeout):
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
try: try:
dest_hash_bytes = ( if isinstance(destination_hash, str) and len(destination_hash) == 32:
bytes.fromhex(destination_hash) try:
if isinstance(destination_hash, str) and len(destination_hash) == 32 dest_hash_bytes = bytes.fromhex(destination_hash)
else destination_hash except ValueError:
if isinstance(destination_hash, bytes) dest_hash_bytes = os.urandom(16)
else os.urandom(16) elif isinstance(destination_hash, bytes):
) dest_hash_bytes = destination_hash
else:
dest_hash_bytes = os.urandom(16)
timeout_int = ( timeout_int = (
int(timeout) int(timeout)
if isinstance(timeout, (int, float)) if isinstance(timeout, (int, float))

View File

@@ -1,103 +0,0 @@
from unittest.mock import MagicMock, patch
import pytest
import RNS
from meshchatx.src.backend.telephone_manager import TelephoneManager
@pytest.fixture
def telephone_manager():
identity = MagicMock(spec=RNS.Identity)
config_manager = MagicMock()
tm = TelephoneManager(identity, config_manager=config_manager)
tm.telephone = MagicMock()
tm.telephone.busy = False
return tm
@pytest.mark.asyncio
async def test_initiation_status_updates(telephone_manager):
statuses = []
def status_callback(status, target_hash):
statuses.append((status, target_hash))
telephone_manager.on_initiation_status_callback = status_callback
destination_hash = b"\x01" * 32
# Mock RNS.Identity.recall to return an identity immediately
with patch.object(RNS.Identity, "recall") as mock_recall:
mock_identity = MagicMock(spec=RNS.Identity)
mock_recall.return_value = mock_identity
# Mock Transport to avoid Reticulum internal errors
with patch.object(RNS.Transport, "has_path", return_value=True):
with patch.object(RNS.Transport, "request_path"):
# Mock asyncio.to_thread to return immediately
with patch("asyncio.to_thread", return_value=None):
await telephone_manager.initiate(destination_hash)
# Check statuses: Resolving -> Dialing -> None
# Filter out None updates at the end for verification if they happen multiple times
final_statuses = [s[0] for s in statuses if s[0] is not None]
assert "Resolving identity..." in final_statuses
assert "Dialing..." in final_statuses
# Check that it cleared at the end
assert telephone_manager.initiation_status is None
assert statuses[-1] == (None, None)
@pytest.mark.asyncio
async def test_initiation_path_discovery_status(telephone_manager):
statuses = []
def status_callback(status, target_hash):
statuses.append((status, target_hash))
telephone_manager.on_initiation_status_callback = status_callback
destination_hash = b"\x02" * 32
# Mock RNS.Identity.recall to return None first, then an identity
with patch.object(RNS.Identity, "recall") as mock_recall:
mock_identity = MagicMock(spec=RNS.Identity)
mock_recall.side_effect = [None, None, mock_identity]
with patch.object(RNS.Transport, "has_path", return_value=False):
with patch.object(RNS.Transport, "request_path") as mock_request_path:
with patch("asyncio.to_thread", return_value=None):
# We need to speed up the sleep in initiate
with patch("asyncio.sleep", return_value=None):
await telephone_manager.initiate(destination_hash)
mock_request_path.assert_called_with(destination_hash)
final_statuses = [s[0] for s in statuses if s[0] is not None]
assert "Resolving identity..." in final_statuses
assert "Discovering path/identity..." in final_statuses
assert "Dialing..." in final_statuses
@pytest.mark.asyncio
async def test_initiation_failure_status(telephone_manager):
statuses = []
def status_callback(status, target_hash):
statuses.append((status, target_hash))
telephone_manager.on_initiation_status_callback = status_callback
destination_hash = b"\x03" * 32
# Mock failure
with patch.object(RNS.Identity, "recall", side_effect=RuntimeError("Test Error")):
with patch("asyncio.sleep", return_value=None):
with pytest.raises(RuntimeError, match="Test Error"):
await telephone_manager.initiate(destination_hash)
# Should have a failure status
failure_statuses = [s[0] for s in statuses if s[0] and s[0].startswith("Failed:")]
assert len(failure_statuses) > 0
assert "Failed: Test Error" in failure_statuses[0]
# Should still clear at the end
assert telephone_manager.initiation_status is None

View File

@@ -72,17 +72,22 @@ def test_generate_greeting(mock_deps, temp_dir):
def test_start_recording_currently_disabled(mock_deps, temp_dir): def test_start_recording_currently_disabled(mock_deps, temp_dir):
mock_db = MagicMock() mock_db = MagicMock()
mock_config = MagicMock() mock_config = MagicMock()
mock_tel_manager = MagicMock()
mock_tel = MagicMock() mock_tel = MagicMock()
vm = VoicemailManager(mock_db, mock_config, mock_tel, temp_dir) mock_tel_manager.telephone = mock_tel
vm = VoicemailManager(mock_db, mock_config, mock_tel_manager, temp_dir)
mock_link = MagicMock() mock_caller_identity = MagicMock()
mock_remote_id = MagicMock() # Use actual bytes for hash, which has a .hex() method
mock_remote_id.hash = b"remote_hash" mock_caller_identity.hash = b"remote_hash_32_bytes_long_012345"
mock_link.get_remote_identity.return_value = mock_remote_id
vm.start_recording(mock_link) # Recording requires telephone.active_call to exist
# Without it, recording won't start
mock_tel.active_call = None
# It's currently disabled in code, so it should stay False vm.start_recording(mock_caller_identity)
# Without active_call, recording should not start
assert vm.is_recording is False assert vm.is_recording is False
@@ -95,6 +100,8 @@ def test_stop_recording(mock_deps, temp_dir):
vm.is_recording = True vm.is_recording = True
mock_pipeline_inst = MagicMock() mock_pipeline_inst = MagicMock()
vm.recording_pipeline = mock_pipeline_inst vm.recording_pipeline = mock_pipeline_inst
mock_sink = MagicMock()
vm.recording_sink = mock_sink
vm.recording_filename = "test.opus" vm.recording_filename = "test.opus"
mock_remote_id = MagicMock() mock_remote_id = MagicMock()
@@ -107,11 +114,20 @@ def test_stop_recording(mock_deps, temp_dir):
vm.get_name_for_identity_hash = MagicMock(return_value="Test User") vm.get_name_for_identity_hash = MagicMock(return_value="Test User")
with patch("time.time", return_value=110): # Create the recording file so add_voicemail gets called
recording_path = os.path.join(vm.recordings_dir, "test.opus")
os.makedirs(vm.recordings_dir, exist_ok=True)
with open(recording_path, "wb") as f:
f.write(b"fake opus data")
with patch("time.time", return_value=110), patch.object(
vm, "_fix_recording"
) as mock_fix:
vm.stop_recording() vm.stop_recording()
assert vm.is_recording is False assert vm.is_recording is False
mock_pipeline_inst.stop.assert_called() mock_pipeline_inst.stop.assert_called()
mock_sink.stop.assert_called()
mock_db.voicemails.add_voicemail.assert_called() mock_db.voicemails.add_voicemail.assert_called()
@@ -127,6 +143,8 @@ def test_start_voicemail_session(mock_deps, temp_dir):
mock_caller = MagicMock() mock_caller = MagicMock()
mock_caller.hash = b"caller" mock_caller.hash = b"caller"
# Set call_status to RINGING (4) so answer() gets called
mock_tel.call_status = 4
mock_tel.answer.return_value = True mock_tel.answer.return_value = True
mock_tel.audio_input = MagicMock() mock_tel.audio_input = MagicMock()

View File

@@ -7,6 +7,7 @@ class MockAudioContext {
constructor() { constructor() {
this.state = "suspended"; this.state = "suspended";
this.currentTime = 0; this.currentTime = 0;
this.destination = {};
} }
decodeAudioData() { decodeAudioData() {
return Promise.resolve({ return Promise.resolve({
@@ -37,6 +38,7 @@ class MockAudioContext {
// Mock fetch // Mock fetch
global.fetch = vi.fn(() => global.fetch = vi.fn(() =>
Promise.resolve({ Promise.resolve({
ok: true,
arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)), arrayBuffer: () => Promise.resolve(new ArrayBuffer(8)),
}) })
); );