diff --git a/sbapp/main.py b/sbapp/main.py index e886cbd..ea0631e 100644 --- a/sbapp/main.py +++ b/sbapp/main.py @@ -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 ###################################### diff --git a/sbapp/sideband/core.py b/sbapp/sideband/core.py index 6e70381..291a437 100644 --- a/sbapp/sideband/core.py +++ b/sbapp/sideband/core.py @@ -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) diff --git a/sbapp/sideband/voice.py b/sbapp/sideband/voice.py index 09837d0..eb346f0 100644 --- a/sbapp/sideband/voice.py +++ b/sbapp/sideband/voice.py @@ -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) diff --git a/sbapp/ui/conversations.py b/sbapp/ui/conversations.py index cc58db1..d677c73 100644 --- a/sbapp/ui/conversations.py +++ b/sbapp/ui/conversations.py @@ -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 + orientation: "vertical" spacing: "16dp" diff --git a/sbapp/ui/voice.py b/sbapp/ui/voice.py index 2c2c135..f07fc04 100644 --- a/sbapp/ui/voice.py +++ b/sbapp/ui/voice.py @@ -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 """