Added basic LXST voice call UI

This commit is contained in:
Mark Qvist 2025-03-09 18:32:31 +01:00
parent a0a03c9eba
commit 143f440df7
5 changed files with 298 additions and 79 deletions

View File

@ -1,6 +1,6 @@
__debug_build__ = False
__disable_shaders__ = False
__version__ = "1.4.0"
__version__ = "1.5.0"
__variant__ = ""
import sys
@ -1597,13 +1597,17 @@ class SidebandApp(MDApp):
self.conversation_action(item)
def conversation_action(self, sender):
context_dest = sender.sb_uid
def cb(dt):
self.open_conversation(context_dest)
def cbu(dt):
self.conversations_view.update()
Clock.schedule_once(cb, 0.15)
Clock.schedule_once(cbu, 0.15+0.25)
if sender.conv_type == self.sideband.CONV_P2P:
context_dest = sender.sb_uid
def cb(dt): self.open_conversation(context_dest)
def cbu(dt): self.conversations_view.update()
Clock.schedule_once(cb, 0.15)
Clock.schedule_once(cbu, 0.15+0.25)
elif sender.conv_type == self.sideband.CONV_VOICE:
identity_hash = sender.sb_uid
def cb(dt): self.dial_action(identity_hash)
Clock.schedule_once(cb, 0.15)
def open_conversation(self, context_dest, direction="left"):
self.rec_dialog_is_open = False
@ -2750,7 +2754,8 @@ class SidebandApp(MDApp):
n_address = dialog.d_content.ids["n_address_field"].text
n_name = dialog.d_content.ids["n_name_field"].text
n_trusted = dialog.d_content.ids["n_trusted"].active
new_result = self.sideband.new_conversation(n_address, n_name, n_trusted)
n_voice_only = dialog.d_content.ids["n_voice_only"].active
new_result = self.sideband.new_conversation(n_address, n_name, n_trusted, n_voice_only)
except Exception as e:
RNS.log("Error while creating conversation: "+str(e), RNS.LOG_ERROR)
@ -5257,7 +5262,7 @@ class SidebandApp(MDApp):
self.voice_screen = Voice(self)
self.voice_ready = True
def voice_open(self, sender=None, direction="left", no_transition=False):
def voice_open(self, sender=None, direction="left", no_transition=False, dial_on_complete=None):
if no_transition:
self.root.ids.screen_manager.transition = self.no_transition
else:
@ -5271,21 +5276,29 @@ class SidebandApp(MDApp):
if no_transition:
self.root.ids.screen_manager.transition = self.slide_transition
def voice_action(self, sender=None, direction="left"):
self.voice_screen.update_call_status()
if dial_on_complete:
self.voice_screen.dial_target = dial_on_complete
self.voice_screen.screen.ids.identity_hash.text = RNS.hexrep(dial_on_complete, delimit=False)
Clock.schedule_once(self.voice_screen.dial_action, 0.25)
def voice_action(self, sender=None, direction="left", dial_on_complete=None):
if self.voice_ready:
self.voice_open(direction=direction)
self.voice_open(direction=direction, dial_on_complete=dial_on_complete)
else:
self.loader_action(direction=direction)
def final(dt):
self.voice_init()
def o(dt):
self.voice_open(no_transition=True)
self.voice_open(no_transition=True, dial_on_complete=dial_on_complete)
Clock.schedule_once(o, ll_ot)
Clock.schedule_once(final, ll_ft)
def close_sub_voice_action(self, sender=None):
self.voice_action(direction="right")
def dial_action(self, identity_hash):
self.voice_action(dial_on_complete=identity_hash)
### Telemetry Screen
######################################

View File

