From 8121de96d760c376582728fd547705ec14465f55 Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Sun, 9 Nov 2025 13:58:40 +0100 Subject: [PATCH] Added simple call log to voice call screen --- sbapp/patches/PythonActivity.java | 2 +- sbapp/sideband/core.py | 41 +++--- sbapp/sideband/voice.py | 74 ++++++++++- sbapp/ui/helpers.py | 1 + sbapp/ui/voice.py | 203 ++++++++++++++++++++++++------ 5 files changed, 264 insertions(+), 57 deletions(-) diff --git a/sbapp/patches/PythonActivity.java b/sbapp/patches/PythonActivity.java index 649a302..41b9460 100644 --- a/sbapp/patches/PythonActivity.java +++ b/sbapp/patches/PythonActivity.java @@ -60,7 +60,7 @@ public class PythonActivity extends SDLActivity { @Override protected void onCreate(Bundle savedInstanceState) { try { this.startIntent = getIntent(); } - catch { Log.e(TAG, "Failed to get pending intent on activity create"); } + catch (Exception e) { Log.e(TAG, "Failed to get pending intent on activity create"); } Log.v(TAG, "PythonActivity onCreate running"); resourceManager = new ResourceManager(this); diff --git a/sbapp/sideband/core.py b/sbapp/sideband/core.py index 59cb1da..38d7feb 100644 --- a/sbapp/sideband/core.py +++ b/sbapp/sideband/core.py @@ -2138,20 +2138,21 @@ class SidebandCore(): elif "get_service_log" in call: connection.send(self.get_service_log()) elif "start_voice" in call: connection.send(self.start_voice()) elif "stop_voice" in call: connection.send(self.stop_voice()) - elif "telephone_is_available" in call: connection.send(self.telephone.is_available) if self.telephone else False - elif "telephone_is_in_call" in call: connection.send(self.telephone.is_in_call) if self.telephone else False - elif "telephone_call_is_connecting" in call: connection.send(self.telephone.call_is_connecting) if self.telephone else False - elif "telephone_is_ringing" in call: connection.send(self.telephone.is_ringing) if self.telephone else False - elif "telephone_caller_info" in call: connection.send(self.telephone.caller.hash) if self.telephone and self.telephone.caller else None - elif "telephone_set_busy" in call: connection.send(self.telephone.set_busy(call["telephone_set_busy"])) if self.telephone else False - elif "telephone_dial" in call: connection.send(self.telephone.dial(call["telephone_dial"])) if self.telephone else False - elif "telephone_hangup" in call: connection.send(self.telephone.hangup()) if self.telephone else False - elif "telephone_answer" in call: connection.send(self.telephone.answer()) if self.telephone else False - elif "telephone_set_speaker" in call: connection.send(self.telephone.set_speaker(call["telephone_set_speaker"])) if self.telephone else False - elif "telephone_set_microphone" in call: connection.send(self.telephone.set_microphone(call["telephone_set_microphone"])) if self.telephone else False - elif "telephone_set_ringer" in call: connection.send(self.telephone.set_ringer(call["telephone_set_ringer"])) if self.telephone else False - elif "telephone_set_low_latency_output" in call: connection.send(self.telephone.set_low_latency_output(call["telephone_set_low_latency_output"])) if self.telephone else False - elif "telephone_announce" in call: connection.send(self.telephone.announce()) if self.telephone else False + elif "telephone_is_available" in call: connection.send(self.telephone.is_available if self.telephone else False) + elif "telephone_is_in_call" in call: connection.send(self.telephone.is_in_call if self.telephone else False) + elif "telephone_call_is_connecting" in call: connection.send(self.telephone.call_is_connecting if self.telephone else False) + elif "telephone_is_ringing" in call: connection.send(self.telephone.is_ringing if self.telephone else False) + elif "telephone_caller_info" in call: connection.send(self.telephone.caller.hash if self.telephone and self.telephone.caller else None) + elif "telephone_set_busy" in call: connection.send(self.telephone.set_busy(call["telephone_set_busy"]) if self.telephone else False) + elif "telephone_dial" in call: connection.send(self.telephone.dial(call["telephone_dial"]) if self.telephone else False) + elif "telephone_hangup" in call: connection.send(self.telephone.hangup() if self.telephone else False) + elif "telephone_answer" in call: connection.send(self.telephone.answer() if self.telephone else False) + elif "telephone_set_speaker" in call: connection.send(self.telephone.set_speaker(call["telephone_set_speaker"]) if self.telephone else False) + elif "telephone_set_microphone" in call: connection.send(self.telephone.set_microphone(call["telephone_set_microphone"]) if self.telephone else False) + elif "telephone_set_ringer" in call: connection.send(self.telephone.set_ringer(call["telephone_set_ringer"]) if self.telephone else False) + elif "telephone_set_low_latency_output" in call: connection.send(self.telephone.set_low_latency_output(call["telephone_set_low_latency_output"]) if self.telephone else False) + elif "telephone_announce" in call: connection.send(self.telephone.announce() if self.telephone else False) + elif "telephone_get_call_log" in call: connection.send(self.telephone.get_call_log() if self.telephone else []) else: connection.send(None) @@ -5518,9 +5519,10 @@ class SidebandCore(): RNS.log("Starting voice service", RNS.LOG_DEBUG) self.voice_running = True self.setstate("voice.running", self.voice_running) - from .voice import ReticulumTelephone - self.telephone = ReticulumTelephone(self.identity, owner=self, speaker=self.config["voice_output"], microphone=self.config["voice_input"], ringer=self.config["voice_ringer"]) ringtone_path = os.path.join(self.asset_dir, "audio", "notifications", "soft1.opus") + call_log_path = os.path.join(self.app_dir, "app_storage", "lxst_call_log") + from .voice import ReticulumTelephone + self.telephone = ReticulumTelephone(self.identity, owner=self, speaker=self.config["voice_output"], microphone=self.config["voice_input"], ringer=self.config["voice_ringer"], logpath=call_log_path) self.telephone.set_ringtone(ringtone_path) self.telephone.set_low_latency_output(self.config["voice_low_latency"]) return True @@ -5584,6 +5586,13 @@ class SidebandCore(): if self.gui_foreground(): RNS.log("Squelching call notification since GUI is in foreground", RNS.LOG_DEBUG) else: self.notify(title="Incoming voice call", content=f"From {display_name}", group="LXST.Telephony", context_id="incoming_call") + def missed_call(self, remote_identity): + display_name = self.voice_display_name(remote_identity.hash) + self.setstate("voice.incoming_call", display_name) + # if self.gui_foreground(): RNS.log("Squelching call notification since GUI is in foreground", RNS.LOG_DEBUG) + # else: self.notify(title="Missed voice call", content=f"From {display_name}", group="LXST.Telephony", context_id="incoming_call") + self.notify(title="Missed voice call", content=f"From {display_name}", group="LXST.Telephony", context_id="incoming_call") + rns_config = """# This template is used to generate a # running configuration for Sideband's # internal RNS instance. Incorrect changes diff --git a/sbapp/sideband/voice.py b/sbapp/sideband/voice.py index 72ad879..f40f135 100644 --- a/sbapp/sideband/voice.py +++ b/sbapp/sideband/voice.py @@ -6,6 +6,7 @@ import time from LXST._version import __version__ from LXST.Primitives.Telephony import Telephone from RNS.vendor.configobj import ConfigObj +import RNS.vendor.umsgpack as msgpack class ReticulumTelephone(): STATE_AVAILABLE = 0x00 @@ -22,7 +23,9 @@ class ReticulumTelephone(): WAIT_TIME = 60 PATH_TIME = 10 - def __init__(self, identity, owner = None, service = False, speaker=None, microphone=None, ringer=None): + CALL_LOG_KEEP = 30*24*60*60 + + def __init__(self, identity, owner = None, service = False, speaker=None, microphone=None, ringer=None, logpath=None): self.identity = identity self.service = service self.owner = owner @@ -40,6 +43,8 @@ class ReticulumTelephone(): self.speaker_device = speaker self.microphone_device = microphone self.ringer_device = ringer + self.logpath = logpath + self.call_log = None self.phonebook = {} self.aliases = {} self.names = {} @@ -113,6 +118,54 @@ class ReticulumTelephone(): def set_busy(self, busy): self.telephone.set_busy(busy) def set_low_latency_output(self, enabled): self.telephone.set_low_latency_output(enabled) + def get_call_log(self): + if self.call_log: return self.call_log + else: + call_log = [] + try: + if os.path.isfile(self.logpath): + with open(self.logpath, "rb") as logfile: + read_call_log = msgpack.unpackb(logfile.read()) + + for entry in read_call_log: + age = time.time()-entry["time"] + if age < self.CALL_LOG_KEEP: call_log.append(entry) + + except Exception as e: RNS.log(f"Could not read call log file: {e}", RNS.LOG_ERROR) + self.call_log = call_log + return self.call_log + + def log_call(self, event, identity): + RNS.log(f"Logging call event {event} for {RNS.prettyhexrep(identity.hash)}", RNS.LOG_DEBUG) + if self.logpath: + try: + if not os.path.isfile(self.logpath): + try: + with open(self.logpath, "wb") as logfile: logfile.write(msgpack.packb([])) + except Exception as e: raise OSError("Could not create call log file") + + call_log = [] + read_call_log = [] + try: + with open(self.logpath, "rb") as logfile: read_call_log = msgpack.unpackb(logfile.read()) + except Exception as e: + RNS.log(f"Error while reading call log file: {e}", RNS.LOG_ERROR) + RNS.log(f"Call log file will be re-created", RNS.LOG_ERROR) + + for entry in read_call_log: + age = time.time()-entry["time"] + if age < self.CALL_LOG_KEEP: call_log.append(entry) + + entry = {"time": time.time(), "event": event, "identity": identity.hash} + call_log.append(entry) + + with open(self.logpath, "wb") as logfile: logfile.write(msgpack.packb(call_log)) + self.call_log = call_log + + except Exception as e: + RNS.log(f"An error occurred while updating call log: {e}", RNS.LOG_ERROR) + RNS.trace_exception(e) + def dial(self, identity_hash): self.last_dialled_identity_hash = identity_hash destination_hash = RNS.Destination.hash_from_name_and_identity("lxst.telephony", identity_hash) @@ -145,6 +198,10 @@ class ReticulumTelephone(): self.owner.incoming_call(remote_identity) def call_ended(self, remote_identity): + call_was_connecting = self.call_is_connecting + was_ringing = self.is_ringing + was_in_call = self.is_in_call + if self.is_in_call or self.is_ringing or self.call_is_connecting: if self.is_in_call: RNS.log(f"Call with {RNS.prettyhexrep(self.caller.hash)} ended\n", RNS.LOG_DEBUG) if self.is_ringing: RNS.log(f"Call {self.direction} {RNS.prettyhexrep(self.caller.hash)} was not answered\n", RNS.LOG_DEBUG) @@ -152,11 +209,23 @@ class ReticulumTelephone(): self.direction = None self.state = self.STATE_AVAILABLE + if call_was_connecting: self.log_call("outgoing-failure", remote_identity) + elif was_in_call: self.log_call("ongoing-ended", remote_identity) + elif was_ringing: + self.log_call("incoming-missed", remote_identity) + self.owner.missed_call(remote_identity) + def call_established(self, remote_identity): + call_was_connecting = self.call_is_connecting + was_ringing = self.is_ringing + if self.call_is_connecting or self.is_ringing: self.state = self.STATE_IN_CALL RNS.log(f"Call established with {RNS.prettyhexrep(self.caller.hash)}", RNS.LOG_DEBUG) + if call_was_connecting: self.log_call("outgoing-success", remote_identity) + elif was_ringing: self.log_call("incoming-success", remote_identity) + def __is_allowed(self, identity_hash): if self.owner.config["voice_trusted_only"]: return self.owner.voice_is_trusted(identity_hash) @@ -202,4 +271,5 @@ class ReticulumTelephoneProxy(): def set_microphone(self, microphone): return self.owner.service_rpc_request({"telephone_set_microphone": microphone }) def set_ringer(self, ringer): return self.owner.service_rpc_request({"telephone_set_ringer": ringer }) def set_low_latency_output(self, enabled): return self.owner.service_rpc_request({"telephone_set_low_latency_output": enabled}) - def announce(self): return self.owner.service_rpc_request({"telephone_announce": True}) \ No newline at end of file + def announce(self): return self.owner.service_rpc_request({"telephone_announce": True}) + def get_call_log(self): return self.owner.service_rpc_request({"telephone_get_call_log": True}) \ No newline at end of file diff --git a/sbapp/ui/helpers.py b/sbapp/ui/helpers.py index 5b43278..d8c0aaa 100644 --- a/sbapp/ui/helpers.py +++ b/sbapp/ui/helpers.py @@ -6,6 +6,7 @@ from kivymd.uix.list import OneLineIconListItem, MDList, IconLeftWidget, IconRig from kivy.properties import StringProperty import re +ts_format_date = "%Y-%m-%d" ts_format = "%Y-%m-%d %H:%M:%S" file_ts_format = "%Y_%m_%d_%H_%M_%S" diff --git a/sbapp/ui/voice.py b/sbapp/ui/voice.py index b0f8e9a..ff604bc 100644 --- a/sbapp/ui/voice.py +++ b/sbapp/ui/voice.py @@ -12,6 +12,8 @@ from kivymd.uix.pickers import MDColorPicker from kivymd.uix.button import MDRectangleFlatButton from kivymd.uix.button import MDRectangleFlatIconButton from kivymd.uix.dialog import MDDialog +from kivy.properties import StringProperty, BooleanProperty, OptionProperty, ColorProperty, Property +from kivymd.uix.list import MDList, IconLeftWidget, IconRightWidget, OneLineAvatarIconListItem from kivymd.icon_definitions import md_icons from kivymd.toast import toast from kivy.properties import StringProperty, BooleanProperty @@ -22,10 +24,16 @@ import threading from datetime import datetime if RNS.vendor.platformutils.get_platform() == "android": - from ui.helpers import ts_format + from ui.helpers import ts_format_date from android.permissions import request_permissions, check_permission else: - from .helpers import ts_format + from .helpers import ts_format_date + +from kivy.utils import escape_markup +if RNS.vendor.platformutils.get_platform() == "android": + from ui.helpers import multilingual_markup +else: + from .helpers import multilingual_markup class Voice(): def __init__(self, app): @@ -37,6 +45,9 @@ class Voice(): self.path_requesting = None self.output_devices = [] self.input_devices = [] + self.log_list = None + self.last_log_update = 0 + self.log_name_cache = {} self.listed_output_devices = [] self.listed_input_devices = [] self.listed_ringer_devices = [] @@ -46,9 +57,8 @@ class Voice(): self.screen.app = self.app self.screen.delegate = self self.app.root.ids.screen_manager.add_widget(self.screen) + self.update_call_log() - self.screen.ids.voice_scrollview.effect_cls = ScrollEffect - def update_call_status(self, dt=None): if self.app.root.ids.screen_manager.current == "voice_screen": if self.ui_updater == None: self.ui_updater = Clock.schedule_interval(self.update_call_status, 0.5) @@ -89,6 +99,8 @@ class Voice(): db.disabled = True; db.text = "Voice calls disabled" ih.disabled = True + if time.time() > self.last_log_update+3: self.update_call_log() + def target_valid(self): if self.app.sideband.voice_running: db = self.screen.ids.dial_button @@ -170,7 +182,7 @@ class Voice(): self.update_call_status() - ### settings screen + ### Settings screen ###################################### def settings_action(self, sender=None): @@ -297,6 +309,116 @@ class Voice(): self.app.sideband.telephone.set_ringer(self.app.sideband.config["voice_ringer"]) + ### Call log + ###################################### + + def update_call_log(self): + if self.log_list == None: + self.log_list = CallList() + self.screen.ids.log_list_container.add_widget(self.log_list) + + self.update_log_list() + self.last_log_update = time.time() + + def update_log_list(self): + call_log = self.app.sideband.telephone.get_call_log() + call_log.sort(key=lambda e: e["time"], reverse=True) + data = [] + for entry in call_log: + try: + at = entry["time"] + td = int(time.time())-int(at) + evt = entry["event"] + idnt = entry["identity"] + + if not idnt in self.log_name_cache: self.log_name_cache[idnt] = self.app.sideband.voice_display_name(idnt) + name = multilingual_markup(escape_markup(str(self.log_name_cache[idnt])).encode("utf-8")).decode("utf-8") + + icon = None + if evt == "incoming-missed": icon = "phone-missed" + elif evt == "outgoing-failure": icon = "phone-cancel" + elif evt == "incoming-success": icon = "phone-incoming" + elif evt == "outgoing-success": icon = "phone-outgoing" + + time_str = None + if td < 60: time_str = "Just now" + elif td < 60*60: td = int((td//60)*60) + elif td < 60*60*24: td = int((td//60)*60) + elif td < 60*60*24*7: td = int((td//(60*60*24))*(60*60*24)) + else: time_str = time.strftime(ts_format_date, time.localtime(at)) + + if time_str == None: time_str = f"{RNS.prettytime(td)} ago" + + if icon: + info = f"{name} • [i]{time_str}[/i]" + entry = {"icon": icon, "text": f"{info}"} + data.append(entry) + + except Exception as e: + RNS.log(f"An error occurred while updating the call log list: {e}", RNS.LOG_ERROR) + RNS.trace_exception(e) + + self.log_list.data = data + +class LogEntry(OneLineAvatarIconListItem): + app = None + owner_screen = None + conversation_dropdown = None + voice_dropdown = None + clear_dialog = None + clear_telemetry_dialog = None + delete_dialog = None + + icon = StringProperty() + # ti_color = OptionProperty(None, options=theme_text_color_options) + # icon_fg = Property(None, allownone=True) + # icon_bg = Property(None, allownone=True) + + def __init__(self): + super().__init__() + # self.bind(on_release=self.app.conversation_action) + # self.ids.left_icon.bind(on_release=self.left_icon_action) + # self.ids.right_icon.bind(on_release=self.right_icon_action) + + def left_icon_action(self, sender): + pass + + def right_icon_action(self, sender): + pass + +class CallList(MDRecycleView): + def __init__(self): + super().__init__() + self.data = [] + +Builder.load_string(""" + + IconLeftWidget: + id: left_icon + # theme_icon_color: root.ti_color + # icon_color: root.icon_fg + # md_bg_color: root.icon_bg + icon: root.icon + _default_icon_pad: dp(14) + icon_size: dp(24) + + # IconRightWidget: + # id: right_icon + # icon: "dots-vertical" + +: + id: calls_scrollview + viewclass: "LogEntry" + effect_cls: "ScrollEffect" + + RecycleBoxLayout: + default_size: None, dp(57) + default_size_hint: 1, None + size_hint_y: None + height: self.minimum_height + orientation: "vertical" +""") + layout_voice_screen = """ MDScreen: name: "voice_screen" @@ -316,48 +438,53 @@ MDScreen: ['close', lambda x: root.app.close_any_action(self)], ] - ScrollView: - id: voice_scrollview + MDBoxLayout: + orientation: "vertical" + size_hint_y: None + height: self.minimum_height + padding: [dp(28), dp(32), dp(28), dp(16)] MDBoxLayout: orientation: "vertical" + # spacing: "24dp" size_hint_y: None height: self.minimum_height - padding: [dp(28), dp(32), dp(28), dp(16)] + padding: [dp(0), dp(12), dp(0), dp(0)] - MDBoxLayout: - orientation: "vertical" - # spacing: "24dp" - size_hint_y: None - height: self.minimum_height - padding: [dp(0), dp(12), dp(0), dp(0)] + MDTextField: + id: identity_hash + hint_text: "Identity hash" + mode: "rectangle" + # size_hint: [1.0, None] + pos_hint: {"center_x": .5} + max_text_length: 32 + on_text: root.delegate.target_input_action(self) - MDTextField: - id: identity_hash - hint_text: "Identity hash" - mode: "rectangle" - # size_hint: [1.0, None] - pos_hint: {"center_x": .5} - max_text_length: 32 - on_text: root.delegate.target_input_action(self) + MDBoxLayout: + orientation: "vertical" + spacing: "24dp" + size_hint_y: None + height: self.minimum_height + padding: [dp(0), dp(35), dp(0), dp(35)] - MDBoxLayout: - orientation: "vertical" - spacing: "24dp" - size_hint_y: None - height: self.minimum_height - padding: [dp(0), dp(35), dp(0), dp(35)] + MDRectangleFlatIconButton: + id: dial_button + icon: "phone-outgoing" + text: "Call" + padding: [dp(0), dp(14), dp(0), dp(14)] + icon_size: dp(24) + font_size: dp(16) + size_hint: [1.0, None] + on_release: root.delegate.dial_action(self) + disabled: True - MDRectangleFlatIconButton: - id: dial_button - icon: "phone-outgoing" - text: "Call" - padding: [dp(0), dp(14), dp(0), dp(14)] - icon_size: dp(24) - font_size: dp(16) - size_hint: [1.0, None] - on_release: root.delegate.dial_action(self) - disabled: True + MDSeparator: + orientation: "horizontal" + height: dp(1) + + MDBoxLayout: + orientation: "vertical" + id: log_list_container """ layout_voice_settings_screen = """