feat(call-management): update incoming call handling by implementing identity hash resolution for contacts, improving call rejection logic, and adding support for LXMF and LXST address matching in contact lookups

This commit is contained in:
Ivan 2026-07-09 13:49:56 -05:00
parent 1184fa47bd
commit 29d91785fb
No known key found for this signature in database
13 changed files with 438 additions and 111 deletions

View file

@ -4018,9 +4018,17 @@ class ReticulumMeshChat:
return
if ctx.telephone_manager and ctx.telephone_manager.initiation_status:
# Outgoing dial owns the line. Reject the inbound caller so they are
# not left ringing while we ignore the callback locally.
print(
"on_incoming_telephone_call: Ignoring as we are currently initiating an outgoing call.",
"on_incoming_telephone_call: Rejecting as we are currently initiating an outgoing call.",
)
telephone = getattr(ctx.telephone_manager, "telephone", None)
if telephone:
threading.Timer(
0.5,
lambda t=telephone: t.hangup(),
).start()
return
caller_hash = caller_identity.hash.hex()
@ -4054,15 +4062,7 @@ class ReticulumMeshChat:
ctx.config.telephone_allow_calls_from_contacts_only.get()
or ctx.config.block_all_from_strangers.get()
):
contact = None
try:
contact = ctx.database.contacts.get_contact_by_identity_hash(
caller_hash
)
except Exception:
# Treat lookup failure as non-contact to avoid accidentally allowing spam
pass
if not contact:
if not self._is_contact(caller_hash, context=ctx):
print(f"Rejecting incoming call from non-contact: {caller_hash}")
telephone = getattr(ctx.telephone_manager, "telephone", None)
if telephone:
@ -4078,13 +4078,7 @@ class ReticulumMeshChat:
print(f"on_incoming_telephone_call: {caller_identity.hash.hex()}")
ch = caller_identity.hash.hex()
caller_name = (self.get_name_for_identity_hash(ch) or "").strip() or "Mesh"
is_contact = False
try:
is_contact = (
ctx.database.contacts.get_contact_by_identity_hash(ch) is not None
)
except Exception:
pass
is_contact = self._is_contact(ch, context=ctx)
AsyncUtils.run_async(
self.websocket_broadcast(
json.dumps(
@ -4181,16 +4175,11 @@ class ReticulumMeshChat:
is_filtered = False
if ctx.config.do_not_disturb_enabled.get():
is_filtered = True
elif ctx.config.telephone_allow_calls_from_contacts_only.get():
try:
contact = ctx.database.contacts.get_contact_by_identity_hash(
remote_identity_hash,
)
if not contact:
is_filtered = True
except Exception:
# Treat lookup failure as filtered to avoid leaking missed-call noise
is_filtered = True
elif (
ctx.config.telephone_allow_calls_from_contacts_only.get()
or ctx.config.block_all_from_strangers.get()
) and not self._is_contact(remote_identity_hash, context=ctx):
is_filtered = True
if not is_filtered:
AsyncUtils.run_async(
@ -9435,16 +9424,14 @@ class ReticulumMeshChat:
if self.config.do_not_disturb_enabled.get():
# Don't report active call if DND is on and it's ringing
telephone_active_call = None
elif self.config.telephone_allow_calls_from_contacts_only.get():
elif (
self.config.telephone_allow_calls_from_contacts_only.get()
or self.config.block_all_from_strangers.get()
):
remote_identity = telephone_active_call.get_remote_identity()
if remote_identity:
caller_hash = remote_identity.hash.hex()
contact = (
self.database.contacts.get_contact_by_identity_hash(
caller_hash,
)
)
if not contact:
if not self._is_contact(caller_hash):
# Don't report active call if contacts-only is on and caller is not a contact
telephone_active_call = None
else:
@ -9483,9 +9470,7 @@ class ReticulumMeshChat:
remote_icon = self.database.misc.get_user_icon(lxmf_destination_hash)
# Check if contact and get custom image
contact = self.database.contacts.get_contact_by_identity_hash(
remote_hash,
)
contact = self._resolve_contact_for_hash(remote_hash)
custom_image = contact["custom_image"] if contact else None
active_call = {
@ -9573,11 +9558,9 @@ class ReticulumMeshChat:
initiation_target_name = None
if initiation_target_hash:
try:
contact = self.database.contacts.get_contact_by_identity_hash(
initiation_target_hash,
)
contact = self._resolve_contact_for_hash(initiation_target_hash)
if contact:
initiation_target_name = contact.name
initiation_target_name = contact["name"]
except Exception:
pass
@ -9755,9 +9738,7 @@ class ReticulumMeshChat:
if tele_hash:
d["remote_telephony_hash"] = tele_hash
contact = self.database.contacts.get_contact_by_identity_hash(
remote_identity_hash,
)
contact = self._resolve_contact_for_hash(remote_identity_hash)
d["is_contact"] = contact is not None
if contact:
d["contact_image"] = contact.get("custom_image")
@ -10595,28 +10576,69 @@ class ReticulumMeshChat:
status=400,
)
if not remote_identity_hash:
# Try to derive identity from LXMF or LXST address
lookup_hash = lxmf_address or lxst_address
if lookup_hash:
announce = self.database.announces.get_announce_by_hash(lookup_hash)
if announce:
remote_identity_hash = announce.get("identity_hash")
else:
# try to recall identity from RNS
ident = self.recall_identity(lookup_hash)
if ident:
remote_identity_hash = ident.hash.hex()
# Normalize: chat UI often posts an LXMF destination hash as
# remote_identity_hash. Prefer the real identity hash when known so
# incoming-call policy (identity hash) matches saved contacts.
provided_hash = remote_identity_hash
lookup_hash = remote_identity_hash or lxmf_address or lxst_address
if lookup_hash:
announce = self.database.announces.get_announce_by_hash(lookup_hash)
if announce and announce.get("identity_hash"):
remote_identity_hash = announce.get("identity_hash")
if not lxmf_address and announce.get("aspect") == "lxmf.delivery":
lxmf_address = announce.get("destination_hash") or lookup_hash
if not lxst_address and announce.get("aspect") == "lxst.telephony":
lxst_address = announce.get("destination_hash") or lookup_hash
else:
ident = self.recall_identity(lookup_hash)
if ident:
remote_identity_hash = ident.hash.hex()
if not remote_identity_hash:
# Fallback: use the provided lookup hash directly as identity hash
remote_identity_hash = lxmf_address or lxst_address
remote_identity_hash = lxmf_address or lxst_address or provided_hash
if not remote_identity_hash:
return web.json_response(
{"message": "Identity hash is required or could not be derived"},
status=400,
)
# If the client only supplied a destination hash, keep it on the
# matching address field so lookups by either form succeed.
if provided_hash and provided_hash != remote_identity_hash:
if not lxmf_address:
lxmf_announce = self.database.announces.get_announce_by_hash(
provided_hash,
)
if lxmf_announce and lxmf_announce.get("aspect") == "lxmf.delivery":
lxmf_address = provided_hash
elif not lxst_address:
lxst_announce = self.database.announces.get_announce_by_hash(
provided_hash,
)
if (
lxst_announce
and lxst_announce.get("aspect") == "lxst.telephony"
):
lxst_address = provided_hash
else:
# Default: treat unknown destination-shaped hashes as LXMF
lxmf_address = lxmf_address or provided_hash
if not lxmf_address:
try:
lxmf_address = self.get_lxmf_destination_hash_for_identity_hash(
remote_identity_hash,
)
except Exception:
pass
if not lxst_address:
try:
lxst_address = self.get_lxst_telephony_hash_for_identity_hash(
remote_identity_hash,
)
except Exception:
pass
self.database.contacts.add_contact(
name,
remote_identity_hash,
@ -10663,7 +10685,7 @@ class ReticulumMeshChat:
@routes.get("/api/v1/telephone/contacts/check/{identity_hash}")
async def telephone_contacts_check(request):
identity_hash = request.match_info["identity_hash"]
contact = self.database.contacts.get_contact_by_identity_hash(identity_hash)
contact = self._resolve_contact_for_hash(identity_hash)
return web.json_response(
{
"is_contact": contact is not None,
@ -19091,15 +19113,82 @@ class ReticulumMeshChat:
background_colour,
)
def _is_contact(self, source_hash: str, context=None) -> bool:
def _related_hashes_for_contact_lookup(self, source_hash: str, context=None):
"""Collect identity/LXMF/LXST hashes that may identify the same peer."""
ctx = context or self.current_context
related = []
seen = set()
def add(value):
if not value or not isinstance(value, str):
return
normalized = normalize_hex_identifier(value)
if not normalized or normalized in seen:
return
seen.add(normalized)
related.append(normalized)
add(source_hash)
if not ctx or not ctx.database:
return False
return related
try:
contact = ctx.database.contacts.get_contact_by_identity_hash(source_hash)
return contact is not None
announce = ctx.database.announces.get_announce_by_hash(source_hash)
if announce:
add(announce.get("identity_hash"))
add(announce.get("destination_hash"))
identity_hash = announce.get("identity_hash")
if identity_hash:
for other in ctx.database.announces.get_announces_by_identity_hash(
identity_hash,
):
add(other.get("destination_hash"))
add(other.get("identity_hash"))
else:
for other in ctx.database.announces.get_announces_by_identity_hash(
source_hash,
):
add(other.get("destination_hash"))
add(other.get("identity_hash"))
except Exception:
return False
pass
try:
lxmf_hash = self.get_lxmf_destination_hash_for_identity_hash(source_hash)
add(lxmf_hash)
except Exception:
pass
try:
lxst_hash = self.get_lxst_telephony_hash_for_identity_hash(source_hash)
add(lxst_hash)
except Exception:
pass
return related
def _resolve_contact_for_hash(self, source_hash: str, context=None):
"""Resolve a contact for an identity or destination hash.
Contacts are often saved with an LXMF destination hash as
``remote_identity_hash`` (from chat UI). Incoming calls provide the
caller's identity hash. Bridge those forms via announces and derived
destination hashes so contacts-only call policy works.
"""
ctx = context or self.current_context
if not ctx or not ctx.database or not source_hash:
return None
try:
related = self._related_hashes_for_contact_lookup(source_hash, context=ctx)
return ctx.database.contacts.get_contact_by_identity_hash(
source_hash,
related_hashes=related,
)
except Exception:
return None
def _is_contact(self, source_hash: str, context=None) -> bool:
return self._resolve_contact_for_hash(source_hash, context=context) is not None
def _encode_pcm_wav_to_ogg_opus(self, wav_bytes: bytes) -> bytes | None:
"""Encode a WAV/PCM payload into an OGG/Opus byte string.

View file

@ -132,8 +132,38 @@ class ContactsDAO:
def delete_contact(self, contact_id):
self.provider.execute("DELETE FROM contacts WHERE id = ?", (contact_id,))
def get_contact_by_identity_hash(self, remote_identity_hash):
def get_contact_by_identity_hash(self, remote_identity_hash, related_hashes=None):
"""Match a contact by identity, LXMF, or LXST hash.
``related_hashes`` may include derived destination hashes for the same
peer so callers that only know an identity hash still match contacts
that were saved with an LXMF or LXST destination hash as the primary key.
Matching is case-insensitive. Hex-only forms are also tried so UUID-style
separators still match stored RNS hashes.
"""
if not remote_identity_hash and not related_hashes:
return None
candidates = []
for value in (remote_identity_hash, *(related_hashes or ())):
if not value or not isinstance(value, str):
continue
lowered = value.strip().lower()
if lowered and lowered not in candidates:
candidates.append(lowered)
hex_only = "".join(c for c in lowered if c in "0123456789abcdef")
if hex_only and hex_only not in candidates:
candidates.append(hex_only)
if not candidates:
return None
placeholders = ", ".join("?" for _ in candidates)
return self.provider.fetchone(
"SELECT * FROM contacts WHERE remote_identity_hash = ? OR lxmf_address = ? OR lxst_address = ?",
(remote_identity_hash, remote_identity_hash, remote_identity_hash),
f"""
SELECT * FROM contacts
WHERE lower(remote_identity_hash) IN ({placeholders})
OR lower(lxmf_address) IN ({placeholders})
OR lower(lxst_address) IN ({placeholders})
""",
tuple(candidates) * 3,
)

View file

@ -171,7 +171,23 @@ class VoicemailManager:
RNS.log("Voicemail: Voicemail is disabled", RNS.LOG_DEBUG)
return
if self.db and self.db.misc.is_destination_blocked(caller_identity.hash.hex()):
caller_hash = caller_identity.hash.hex()
is_blocked = False
if self.db:
try:
if self.db.misc.is_destination_blocked(caller_hash):
is_blocked = True
else:
for ann in self.db.announces.get_announces_by_identity_hash(
caller_hash,
):
dest = ann.get("destination_hash")
if dest and self.db.misc.is_destination_blocked(dest):
is_blocked = True
break
except Exception:
is_blocked = False
if is_blocked:
RNS.log(
f"Voicemail: Caller {RNS.prettyhexrep(caller_identity.hash)} is blocked; skipping auto-answer",
RNS.LOG_DEBUG,

View file

@ -1267,7 +1267,11 @@ export default {
if (this.config?.do_not_disturb_enabled) {
return;
}
if (this.config?.telephone_allow_calls_from_contacts_only && !json.is_contact) {
if (
(this.config?.telephone_allow_calls_from_contacts_only ||
this.config?.block_all_from_strangers) &&
!json.is_contact
) {
return;
}
if (this.initiationStatus) {

View file

@ -2422,6 +2422,8 @@ export default {
contactForm: {
name: "",
remote_identity_hash: "",
lxmf_address: "",
lxst_address: "",
},
searchDebounceTimeout: null,
isVoicemailSettingsExpanded: false,
@ -3585,8 +3587,9 @@ export default {
this.editingContact = null;
this.contactForm = {
name: entry.remote_identity_name || "",
remote_identity_hash:
entry.remote_telephony_hash || entry.remote_destination_hash || entry.remote_identity_hash,
remote_identity_hash: entry.remote_identity_hash || "",
lxmf_address: entry.remote_destination_hash || "",
lxst_address: entry.remote_telephony_hash || "",
preferred_ringtone_id: null,
};
this.isContactModalOpen = true;
@ -4245,22 +4248,32 @@ export default {
}
let hashToCall = identityHash.trim();
// Accept lxmf:// URIs or pasted text; extract first 64-char hex
const hexMatch = hashToCall.match(/[0-9a-fA-F]{64}/);
// Accept lxmf:// URIs or pasted text; RNS truncated hashes are 32 hex chars
const hexMatch = hashToCall.match(/[0-9a-fA-F]{32,64}/);
if (hexMatch) {
hashToCall = hexMatch[0];
hashToCall = hexMatch[0].slice(0, 32);
}
hashToCall = hashToCall.toLowerCase();
// Try to resolve name from contacts
const contact = this.contacts.find((c) => c.name.toLowerCase() === hashToCall.toLowerCase());
if (contact) {
hashToCall = contact.remote_identity_hash;
hashToCall =
contact.remote_identity_hash ||
contact.remote_telephony_hash ||
contact.lxst_address ||
contact.lxmf_address;
}
// Provide immediate feedback
this.destinationHash = hashToCall;
const targetContact = this.contacts.find((c) => c.remote_identity_hash === hashToCall);
const targetContact = this.contacts.find(
(c) =>
c.remote_identity_hash === hashToCall ||
c.lxmf_address === hashToCall ||
c.lxst_address === hashToCall ||
c.remote_telephony_hash === hashToCall
);
this.initiationTargetHash = hashToCall;
this.initiationTargetName = targetContact ? targetContact.name : null;
this.activeTab = "phone";

View file

@ -228,7 +228,8 @@ export default {
// create contact first
await window.api.post("/api/v1/telephone/contacts", {
name: this.peer.display_name,
remote_identity_hash: this.peer.destination_hash,
remote_identity_hash: this.peer.identity_hash || undefined,
lxmf_address: this.peer.destination_hash,
is_telemetry_trusted: true,
});
await this.fetchContact();
@ -239,7 +240,7 @@ export default {
this.contact.is_telemetry_trusted = newStatus;
}
GlobalEmitter.emit("contact-updated", {
remote_identity_hash: this.peer.destination_hash,
remote_identity_hash: this.peer.identity_hash || this.peer.destination_hash,
is_telemetry_trusted: newStatus,
});
DialogUtils.alert(

View file

@ -3143,11 +3143,12 @@ export default {
await window.api.post("/api/v1/telephone/contacts", {
name: displayName,
lxmf_address: hash,
remote_identity_hash: this.selectedPeer.identity_hash || undefined,
});
this.isStrangerPeer = false;
this.strangerBannerDismissed = true;
GlobalEmitter.emit("contact-updated", {
remote_identity_hash: hash,
remote_identity_hash: this.selectedPeer.identity_hash || hash,
});
this.$emit("reload-conversations");
ToastUtils.success(this.$t("contacts.contact_added"));

View file

@ -1275,13 +1275,13 @@ export default {
async toggleTelemetryTrust(hash) {
const contact = this.contextMenu.targetContact;
const newStatus = !contact?.is_telemetry_trusted;
const conv = this.conversations.find((c) => c.destination_hash === hash);
try {
if (!contact) {
// find display name from conversations
const conv = this.conversations.find((c) => c.destination_hash === hash);
await window.api.post("/api/v1/telephone/contacts", {
name: conv?.display_name || hash.substring(0, 8),
remote_identity_hash: hash,
remote_identity_hash: conv?.identity_hash || undefined,
lxmf_address: hash,
is_telemetry_trusted: true,
});
} else {
@ -1290,7 +1290,7 @@ export default {
});
}
GlobalEmitter.emit("contact-updated", {
remote_identity_hash: hash,
remote_identity_hash: conv?.identity_hash || hash,
is_telemetry_trusted: newStatus,
});
this.contextMenu.show = false;

View file

@ -0,0 +1,70 @@
# SPDX-License-Identifier: 0BSD
"""Contact resolution across identity / LXMF / LXST hash forms."""
from unittest.mock import MagicMock
from meshchatx.meshchat import ReticulumMeshChat
IDENTITY = "a1" * 16
LXMF = "b2" * 16
LXST = "c3" * 16
def _app_with_db(contact_row=None, announces=None):
app = ReticulumMeshChat.__new__(ReticulumMeshChat)
ctx = MagicMock()
ctx.database = MagicMock()
ctx.database.announces.get_announce_by_hash.return_value = None
ctx.database.announces.get_announces_by_identity_hash.return_value = announces or []
ctx.database.contacts.get_contact_by_identity_hash.return_value = contact_row
app.current_context = ctx
app.get_lxmf_destination_hash_for_identity_hash = MagicMock(return_value=LXMF)
app.get_lxst_telephony_hash_for_identity_hash = MagicMock(return_value=LXST)
return app
def test_related_hashes_include_announced_and_derived_destinations():
app = _app_with_db(
announces=[
{
"destination_hash": LXMF,
"identity_hash": IDENTITY,
"aspect": "lxmf.delivery",
},
],
)
related = app._related_hashes_for_contact_lookup(IDENTITY)
assert IDENTITY in related
assert LXMF in related
assert LXST in related
def test_is_contact_true_when_saved_under_lxmf_primary_key():
contact = {"id": 1, "name": "Friend", "remote_identity_hash": LXMF}
def lookup(primary, related_hashes=None):
keys = {primary, *(related_hashes or ())}
if LXMF in keys:
return contact
return None
app = _app_with_db()
app.current_context.database.contacts.get_contact_by_identity_hash.side_effect = (
lookup
)
app.current_context.database.announces.get_announces_by_identity_hash.return_value = [
{
"destination_hash": LXMF,
"identity_hash": IDENTITY,
"aspect": "lxmf.delivery",
},
]
assert app._is_contact(IDENTITY) is True
assert app._resolve_contact_for_hash(IDENTITY)["name"] == "Friend"
def test_is_contact_false_for_unknown_peer():
app = _app_with_db(contact_row=None)
assert app._is_contact(IDENTITY) is False

View file

@ -288,6 +288,24 @@ class TestContactsEdgeCases:
assert result is not None
assert result["name"] == "Via LXST"
def test_get_contact_by_identity_hash_case_insensitive(self, contacts_dao):
contacts_dao.add_contact("Case", "AABB" + "cc" * 14)
result = contacts_dao.get_contact_by_identity_hash("aabb" + "CC" * 14)
assert result is not None
assert result["name"] == "Case"
def test_get_contact_by_identity_hash_related_hashes(self, contacts_dao):
identity = "a1" * 16
lxmf = "b2" * 16
contacts_dao.add_contact("SavedAsLxmf", lxmf, lxmf_address=lxmf)
result = contacts_dao.get_contact_by_identity_hash(
identity,
related_hashes=[lxmf],
)
assert result is not None
assert result["name"] == "SavedAsLxmf"
assert result["remote_identity_hash"] == lxmf
def test_delete_nonexistent_contact(self, contacts_dao):
contacts_dao.delete_contact(99999)

View file

@ -9,6 +9,7 @@ import pytest
from meshchatx.meshchat import ReticulumMeshChat
CALLER_HASH_HEX = "a1" * 16
LXMF_DEST_HEX = "b2" * 16
def _caller_identity():
@ -27,10 +28,15 @@ def policy_app():
ctx.telephone_manager = tm
ctx.config = MagicMock()
ctx.database = MagicMock()
ctx.database.announces.get_announce_by_hash.return_value = None
ctx.database.announces.get_announces_by_identity_hash.return_value = []
ctx.voicemail_manager = MagicMock()
app.current_context = ctx
app.is_destination_blocked = MagicMock(return_value=False)
app.websocket_broadcast = MagicMock()
app.get_name_for_identity_hash = MagicMock(return_value="Caller")
app.get_lxmf_destination_hash_for_identity_hash = MagicMock(return_value=None)
app.get_lxst_telephony_hash_for_identity_hash = MagicMock(return_value=None)
return app
@ -56,8 +62,8 @@ def test_incoming_rejects_when_blocked_uses_delayed_hangup(policy_app):
_run_incoming(policy_app, caller)
policy_app.telephone_manager.telephone.hangup.assert_called_once()
policy_app.voicemail_manager.handle_incoming_call.assert_not_called()
policy_app.current_context.telephone_manager.telephone.hangup.assert_called_once()
policy_app.current_context.voicemail_manager.handle_incoming_call.assert_not_called()
def test_incoming_dnd_rejects_before_contact_check(policy_app):
@ -115,9 +121,9 @@ def test_contacts_only_rejects_non_contact_uses_identity_lookup(policy_app):
async_utils.run_async = MagicMock()
_run_incoming(policy_app, caller)
policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called_once_with(
CALLER_HASH_HEX,
)
policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called()
call_kwargs = policy_app.current_context.database.contacts.get_contact_by_identity_hash.call_args
assert call_kwargs[0][0] == CALLER_HASH_HEX
policy_app.current_context.telephone_manager.telephone.hangup.assert_called_once()
policy_app.current_context.voicemail_manager.handle_incoming_call.assert_not_called()
async_utils.run_async.assert_not_called()
@ -139,13 +145,10 @@ def test_contacts_only_accepts_matching_contact(policy_app):
async_utils.run_async = MagicMock()
_run_incoming(policy_app, caller)
# Called twice: once for policy check, once for websocket broadcast is_contact flag
policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called_with(
CALLER_HASH_HEX,
)
policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called()
assert (
policy_app.current_context.database.contacts.get_contact_by_identity_hash.call_count
== 2
>= 1
)
policy_app.current_context.voicemail_manager.handle_incoming_call.assert_called_once_with(
caller
@ -154,6 +157,42 @@ def test_contacts_only_accepts_matching_contact(policy_app):
async_utils.run_async.assert_called_once()
def test_contacts_only_accepts_contact_saved_under_lxmf_destination(policy_app):
"""Chat UI often stores LXMF destination hash as remote_identity_hash."""
policy_app.is_destination_blocked.return_value = False
policy_app.config.do_not_disturb_enabled.get.return_value = False
policy_app.config.telephone_allow_calls_from_contacts_only.get.return_value = True
policy_app.config.block_all_from_strangers.get.return_value = False
policy_app.current_context.database.announces.get_announces_by_identity_hash.return_value = [
{
"destination_hash": LXMF_DEST_HEX,
"identity_hash": CALLER_HASH_HEX,
"aspect": "lxmf.delivery",
},
]
policy_app.get_lxmf_destination_hash_for_identity_hash.return_value = LXMF_DEST_HEX
def lookup(primary, related_hashes=None):
keys = {primary, *(related_hashes or ())}
if LXMF_DEST_HEX in keys or CALLER_HASH_HEX in keys:
return {"id": 1, "name": "Friend", "remote_identity_hash": LXMF_DEST_HEX}
return None
policy_app.current_context.database.contacts.get_contact_by_identity_hash.side_effect = lookup
caller = _caller_identity()
with patch("meshchatx.meshchat.AsyncUtils") as async_utils:
async_utils.run_async = MagicMock()
_run_incoming(policy_app, caller)
policy_app.current_context.voicemail_manager.handle_incoming_call.assert_called_once_with(
caller
)
policy_app.current_context.telephone_manager.telephone.hangup.assert_not_called()
async_utils.run_async.assert_called_once()
def test_block_all_strangers_uses_same_contact_gate(policy_app):
policy_app.is_destination_blocked.return_value = False
policy_app.config.do_not_disturb_enabled.get.return_value = False
@ -177,9 +216,7 @@ def test_block_all_strangers_uses_same_contact_gate(policy_app):
async_utils.run_async = MagicMock()
_run_incoming(policy_app, caller)
policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called_once_with(
CALLER_HASH_HEX,
)
policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called()
policy_app.current_context.voicemail_manager.handle_incoming_call.assert_not_called()
@ -188,6 +225,7 @@ def test_when_policy_off_stranger_rings(policy_app):
policy_app.config.do_not_disturb_enabled.get.return_value = False
policy_app.config.telephone_allow_calls_from_contacts_only.get.return_value = False
policy_app.config.block_all_from_strangers.get.return_value = False
policy_app.current_context.database.contacts.get_contact_by_identity_hash.return_value = None
caller = _caller_identity()
@ -195,24 +233,32 @@ def test_when_policy_off_stranger_rings(policy_app):
async_utils.run_async = MagicMock()
_run_incoming(policy_app, caller)
# Called once for websocket broadcast is_contact flag even when policy is off
policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called_once_with(
CALLER_HASH_HEX,
)
policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_called()
policy_app.current_context.voicemail_manager.handle_incoming_call.assert_called_once_with(
caller
)
async_utils.run_async.assert_called_once()
def test_ignores_incoming_while_outgoing_initiation(policy_app):
def test_rejects_incoming_while_outgoing_initiation(policy_app):
policy_app.current_context.telephone_manager.initiation_status = "Dialing..."
caller = _caller_identity()
with patch("meshchatx.meshchat.AsyncUtils") as async_utils:
async_utils.run_async = MagicMock()
_run_incoming(policy_app, caller)
with patch("meshchatx.meshchat.threading.Timer") as mock_timer:
def run_timer(delay, fn):
fn()
t = MagicMock()
t.start = MagicMock()
return t
mock_timer.side_effect = run_timer
with patch("meshchatx.meshchat.AsyncUtils") as async_utils:
async_utils.run_async = MagicMock()
_run_incoming(policy_app, caller)
policy_app.current_context.telephone_manager.telephone.hangup.assert_called_once()
policy_app.current_context.voicemail_manager.handle_incoming_call.assert_not_called()
async_utils.run_async.assert_not_called()
@ -224,14 +270,14 @@ def test_uses_passed_context_not_app_current_context(policy_app):
policy_app.config.telephone_allow_calls_from_contacts_only.get.return_value = True
policy_app.config.block_all_from_strangers.get.return_value = False
# current_context has no contact
policy_app.current_context.database.contacts.get_contact_by_identity_hash.return_value = None
# But a different ctx passed to the method DOES have the contact
other_ctx = MagicMock()
other_ctx.telephone_manager = policy_app.current_context.telephone_manager
other_ctx.config = policy_app.config
other_ctx.database = MagicMock()
other_ctx.database.announces.get_announce_by_hash.return_value = None
other_ctx.database.announces.get_announces_by_identity_hash.return_value = []
other_ctx.database.contacts.get_contact_by_identity_hash.return_value = {
"id": 1,
"name": "Friend",
@ -244,10 +290,6 @@ def test_uses_passed_context_not_app_current_context(policy_app):
async_utils.run_async = MagicMock()
_run_incoming(policy_app, caller, ctx=other_ctx)
# Called twice: once for policy check, once for websocket broadcast is_contact flag
other_ctx.database.contacts.get_contact_by_identity_hash.assert_called_with(
CALLER_HASH_HEX,
)
assert other_ctx.database.contacts.get_contact_by_identity_hash.call_count == 2
other_ctx.database.contacts.get_contact_by_identity_hash.assert_called()
policy_app.current_context.database.contacts.get_contact_by_identity_hash.assert_not_called()
other_ctx.voicemail_manager.handle_incoming_call.assert_called_once_with(caller)

View file

@ -201,6 +201,27 @@ def test_voicemail_session_sets_active_flag(mock_deps, temp_dir):
assert mock_tel_manager.is_voicemail_session_active is True
def test_handle_incoming_skips_when_lxmf_destination_blocked(mock_deps, temp_dir):
mock_db = MagicMock()
mock_config = MagicMock()
mock_config.voicemail_enabled.get.return_value = True
mock_tel_manager = MagicMock()
vm = VoicemailManager(mock_db, mock_config, mock_tel_manager, temp_dir)
caller_hash = "a1" * 16
lxmf_dest = "b2" * 16
mock_caller = MagicMock()
mock_caller.hash = bytes.fromhex(caller_hash)
mock_db.misc.is_destination_blocked.side_effect = lambda h: h == lxmf_dest
mock_db.announces.get_announces_by_identity_hash.return_value = [
{"destination_hash": lxmf_dest, "identity_hash": caller_hash},
]
with patch("threading.Thread") as mock_thread:
vm.handle_incoming_call(mock_caller)
mock_thread.assert_not_called()
def test_stop_recording_clears_active_flag(mock_deps, temp_dir):
mock_db = MagicMock()
mock_config = MagicMock()

View file

@ -260,6 +260,28 @@ describe("CallPage.vue", () => {
});
});
it("call extracts 32-char RNS hash from pasted text", async () => {
const wrapper = mountCallPage();
await wrapper.vm.$nextTick();
const hash32 = "ab".repeat(16);
await wrapper.vm.call(`Call me at ${hash32} please`);
expect(axiosMock.get).toHaveBeenCalledWith(`/api/v1/telephone/call/${hash32}`);
});
it("addContactFromHistory prefers identity hash over destination hashes", async () => {
const wrapper = mountCallPage();
await wrapper.vm.$nextTick();
await wrapper.vm.addContactFromHistory({
remote_identity_name: "Sam",
remote_identity_hash: "aa".repeat(16),
remote_destination_hash: "bb".repeat(16),
remote_telephony_hash: "cc".repeat(16),
});
expect(wrapper.vm.contactForm.remote_identity_hash).toBe("aa".repeat(16));
expect(wrapper.vm.contactForm.lxmf_address).toBe("bb".repeat(16));
expect(wrapper.vm.contactForm.lxst_address).toBe("cc".repeat(16));
});
it("toggleTelephoneAnnounceEnabled patches config", async () => {
const wrapper = mountCallPage();
await wrapper.vm.$nextTick();