@ -107,6 +107,7 @@ class SidebandCore():
CONV_P2P = 0x01
CONV_GROUP = 0x02
CONV_BROADCAST = 0x03
CONV_VOICE = 0x04
MAX_ANNOUNCES = 24
@ -2639,6 +2640,7 @@ class SidebandCore():
"last_rx": last_rx,
"last_tx": last_tx,
"last_activity": last_activity,
"type": entry[4],
"trust": entry[5],
"data": data,
}
@ -2790,6 +2792,27 @@ class SidebandCore():
self.__event_conversations_changed()
def _db_create_voice_object(self, identity_hash, name = None, trust = False):
RNS.log("Creating voice object for "+RNS.prettyhexrep(identity_hash), RNS.LOG_DEBUG)
with self.db_lock:
db = self.__db_connect()
dbc = db.cursor()
def_name = "".encode("utf-8")
query = "INSERT INTO conv (dest_context, last_tx, last_rx, unread, type, trust, name, data) values (?, ?, ?, ?, ?, ?, ?, ?)"
data = (identity_hash, 0, time.time(), 0, SidebandCore.CONV_VOICE, 0, def_name, msgpack.packb(None))
dbc.execute(query, data)
db.commit()
if trust:
self._db_conversation_set_trusted(identity_hash, True)
if name != None and name != "":
self._db_conversation_set_name(identity_hash, name)
self.__event_conversations_changed()
def _db_delete_message(self, msg_hash):
RNS.log("Deleting message "+RNS.prettyhexrep(msg_hash))
with self.db_lock:
@ -4630,7 +4653,7 @@ class SidebandCore():
RNS.log("Error while sending message: "+str(e), RNS.LOG_ERROR)
return False
def new_conversation(self, dest_str, name = "", trusted = False):
def new_conversation(self, dest_str, name = "", trusted = False, voice_only = False):
if len(dest_str) != RNS.Reticulum.TRUNCATED_HASHLENGTH//8*2:
return False
@ -4640,7 +4663,8 @@ class SidebandCore():
RNS.log("Cannot create conversation with own LXMF address", RNS.LOG_ERROR)
return False
else:
self._db_create_conversation(addr_b, name, trusted)
if not voice_only: self._db_create_conversation(addr_b, name, trusted)
else: self._db_create_voice_object(addr_b, name, trusted)
except Exception as e:
RNS.log("Error while creating conversation: "+str(e), RNS.LOG_ERROR)

View File

@ -92,18 +92,13 @@ class ReticulumTelephone():
self.telephone.teardown()
self.telephone = None
def hangup(self): self.telephone.hangup()
def answer(self): self.telephone.answer(self.caller)
def set_busy(self, busy): self.telephone.set_busy(busy)
def dial(self, identity_hash):
self.last_dialled_identity_hash = identity_hash
self.telephone.set_busy(True)
identity_hash = bytes.fromhex(identity_hash)
destination_hash = RNS.Destination.hash_from_name_and_identity("lxst.telephony", identity_hash)
if not RNS.Transport.has_path(destination_hash):
RNS.Transport.request_path(destination_hash)
def spincheck(): return RNS.Transport.has_path(destination_hash)
self.__spin(spincheck, "Requesting path for call to "+RNS.prettyhexrep(identity_hash), self.path_time)
if not spincheck(): RNS.log("Path request timed out", RNS.LOG_DEBUG)
self.telephone.set_busy(False)
if RNS.Transport.has_path(destination_hash):
call_hops = RNS.Transport.hops_to(destination_hash)
cs = "" if call_hops == 1 else "s"
@ -111,7 +106,7 @@ class ReticulumTelephone():
identity = RNS.Identity.recall(destination_hash)
self.call(identity)
else:
pass
return "no_path"
def redial(self, args=None):
if self.last_dialled_identity_hash: self.dial(self.last_dialled_identity_hash)

View File

@ -6,6 +6,7 @@ from kivy.uix.boxlayout import BoxLayout
from kivy.properties import StringProperty, BooleanProperty
from kivymd.uix.list import MDList, IconLeftWidget, IconRightWidget, OneLineAvatarIconListItem
from kivymd.uix.menu import MDDropdownMenu
from kivymd.toast import toast
from kivy.uix.gridlayout import GridLayout
from kivy.uix.boxlayout import BoxLayout
from kivy.clock import Clock
@ -53,6 +54,7 @@ class Conversations():
self.app.root.ids.screen_manager.add_widget(self.screen)
self.conversation_dropdown = None
self.voice_dropdown = None
self.delete_dialog = None
self.clear_dialog = None
self.clear_telemetry_dialog = None
@ -91,6 +93,7 @@ class Conversations():
self.app.sideband.setstate("wants.viewupdate.conversations", False)
def trust_icon(self, conv):
conv_type = conv["type"]
context_dest = conv["dest"]
unread = conv["unread"]
appearance = self.app.sideband.peer_appearance(context_dest, conv=conv)
@ -106,25 +109,28 @@ class Conversations():
trust_icon = appearance[0] or da[0];
else:
if self.app.sideband.requests_allowed_from(context_dest):
if unread:
if is_trusted:
trust_icon = "email-seal"
else:
trust_icon = "email"
else:
trust_icon = "account-lock-open"
if conv_type == self.app.sideband.CONV_VOICE:
trust_icon = "phone"
else:
if is_trusted:
if self.app.sideband.requests_allowed_from(context_dest):
if unread:
trust_icon = "email-seal"
if is_trusted:
trust_icon = "email-seal"
else:
trust_icon = "email"
else:
trust_icon = "account-check"
trust_icon = "account-lock-open"
else:
if unread:
trust_icon = "email"
if is_trusted:
if unread:
trust_icon = "email-seal"
else:
trust_icon = "account-check"
else:
trust_icon = "account-question"
if unread:
trust_icon = "email"
else:
trust_icon = "account-question"
return trust_icon
@ -166,6 +172,7 @@ class Conversations():
iconl._default_icon_pad = dp(ic_p)
iconl.icon_size = dp(ic_s)
iconl.conv_type = conv["type"]
return iconl
@ -187,6 +194,7 @@ class Conversations():
for conv in self.context_dests:
context_dest = conv["dest"]
conv_type = conv["type"]
unread = conv["unread"]
last_activity = conv["last_activity"]
@ -203,6 +211,7 @@ class Conversations():
item.sb_uid = context_dest
item.sb_unread = unread
iconl.sb_uid = context_dest
item.conv_type = conv_type
def gen_edit(item):
def x():
@ -366,23 +375,58 @@ class Conversations():
self.delete_dialog.open()
return x
# def gen_move_to(item):
# def x():
# item.dmenu.dismiss()
# self.app.sideband.conversation_set_object(self.conversation_dropdown.context_dest, not self.app.sideband.is_object(self.conversation_dropdown.context_dest))
# self.app.conversations_view.update()
# return x
def gen_copy_addr(item):
def x():
Clipboard.copy(RNS.hexrep(self.conversation_dropdown.context_dest, delimit=False))
self.voice_dropdown.dismiss()
self.conversation_dropdown.dismiss()
return x
def gen_call(item):
def x():
identity = RNS.Identity.recall(self.conversation_dropdown.context_dest)
if identity: self.app.dial_action(identity.hash)
else: toast("Can't call, identity unknown")
item.dmenu.dismiss()
return x
item.iconr = IconRightWidget(icon="dots-vertical");
if self.voice_dropdown == None:
dmi_h = 40
dmv_items = [
{
"viewclass": "OneLineListItem",
"text": "Edit",
"height": dp(dmi_h),
"on_release": gen_edit(item)
},
{
"text": "Copy Identity Hash",
"viewclass": "OneLineListItem",
"height": dp(dmi_h),
"on_release": gen_copy_addr(item)
},
{
"text": "Delete",
"viewclass": "OneLineListItem",
"height": dp(dmi_h),
"on_release": gen_del(item)
}
]
self.voice_dropdown = MDDropdownMenu(
caller=item.iconr,
items=dmv_items,
position="auto",
width=dp(256),
elevation=0,
radius=dp(3),
)
self.voice_dropdown.effect_cls = ScrollEffect
self.voice_dropdown.md_bg_color = self.app.color_hover
if self.conversation_dropdown == None:
obj_str = "conversations" if is_object else "objects"
dmi_h = 40
dm_items = [
{
@ -391,18 +435,18 @@ class Conversations():
"height": dp(dmi_h),
"on_release": gen_edit(item)
},
{
"viewclass": "OneLineListItem",
"text": "Call",
"height": dp(dmi_h),
"on_release": gen_call(item)
},
{
"text": "Copy Address",
"viewclass": "OneLineListItem",
"height": dp(dmi_h),
"on_release": gen_copy_addr(item)
},
# {
# "text": "Move to objects",
# "viewclass": "OneLineListItem",
# "height": dp(dmi_h),
# "on_release": gen_move_to(item)
# },
{
"text": "Clear Messages",
"viewclass": "OneLineListItem",
@ -434,11 +478,15 @@ class Conversations():
self.conversation_dropdown.effect_cls = ScrollEffect
self.conversation_dropdown.md_bg_color = self.app.color_hover
item.dmenu = self.conversation_dropdown
if conv_type == self.app.sideband.CONV_VOICE:
item.dmenu = self.voice_dropdown
else:
item.dmenu = self.conversation_dropdown
def callback_factory(ref, dest):
def x(sender):
self.conversation_dropdown.context_dest = dest
self.voice_dropdown.context_dest = dest
ref.dmenu.caller = ref.iconr
ref.dmenu.open()
return x
@ -448,6 +496,7 @@ class Conversations():
item.add_widget(item.iconr)
item.trusted = self.app.sideband.is_trusted(context_dest, conv_data=existing_conv)
item.conv_type = conv_type
self.added_item_dests.append(context_dest)
self.list.add_widget(item)
@ -519,7 +568,7 @@ Builder.load_string("""
orientation: "vertical"
spacing: "24dp"
size_hint_y: None
height: dp(250)
height: dp(260)
MDTextField:
id: n_address_field
@ -540,7 +589,7 @@ Builder.load_string("""
orientation: "horizontal"
size_hint_y: None
padding: [0,0,dp(8),dp(24)]
height: dp(48)
height: dp(24)
MDLabel:
id: "trusted_switch_label"
text: "Trusted"
@ -551,6 +600,21 @@ Builder.load_string("""
pos_hint: {"center_y": 0.3}
active: False
MDBoxLayout:
orientation: "horizontal"
size_hint_y: None
padding: [0,0,dp(8),dp(24)]
height: dp(24)
MDLabel:
id: "trusted_switch_label"
text: "Voice Only"
font_style: "H6"
MDSwitch:
id: n_voice_only
pos_hint: {"center_y": 0.3}
active: False
<ConvSettings>
orientation: "vertical"
spacing: "16dp"

View File

@ -30,9 +30,10 @@ class Voice():
def __init__(self, app):
self.app = app
self.screen = None
self.rnstatus_screen = None
self.rnstatus_instance = None
self.logviewer_screen = None
self.settings_screen = None
self.dial_target = None
self.ui_updater = None
self.path_requesting = None
if not self.app.root.ids.screen_manager.has_screen("voice_screen"):
self.screen = Builder.load_string(layout_voice_screen)
@ -41,13 +42,131 @@ class Voice():
self.app.root.ids.screen_manager.add_widget(self.screen)
self.screen.ids.voice_scrollview.effect_cls = ScrollEffect
info = "Voice services UI"
info += ""
# info = "Voice services UI"
# info += ""
if self.app.theme_cls.theme_style == "Dark":
info = "[color=#"+self.app.dark_theme_text_color+"]"+info+"[/color]"
# if self.app.theme_cls.theme_style == "Dark":
# info = "[color=#"+self.app.dark_theme_text_color+"]"+info+"[/color]"
self.screen.ids.voice_info.text = info
# self.screen.ids.voice_info.text = info
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)
else:
if self.ui_updater: self.ui_updater.cancel()
db = self.screen.ids.dial_button
ih = self.screen.ids.identity_hash
if self.app.sideband.voice_running:
telephone = self.app.sideband.telephone
if self.path_requesting:
db.disabled = True
ih.disabled = True
else:
if telephone.is_available:
ih.disabled = False
self.target_input_action(ih)
else:
ih.disabled = True
if telephone.is_in_call or telephone.call_is_connecting:
ih.disabled = True
db.disabled = False
db.text = "Hang up"
db.icon = "phone-hangup"
elif telephone.is_ringing:
ih.disabled = True
db.disabled = False
db.text = "Answer"
db.icon = "phone-ring"
if telephone.caller: ih.text = RNS.hexrep(telephone.caller.hash, delimit=False)
else:
db.disabled = True; db.text = "Voice calls disabled"
ih.disabled = True
def target_valid(self):
if self.app.sideband.voice_running:
db = self.screen.ids.dial_button
db.disabled = False; db.text = "Call"
db.icon = "phone-outgoing"
def target_invalid(self):
if self.app.sideband.voice_running:
db = self.screen.ids.dial_button
db.disabled = True; db.text = "Call"
db.icon = "phone-outgoing"
def target_input_action(self, sender):
if sender:
target_hash = sender.text
if len(target_hash) == RNS.Reticulum.TRUNCATED_HASHLENGTH//8*2:
try:
identity_hash = bytes.fromhex(target_hash)
self.dial_target = identity_hash
self.target_valid()
except Exception as e: self.target_invalid()
else: self.target_invalid()
def request_path(self, destination_hash):
if not self.path_requesting:
self.app.sideband.telephone.set_busy(True)
toast("Requesting path...")
self.screen.ids.dial_button.disabled = True
self.path_requesting = destination_hash
RNS.Transport.request_path(destination_hash)
threading.Thread(target=self._path_wait_job, daemon=True).start()
else:
toast("Waiting for path request answer...")
def _path_wait_job(self):
timeout = time.time()+self.app.sideband.telephone.PATH_TIME
while not RNS.Transport.has_path(self.path_requesting) and time.time() < timeout:
time.sleep(0.25)
self.app.sideband.telephone.set_busy(False)
if RNS.Transport.has_path(self.path_requesting):
RNS.log(f"Calling {RNS.prettyhexrep(self.dial_target)}...", RNS.LOG_DEBUG)
self.app.sideband.telephone.dial(self.dial_target)
Clock.schedule_once(self.update_call_status, 0.1)
else:
Clock.schedule_once(self._path_request_failed, 0.05)
Clock.schedule_once(self.update_call_status, 0.1)
self.path_requesting = None
self.update_call_status()
def _path_request_failed(self, dt):
toast("Path request timed out")
def dial_action(self, sender=None):
if self.app.sideband.voice_running:
if self.app.sideband.telephone.is_available:
destination_hash = RNS.Destination.hash_from_name_and_identity("lxst.telephony", self.dial_target)
if not RNS.Transport.has_path(destination_hash):
self.request_path(destination_hash)
else:
RNS.log(f"Calling {RNS.prettyhexrep(self.dial_target)}...", RNS.LOG_DEBUG)
self.app.sideband.telephone.dial(self.dial_target)
self.update_call_status()
elif self.app.sideband.telephone.is_in_call or self.app.sideband.telephone.call_is_connecting:
RNS.log(f"Hanging up", RNS.LOG_DEBUG)
self.app.sideband.telephone.hangup()
self.update_call_status()
elif self.app.sideband.telephone.is_ringing:
RNS.log(f"Answering", RNS.LOG_DEBUG)
self.app.sideband.telephone.answer()
self.update_call_status()
layout_voice_screen = """
MDScreen:
@ -76,17 +195,21 @@ MDScreen:
height: self.minimum_height
padding: [dp(28), dp(32), dp(28), dp(16)]
# MDLabel:
# text: "Utilities & Tools"
# font_style: "H6"
MDLabel:
id: voice_info
markup: True
text: ""
MDBoxLayout:
orientation: "vertical"
# spacing: "24dp"
size_hint_y: None
text_size: self.width, None
height: self.texture_size[1]
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)
MDBoxLayout:
orientation: "vertical"
@ -96,13 +219,13 @@ MDScreen:
padding: [dp(0), dp(35), dp(0), dp(35)]
MDRectangleFlatIconButton:
id: rnstatus_button
icon: "wifi-check"
text: "Reticulum Status"
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.rnstatus_action(self)
disabled: False
on_release: root.delegate.dial_action(self)
disabled: True
"""