diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ab730fb --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +all: prepare debug + +prepare: activate + +clean: + buildozer android clean + +activate: + (. venv/bin/activate) + (mv setup.py setup.disabled) + +debug: + buildozer android debug + +release: + buildozer android release + +apk: prepare release + +devapk: prepare debug + +install: + adb install bin/sideband-0.0.1-arm64-v8a-debug.apk + +console: + (adb logcat | grep python) + +getrns: + (rm ./RNS -r;cp -rv ../Reticulum/RNS ./;rm ./RNS/Utilities/RNS;rm ./RNS/__pycache__ -r) diff --git a/README.md b/README.md new file mode 100644 index 0000000..6455ece --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# Sideband + +Sideband is an LXMF client for Android, Linux and macOS. It allows you to communicate with other people or LXMF-compatible systems over Reticulum networks using LoRa, Packet Radio, WiFi, I2P, or anything else Reticulum supports. + +Sideband is completely free, anonymous and infrastructure-less. Sideband uses the completely peer-to-peer and distributed messaging system [LXMF](https://github.com/markqvist/lxmf "LXMF"). There is no service providers, no "end-user license agreements", no data theft and no surveillance. You own the system. + +Sideband currently includes basic functionality for secure and independent communication, and many useful features are planned for implementation. To get a feel for the idea behind Sideband, you can download it and try it out now. Please help make all the functionality a reality by supporting the development with donations. + +Sideband is currently provided as an early alpha-quality preview release. No user support is provided other than the published articles, tutorials and documentation resources. You are welcome to try it out and use it in any way you please. Please report any bugs or unexpected behaviours. Please expect this program to implode the known universe. + +If you want to help develop this program, get in touch. + +Please check back shortly for downloadable APK files for your device. diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..f935d61 Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/presplash.png b/assets/presplash.png new file mode 100644 index 0000000..bf01865 Binary files /dev/null and b/assets/presplash.png differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..478fb0a --- /dev/null +++ b/main.py @@ -0,0 +1,574 @@ +import RNS +import LXMF +import time + +from sideband.core import SidebandCore + +from kivymd.app import MDApp +from kivy.core.window import Window +from kivy.clock import Clock +from kivy.lang.builder import Builder + +from ui.layouts import root_layout +from ui.conversations import Conversations, MsgSync, NewConv +from ui.announces import Announces +from ui.messages import Messages, ts_format +from ui.helpers import ContentNavigationDrawer, DrawerList, IconListItem + +from kivy.metrics import dp +from kivymd.uix.button import MDFlatButton +from kivymd.uix.dialog import MDDialog + +__version__ = "0.1.3" +__variant__ = "alpha" + +class SidebandApp(MDApp): + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.title = "Sideband" + + self.sideband = SidebandCore(self) + self.conversations_view = None + + self.flag_new_conversations = False + self.flag_unread_conversations = False + self.flag_new_announces = False + self.lxmf_sync_dialog_open = False + self.sync_dialog = None + + + Window.softinput_mode = "below_target" + self.icon = self.sideband.asset_dir+"/images/icon.png" + + ################################################# + # General helpers # + ################################################# + + def build(self): + FONT_PATH = self.sideband.asset_dir+"/fonts" + # self.theme_cls.primary_palette = "Green" + self.theme_cls.theme_style = "Dark" + # self.theme_cls.theme_style = "Light" + screen = Builder.load_string(root_layout) + + return screen + + def jobs(self, delta_time): + if self.root.ids.screen_manager.current == "messages_screen": + self.messages_view.update() + + if not self.root.ids.messages_scrollview.dest_known: + self.message_area_detect() + + elif self.root.ids.screen_manager.current == "conversations_screen": + if self.flag_new_conversations: + RNS.log("Updating because of new conversations flag") + if self.conversations_view != None: + self.conversations_view.update() + + if self.flag_unread_conversations: + RNS.log("Updating because of unread messages flag") + if self.conversations_view != None: + self.conversations_view.update() + + if self.lxmf_sync_dialog_open and self.sync_dialog != None: + self.sync_dialog.ids.sync_progress.value = self.sideband.get_sync_progress()*100 + self.sync_dialog.ids.sync_status.text = self.sideband.get_sync_status() + + elif self.root.ids.screen_manager.current == "announces_screen": + if self.flag_new_announces: + RNS.log("Updating because of new announces flag") + if self.announces_view != None: + self.announces_view.update() + + def on_start(self): + self.root.ids.screen_manager.app = self + self.root.ids.app_version_info.text = "Sideband v"+__version__+" "+__variant__ + + self.open_conversations() + + Clock.schedule_interval(self.jobs, 1) + + def widget_hide(self, w, hide=True): + if hasattr(w, "saved_attrs"): + if not hide: + w.height, w.size_hint_y, w.opacity, w.disabled = w.saved_attrs + del w.saved_attrs + elif hide: + w.saved_attrs = w.height, w.size_hint_y, w.opacity, w.disabled + w.height, w.size_hint_y, w.opacity, w.disabled = 0, None, 0, True + + def quit_action(self, sender): + RNS.exit() + RNS.log("RNS shutdown complete") + MDApp.get_running_app().stop() + Window.close() + + def announce_now_action(self, sender=None): + self.sideband.lxmf_announce() + + yes_button = MDFlatButton( + text="OK", + ) + + dialog = MDDialog( + text="An announce for your LXMF destination was sent on all available interfaces", + buttons=[ yes_button ], + ) + def dl_yes(s): + dialog.dismiss() + + yes_button.bind(on_release=dl_yes) + dialog.open() + + def conversation_update(self, context_dest): + pass + # if self.root.ids.messages_scrollview.active_conversation == context_dest: + # self.messages_view.update_widget() + # else: + # RNS.log("Not updating since context_dest does not match active") + + + ################################################# + # Screens # + ################################################# + + ### Messages (conversation) screen + ###################################### + def conversation_from_announce_action(self, context_dest): + if self.sideband.has_conversation(context_dest): + pass + else: + self.sideband.create_conversation(context_dest) + + self.open_conversation(context_dest) + + + def conversation_action(self, sender): + self.open_conversation(sender.sb_uid) + + def open_conversation(self, context_dest): + if self.sideband.config["propagation_by_default"]: + self.outbound_mode_propagation = True + else: + self.outbound_mode_propagation = False + + self.root.ids.screen_manager.transition.direction = "left" + self.messages_view = Messages(self, context_dest) + + for child in self.root.ids.messages_scrollview.children: + self.root.ids.messages_scrollview.remove_widget(child) + + list_widget = self.messages_view.get_widget() + + self.root.ids.messages_scrollview.add_widget(list_widget) + self.root.ids.messages_scrollview.scroll_y = 0 + self.root.ids.messages_toolbar.title = self.sideband.peer_display_name(context_dest) + self.root.ids.messages_scrollview.active_conversation = context_dest + + self.root.ids.nokeys_text.text = "" + self.message_area_detect() + self.update_message_widgets() + + self.root.ids.screen_manager.current = "messages_screen" + + self.sideband.read_conversation(context_dest) + + def close_messages_action(self, sender=None): + self.open_conversations(direction="right") + + def message_send_action(self, sender=None): + if self.root.ids.screen_manager.current == "messages_screen": + if self.outbound_mode_propagation and self.sideband.message_router.get_outbound_propagation_node() == None: + self.messages_view.send_error_dialog = MDDialog( + text="Error: Propagated delivery was requested, but no active LXMF propagation nodes were found. Cannot send message. Wait for a Propagation Node to announce on the network, or manually specify one in the settings.", + buttons=[ + MDFlatButton( + text="OK", + theme_text_color="Custom", + text_color=self.theme_cls.primary_color, + on_release=self.messages_view.close_send_error_dialog + ) + ], + ) + self.messages_view.send_error_dialog.open() + + else: + msg_content = self.root.ids.message_text.text + context_dest = self.root.ids.messages_scrollview.active_conversation + if self.sideband.send_message(msg_content, context_dest, self.outbound_mode_propagation): + self.root.ids.message_text.text = "" + self.jobs(0) + else: + self.messages_view.send_error_dialog = MDDialog( + text="Error: Could not send the message", + buttons=[ + MDFlatButton( + text="OK", + theme_text_color="Custom", + text_color=self.theme_cls.primary_color, + on_release=self.messages_view.close_send_error_dialog + ) + ], + ) + self.messages_view.send_error_dialog.open() + + def message_propagation_action(self, sender): + if self.outbound_mode_propagation: + self.outbound_mode_propagation = False + else: + self.outbound_mode_propagation = True + self.update_message_widgets() + + def update_message_widgets(self): + toolbar_items = self.root.ids.messages_toolbar.ids.right_actions.children + mode_item = toolbar_items[1] + + if not self.outbound_mode_propagation: + mode_item.icon = "lan-connect" + self.root.ids.message_text.hint_text = "Write message for direct delivery" + else: + mode_item.icon = "upload-network" + self.root.ids.message_text.hint_text = "Write message for propagation" + # self.root.ids.message_text.hint_text = "Write message for delivery via propagation nodes" + + def key_query_action(self, sender): + context_dest = self.root.ids.messages_scrollview.active_conversation + if self.sideband.request_key(context_dest): + keys_str = "Public key information for "+RNS.prettyhexrep(context_dest)+" was requested from the network. Waiting for request to be answered." + self.root.ids.nokeys_text.text = keys_str + else: + keys_str = "Could not send request. Check your connectivity and addresses." + self.root.ids.nokeys_text.text = keys_str + + def message_area_detect(self): + context_dest = self.root.ids.messages_scrollview.active_conversation + if self.sideband.is_known(context_dest): + self.root.ids.messages_scrollview.dest_known = True + self.widget_hide(self.root.ids.message_input_part, False) + self.widget_hide(self.root.ids.no_keys_part, True) + else: + self.root.ids.messages_scrollview.dest_known = False + if self.root.ids.nokeys_text.text == "": + keys_str = "The crytographic keys for the destination address are unknown at this time. You can wait for an announce to arrive, or query the network for the necessary keys." + self.root.ids.nokeys_text.text = keys_str + self.widget_hide(self.root.ids.message_input_part, True) + self.widget_hide(self.root.ids.no_keys_part, False) + + + ### Conversations screen + ###################################### + def conversations_action(self, sender=None): + self.open_conversations() + + def open_conversations(self, direction="left"): + self.root.ids.screen_manager.transition.direction = direction + self.root.ids.nav_drawer.set_state("closed") + self.conversations_view = Conversations(self) + + for child in self.root.ids.conversations_scrollview.children: + self.root.ids.conversations_scrollview.remove_widget(child) + + self.root.ids.conversations_scrollview.add_widget(self.conversations_view.get_widget()) + + self.root.ids.screen_manager.current = "conversations_screen" + self.root.ids.messages_scrollview.active_conversation = None + + def lxmf_sync_action(self, sender): + if self.sideband.message_router.get_outbound_propagation_node() == None: + yes_button = MDFlatButton( + text="OK", + ) + + dialog = MDDialog( + text="No active LXMF propagation nodes were found. Cannot fetch messages. Wait for a Propagation Node to announce on the network, or manually specify one in the settings.", + buttons=[ yes_button ], + ) + def dl_yes(s): + dialog.dismiss() + + yes_button.bind(on_release=dl_yes) + dialog.open() + else: + if self.sideband.config["lxmf_sync_limit"]: + sl = self.sideband.config["lxmf_sync_max"] + else: + sl = None + + self.sideband.request_lxmf_sync(limit=sl) + + close_button = MDFlatButton(text="Close", font_size=dp(20)) + # stop_button = MDFlatButton(text="Stop", font_size=dp(20)) + dialog_content = MsgSync() + dialog = MDDialog( + title="LXMF Sync via "+RNS.prettyhexrep(self.sideband.message_router.get_outbound_propagation_node()), + type="custom", + content_cls=dialog_content, + buttons=[ close_button ], + ) + dialog.d_content = dialog_content + def dl_close(s): + self.lxmf_sync_dialog_open = False + dialog.dismiss() + self.sideband.cancel_lxmf_sync() + + # def dl_stop(s): + # self.lxmf_sync_dialog_open = False + # dialog.dismiss() + # self.sideband.cancel_lxmf_sync() + + close_button.bind(on_release=dl_close) + # stop_button.bind(on_release=dl_stop) + self.lxmf_sync_dialog_open = True + self.sync_dialog = dialog_content + dialog.open() + dialog_content.ids.sync_progress.value = self.sideband.get_sync_progress()*100 + dialog_content.ids.sync_status.text = self.sideband.get_sync_status() + + def new_conversation_action(self, sender=None): + try: + yes_button = MDFlatButton( + text="OK", + font_size=dp(20), + ) + no_button = MDFlatButton( + text="Cancel", + font_size=dp(20), + ) + dialog_content = NewConv() + dialog = MDDialog( + title="New Conversation", + type="custom", + content_cls=dialog_content, + buttons=[ yes_button, no_button ], + ) + dialog.d_content = dialog_content + def dl_yes(s): + new_result = False + try: + 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 + RNS.log("Create conversation "+str(n_address)+"/"+str(n_name)+"/"+str(n_trusted)) + new_result = self.sideband.new_conversation(n_address, n_name, n_trusted) + + except Exception as e: + RNS.log("Error while creating conversation: "+str(e), RNS.LOG_ERROR) + + if new_result: + dialog.d_content.ids["n_address_field"].error = False + dialog.dismiss() + self.open_conversations() + else: + dialog.d_content.ids["n_address_field"].error = True + # dialog.d_content.ids["n_error_field"].text = "Could not create conversation. Check your input." + + def dl_no(s): + dialog.dismiss() + + yes_button.bind(on_release=dl_yes) + no_button.bind(on_release=dl_no) + dialog.open() + + except Exception as e: + RNS.log("Error while creating new conversation dialog: "+str(e), RNS.LOG_ERROR) + + ### Information/version screen + ###################################### + def information_action(self, sender=None): + def link_exec(sender=None, event=None): + RNS.log("Click") + import webbrowser + webbrowser.open("https://unsigned.io/sideband") + + info = "This is Sideband v"+__version__+" "+__variant__+".\n\nHumbly build using the following open components:\n\n - [b]Reticulum[/b] (MIT License)\n - [b]LXMF[/b] (MIT License)\n - [b]KivyMD[/b] (MIT License)\n - [b]Kivy[/b] (MIT License)\n - [b]Python[/b] (PSF License)"+"\n\nGo to [u][ref=link]https://unsigned.io/sideband[/ref][/u] to support the project.\n\nThe Sideband app is Copyright (c) 2022 Mark Qvist / unsigned.io\n\nPermission is granted to freely share and distribute binary copies of Sideband v"+__version__+" "+__variant__+", so long as no payment or compensation is charged for said distribution or sharing.\n\nIf you were charged or paid anything for this copy of Sideband, please report it to [b]license@unsigned.io[/b].\n\nTHIS IS EXPERIMENTAL SOFTWARE - USE AT YOUR OWN RISK AND RESPONSIBILITY" + self.root.ids.information_info.text = info + self.root.ids.information_info.bind(on_ref_press=link_exec) + self.root.ids.screen_manager.transition.direction = "left" + self.root.ids.screen_manager.current = "information_screen" + self.root.ids.nav_drawer.set_state("closed") + + ### Prepare Settings screen + def settings_action(self, sender=None): + self.root.ids.screen_manager.transition.direction = "left" + + def save_disp_name(sender=None, event=None): + in_name = self.root.ids.settings_display_name.text + if in_name == "": + new_name = "Anonymous Peer" + else: + new_name = in_name + + self.sideband.config["display_name"] = new_name + self.sideband.save_configuration() + + def save_prop_addr(sender=None, event=None): + in_addr = self.root.ids.settings_propagation_node_address.text + + new_addr = None + if in_addr == "": + new_addr = None + self.root.ids.settings_propagation_node_address.error = False + else: + if len(in_addr) != RNS.Reticulum.TRUNCATED_HASHLENGTH//8*2: + new_addr = None + else: + try: + new_addr = bytes.fromhex(in_addr) + except Exception as e: + new_addr = None + + if new_addr == None: + self.root.ids.settings_propagation_node_address.error = True + else: + self.root.ids.settings_propagation_node_address.error = False + + + self.sideband.config["lxmf_propagation_node"] = new_addr + self.sideband.set_active_propagation_node(self.sideband.config["lxmf_propagation_node"]) + + def save_start_announce(sender=None, event=None): + RNS.log("Save announce") + self.sideband.config["start_announce"] = self.root.ids.settings_start_announce.active + self.sideband.save_configuration() + + def save_lxmf_delivery_by_default(sender=None, event=None): + RNS.log("Save propagation") + self.sideband.config["propagation_by_default"] = self.root.ids.settings_lxmf_delivery_by_default.active + self.sideband.save_configuration() + + def save_lxmf_sync_limit(sender=None, event=None): + RNS.log("Save propagation") + self.sideband.config["lxmf_sync_limit"] = self.root.ids.settings_lxmf_sync_limit.active + self.sideband.save_configuration() + + self.root.ids.settings_lxmf_address.text = RNS.hexrep(self.sideband.lxmf_destination.hash, delimit=False) + + self.root.ids.settings_display_name.text = self.sideband.config["display_name"] + self.root.ids.settings_display_name.bind(on_text_validate=save_disp_name) + self.root.ids.settings_display_name.bind(focus=save_disp_name) + + if self.sideband.config["lxmf_propagation_node"] == None: + prop_node_addr = "" + else: + prop_node_addr = RNS.hexrep(self.sideband.config["lxmf_propagation_node"], delimit=False) + + self.root.ids.settings_propagation_node_address.text = prop_node_addr + self.root.ids.settings_propagation_node_address.bind(on_text_validate=save_prop_addr) + self.root.ids.settings_propagation_node_address.bind(focus=save_prop_addr) + + self.root.ids.settings_start_announce.active = self.sideband.config["start_announce"] + self.root.ids.settings_start_announce.bind(active=save_start_announce) + + self.root.ids.settings_lxmf_delivery_by_default.active = self.sideband.config["propagation_by_default"] + self.root.ids.settings_lxmf_delivery_by_default.bind(active=save_lxmf_delivery_by_default) + + if self.sideband.config["lxmf_sync_limit"] == None or self.sideband.config["lxmf_sync_limit"] == False: + sync_limit = False + else: + sync_limit = True + + self.root.ids.settings_lxmf_sync_limit.active = sync_limit + self.root.ids.settings_lxmf_sync_limit.bind(active=save_lxmf_sync_limit) + + self.root.ids.screen_manager.current = "settings_screen" + self.root.ids.nav_drawer.set_state("closed") + + def close_settings_action(self, sender=None): + self.open_conversations(direction="right") + + + ### Announce Stream screen + ###################################### + def announces_action(self, sender=None): + self.root.ids.screen_manager.transition.direction = "left" + self.root.ids.nav_drawer.set_state("closed") + self.announces_view = Announces(self) + + + # info = "The [b]Announce Stream[/b] feature is not yet implemented in Sideband.\n\nWant it faster? Go to [u][ref=link]https://unsigned.io/sideband[/ref][/u] to support the project." + # self.root.ids.announces_info.text = info + # self.root.ids.announces_info.bind(on_ref_press=link_exec) + + for child in self.root.ids.announces_scrollview.children: + self.root.ids.announces_scrollview.remove_widget(child) + + self.root.ids.announces_scrollview.add_widget(self.announces_view.get_widget()) + + self.root.ids.screen_manager.current = "announces_screen" + + def announce_filter_action(self, sender=None): + pass + + + ################################################# + # Unimplemented Screens # + ################################################# + + def keys_action(self, sender=None): + def link_exec(sender=None, event=None): + RNS.log("Click") + import webbrowser + webbrowser.open("https://unsigned.io/sideband") + + info = "The [b]Encryption Keys[/b] import and export feature is not yet implemented in Sideband.\n\nWant it faster? Go to [u][ref=link]https://unsigned.io/sideband[/ref][/u] to support the project." + self.root.ids.keys_info.text = info + self.root.ids.keys_info.bind(on_ref_press=link_exec) + self.root.ids.screen_manager.transition.direction = "left" + self.root.ids.screen_manager.current = "keys_screen" + self.root.ids.nav_drawer.set_state("closed") + + def map_action(self, sender=None): + def link_exec(sender=None, event=None): + RNS.log("Click") + import webbrowser + webbrowser.open("https://unsigned.io/sideband") + + info = "The [b]Local Area[/b] feature is not yet implemented in Sideband.\n\nWant it faster? Go to [u][ref=link]https://unsigned.io/sideband[/ref][/u] to support the project." + self.root.ids.map_info.text = info + self.root.ids.map_info.bind(on_ref_press=link_exec) + self.root.ids.screen_manager.transition.direction = "left" + self.root.ids.screen_manager.current = "map_screen" + self.root.ids.nav_drawer.set_state("closed") + + def connectivity_action(self, sender=None): + def link_exec(sender=None, event=None): + RNS.log("Click") + import webbrowser + webbrowser.open("https://unsigned.io/sideband") + + info = "The [b]Connectivity[/b] feature will allow you use LoRa and radio interfaces directly on Android over USB or Bluetooth, through a simple and user-friendly setup process. It will also let you view advanced connectivity stats and options.\n\nThis feature is not yet implemented in Sideband.\n\nWant it faster? Go to [u][ref=link]https://unsigned.io/sideband[/ref][/u] to support the project." + self.root.ids.connectivity_info.text = info + self.root.ids.connectivity_info.bind(on_ref_press=link_exec) + + self.root.ids.screen_manager.transition.direction = "left" + self.root.ids.screen_manager.current = "connectivity_screen" + self.root.ids.nav_drawer.set_state("closed") + + def broadcasts_action(self, sender=None): + def link_exec(sender=None, event=None): + RNS.log("Click") + import webbrowser + webbrowser.open("https://unsigned.io/sideband") + + info = "The [b]Local Broadcasts[/b] feature will allow you to send and listen for local broadcast transmissions on connected radio, LoRa and WiFi interfaces.\n\n[b]Local Broadcasts[/b] makes it easy to establish public information exchange with anyone in direct radio range, or even with large areas far away using the [i]Remote Broadcast Repeater[/i] feature.\n\nThese features are not yet implemented in Sideband.\n\nWant it faster? Go to [u][ref=link]https://unsigned.io/sideband[/ref][/u] to support the project." + self.root.ids.broadcasts_info.text = info + self.root.ids.broadcasts_info.bind(on_ref_press=link_exec) + self.root.ids.screen_manager.transition.direction = "left" + self.root.ids.screen_manager.current = "broadcasts_screen" + self.root.ids.nav_drawer.set_state("closed") + + def guide_action(self, sender=None): + def link_exec(sender=None, event=None): + RNS.log("Click") + import webbrowser + webbrowser.open("https://unsigned.io/sideband") + + info = "The [b]Guide[/b] section is not yet implemented in Sideband.\n\nWant it faster? Go to [u][ref=link]https://unsigned.io/sideband[/ref][/u] to support the project." + self.root.ids.guide_info.text = info + self.root.ids.guide_info.bind(on_ref_press=link_exec) + self.root.ids.screen_manager.transition.direction = "left" + self.root.ids.screen_manager.current = "guide_screen" + self.root.ids.nav_drawer.set_state("closed") + +SidebandApp().run() diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..d9ff199 --- /dev/null +++ b/setup.py @@ -0,0 +1,30 @@ +import setuptools + +exec(open("sideband/_version.py", "r").read()) + +with open("README.md", "r") as fh: + long_description = fh.read() + +setuptools.setup( + name="sideband", + version=__version__, + author="Mark Qvist", + author_email="mark@unsigned.io", + description="LXMF client for Android, Linux and macOS allowing you to communicate with people or LXMF-compatible systems over Reticulum networks using LoRa, Packet Radio, WiFi, I2P, or anything else Reticulum supports.", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://unsigned.io/sideband", + packages=setuptools.find_packages(), + classifiers=[ + "Programming Language :: Python :: 3", + "License :: Other/Proprietary License", + "Operating System :: OS Independent", + ], + entry_points= { + 'console_scripts': [ + 'sideband=main:main', + ] + }, + install_requires=['lxmf'], + python_requires='>=3.6', +) diff --git a/sideband/_version.py b/sideband/_version.py new file mode 100644 index 0000000..9bc1126 --- /dev/null +++ b/sideband/_version.py @@ -0,0 +1,2 @@ +__version__ = "0.1.2" +__variant__ = "alpha" diff --git a/sideband/core.py b/sideband/core.py new file mode 100644 index 0000000..8b3a741 --- /dev/null +++ b/sideband/core.py @@ -0,0 +1,888 @@ +import RNS +import LXMF +import threading +import plyer +import os.path +import time +import sqlite3 + +import RNS.vendor.umsgpack as msgpack + +class PropagationNodeDetector(): + EMITTED_DELTA_GRACE = 300 + EMITTED_DELTA_IGNORE = 10 + + aspect_filter = "lxmf.propagation" + + def received_announce(self, destination_hash, announced_identity, app_data): + try: + unpacked = msgpack.unpackb(app_data) + node_active = unpacked[0] + emitted = unpacked[1] + hops = RNS.Transport.hops_to(destination_hash) + + age = time.time() - emitted + if age < 0: + RNS.log("Warning, propagation node announce emitted in the future, possible timing inconsistency or tampering attempt.") + if age < -1*PropagationNodeDetector.EMITTED_DELTA_GRACE: + raise ValueError("Announce timestamp too far in the future, discarding it") + + if age > -1*PropagationNodeDetector.EMITTED_DELTA_IGNORE: + # age = 0 + pass + + RNS.log("Detected active propagation node "+RNS.prettyhexrep(destination_hash)+" emission "+str(age)+" seconds ago, "+str(hops)+" hops away") + + if self.owner.config["lxmf_propagation_node"] == None: + if self.owner.active_propagation_node == None: + self.owner.set_active_propagation_node(destination_hash) + else: + prev_hops = RNS.Transport.hops_to(self.owner.active_propagation_node) + if hops <= prev_hops: + self.owner.set_active_propagation_node(destination_hash) + else: + pass + else: + pass + + except Exception as e: + RNS.log("Error while processing received propagation node announce: "+str(e)) + + def __init__(self, owner): + self.owner = owner + self.owner_app = owner.owner_app + +class SidebandCore(): + CONV_P2P = 0x01 + CONV_GROUP = 0x02 + CONV_BROADCAST = 0x03 + + MAX_ANNOUNCES = 64 + + aspect_filter = "lxmf.delivery" + def received_announce(self, destination_hash, announced_identity, app_data): + # Add the announce to the directory announce + # stream logger + self.log_announce(destination_hash, app_data) + + def __init__(self, owner_app): + self.owner_app = owner_app + self.reticulum = None + + self.app_dir = plyer.storagepath.get_application_dir() + + self.rns_configdir = None + if RNS.vendor.platformutils.get_platform() == "android": + self.app_dir = self.app_dir+"/io.unsigned.sideband/files/" + self.rns_configdir = self.app_dir+"/app_storage/reticulum" + + if not os.path.isdir(self.app_dir+"/app_storage"): + os.makedirs(self.app_dir+"/app_storage") + + self.asset_dir = self.app_dir+"/assets" + self.kv_dir = self.app_dir+"/views/kv" + self.config_path = self.app_dir+"/app_storage/sideband_config" + self.identity_path = self.app_dir+"/app_storage/primary_identity" + self.db_path = self.app_dir+"/app_storage/sideband.db" + self.lxmf_storage = self.app_dir+"/app_storage/" + + try: + if not os.path.isfile(self.config_path): + self.__init_config() + else: + self.__load_config() + + except Exception as e: + RNS.log("Error while configuring Sideband: "+str(e), RNS.LOG_ERROR) + + + # Initialise Reticulum configuration + if RNS.vendor.platformutils.get_platform() == "android": + try: + self.rns_configdir = self.app_dir+"/app_storage/reticulum" + if not os.path.isdir(self.rns_configdir): + os.makedirs(self.rns_configdir) + + RNS.log("Configuring Reticulum instance...") + config_file = open(self.rns_configdir+"/config", "wb") + config_file.write(rns_config) + config_file.close() + + except Exception as e: + RNS.log("Error while configuring Reticulum instance: "+str(e), RNS.LOG_ERROR) + + else: + pass + + self.active_propagation_node = None + self.propagation_detector = PropagationNodeDetector(self) + + RNS.Transport.register_announce_handler(self) + RNS.Transport.register_announce_handler(self.propagation_detector) + + self.start() + + + def __init_config(self): + RNS.log("Creating new Sideband configuration...") + if os.path.isfile(self.identity_path): + self.identity = RNS.Identity.from_file(self.identity_path) + else: + self.identity = RNS.Identity() + self.identity.to_file(self.identity_path) + + self.config = {} + self.config["display_name"] = "Anonymous Peer" + self.config["start_announce"] = False + self.config["propagation_by_default"] = False + self.config["home_node_as_broadcast_repeater"] = False + self.config["send_telemetry_to_home_node"] = False + self.config["lxmf_propagation_node"] = None + self.config["lxmf_sync_limit"] = None + self.config["lxmf_sync_max"] = 3 + self.config["last_lxmf_propagation_node"] = None + self.config["nn_home_node"] = None + self.__save_config() + + if not os.path.isfile(self.db_path): + self.__db_init() + + + def __load_config(self): + RNS.log("Loading Sideband identity...") + self.identity = RNS.Identity.from_file(self.identity_path) + + RNS.log("Loading Sideband configuration...") + config_file = open(self.config_path, "rb") + self.config = msgpack.unpackb(config_file.read()) + config_file.close() + + if not os.path.isfile(self.db_path): + self.__db_init() + + + def __save_config(self): + RNS.log("Saving Sideband configuration...") + config_file = open(self.config_path, "wb") + config_file.write(msgpack.packb(self.config)) + config_file.close() + + + def save_configuration(self): + RNS.log("Saving configuration") + self.__save_config() + + def set_active_propagation_node(self, dest): + if dest == None: + RNS.log("No active propagation node configured") + else: + try: + self.active_propagation_node = dest + self.config["last_lxmf_propagation_node"] = dest + self.message_router.set_outbound_propagation_node(dest) + RNS.log("Active propagation node set to: "+RNS.prettyhexrep(dest)) + self.__save_config() + except Exception as e: + RNS.log("Error while setting LXMF propagation node: "+str(e), RNS.LOG_ERROR) + + + def log_announce(self, dest, app_data): + try: + RNS.log("Received LXMF destination announce for "+RNS.prettyhexrep(dest)+" with data: "+app_data.decode("utf-8")) + self._db_save_announce(dest, app_data) + self.owner_app.flag_new_announces = True + + except Exception as e: + RNS.log("Exception while decoding LXMF destination announce data:"+str(e)) + + def list_conversations(self): + result = self._db_conversations() + if result != None: + return result + else: + return [] + + def list_announces(self): + result = self._db_announces() + if result != None: + return result + else: + return [] + + def has_conversation(self, context_dest): + existing_conv = self._db_conversation(context_dest) + if existing_conv != None: + return True + else: + return False + + def is_trusted(self, context_dest): + try: + existing_conv = self._db_conversation(context_dest) + if existing_conv != None: + if existing_conv["trust"] == 1: + return True + else: + return False + else: + return False + + except Exception as e: + RNS.log("Error while checking trust for "+RNS.prettyhexrep(context_dest)+": "+str(e), RNS.LOG_ERROR) + return False + + def raw_display_name(self, context_dest): + try: + existing_conv = self._db_conversation(context_dest) + if existing_conv != None: + if existing_conv["name"] != None and existing_conv["name"] != "": + return existing_conv["name"] + else: + return "" + else: + return "" + + except Exception as e: + RNS.log("Error while getting peer name: "+str(e), RNS.LOG_ERROR) + return "" + + def peer_display_name(self, context_dest): + try: + existing_conv = self._db_conversation(context_dest) + if existing_conv != None: + if existing_conv["name"] != None and existing_conv["name"] != "": + if existing_conv["trust"] == 1: + return existing_conv["name"] + else: + return existing_conv["name"]+" "+RNS.prettyhexrep(context_dest) + + else: + app_data = RNS.Identity.recall_app_data(context_dest) + if app_data != None: + if existing_conv["trust"] == 1: + return app_data.decode("utf-8") + else: + return app_data.decode("utf-8")+" "+RNS.prettyhexrep(context_dest) + else: + return RNS.prettyhexrep(context_dest) + else: + app_data = RNS.Identity.recall_app_data(context_dest) + if app_data != None: + return app_data.decode("utf-8")+" "+RNS.prettyhexrep(context_dest) + else: + return RNS.prettyhexrep(context_dest) + + + except Exception as e: + RNS.log("Error while getting peer name: "+str(e), RNS.LOG_ERROR) + return RNS.prettyhexrep(context_dest) + + def clear_conversation(self, context_dest): + self._db_clear_conversation(context_dest) + + def delete_conversation(self, context_dest): + self._db_clear_conversation(context_dest) + self._db_delete_conversation(context_dest) + + def delete_message(self, message_hash): + self._db_delete_message(message_hash) + + def read_conversation(self, context_dest): + self._db_conversation_set_unread(context_dest, False) + + def unread_conversation(self, context_dest): + self._db_conversation_set_unread(context_dest, True) + + def trusted_conversation(self, context_dest): + self._db_conversation_set_trusted(context_dest, True) + + def untrusted_conversation(self, context_dest): + self._db_conversation_set_trusted(context_dest, False) + + def named_conversation(self, name, context_dest): + self._db_conversation_set_name(context_dest, name) + + def list_messages(self, context_dest, after = None): + result = self._db_messages(context_dest, after) + if result != None: + return result + else: + return [] + + def __event_conversations_changed(self): + pass + + def __event_conversation_changed(self, context_dest): + pass + + def __db_init(self): + db = sqlite3.connect(self.db_path) + dbc = db.cursor() + + dbc.execute("DROP TABLE IF EXISTS lxm") + dbc.execute("CREATE TABLE lxm (lxm_hash BLOB PRIMARY KEY, dest BLOB, source BLOB, title BLOB, tx_ts INTEGER, rx_ts INTEGER, state INTEGER, method INTEGER, t_encrypted INTEGER, t_encryption INTEGER, data BLOB)") + + dbc.execute("DROP TABLE IF EXISTS conv") + dbc.execute("CREATE TABLE conv (dest_context BLOB PRIMARY KEY, last_tx INTEGER, last_rx INTEGER, unread INTEGER, type INTEGER, trust INTEGER, name BLOB, data BLOB)") + + dbc.execute("DROP TABLE IF EXISTS announce") + dbc.execute("CREATE TABLE announce (id PRIMARY KEY, received INTEGER, source BLOB, data BLOB)") + + db.commit() + db.close() + + def _db_conversation_set_unread(self, context_dest, unread): + db = sqlite3.connect(self.db_path) + dbc = db.cursor() + + query = "UPDATE conv set unread = ? where dest_context = ?" + data = (unread, context_dest) + dbc.execute(query, data) + result = dbc.fetchall() + db.commit() + + db.close() + + def _db_conversation_set_trusted(self, context_dest, trusted): + db = sqlite3.connect(self.db_path) + dbc = db.cursor() + + query = "UPDATE conv set trust = ? where dest_context = ?" + data = (trusted, context_dest) + dbc.execute(query, data) + result = dbc.fetchall() + db.commit() + + db.close() + + def _db_conversation_set_name(self, context_dest, name): + db = sqlite3.connect(self.db_path) + dbc = db.cursor() + + query = "UPDATE conv set name=:name_data where dest_context=:ctx;" + dbc.execute(query, {"ctx": context_dest, "name_data": name.encode("utf-8")}) + result = dbc.fetchall() + db.commit() + + db.close() + + def _db_conversations(self): + db = sqlite3.connect(self.db_path) + dbc = db.cursor() + + dbc.execute("select * from conv") + result = dbc.fetchall() + + db.close() + + if len(result) < 1: + return None + else: + convs = [] + for entry in result: + conv = { + "dest": entry[0], + "unread": entry[3], + } + convs.append(conv) + + return convs + + + def _db_announces(self): + db = sqlite3.connect(self.db_path) + dbc = db.cursor() + + dbc.execute("select * from announce order by received desc") + result = dbc.fetchall() + + db.close() + + if len(result) < 1: + return None + else: + announces = [] + for entry in result: + try: + announce = { + "dest": entry[2], + "data": entry[3].decode("utf-8"), + "time": entry[1], + } + announces.append(announce) + except Exception as e: + RNS.log("Exception while fetching announce from DB: "+str(e), RNS.LOG_ERROR) + + return announces + + + def _db_conversation(self, context_dest): + db = sqlite3.connect(self.db_path) + dbc = db.cursor() + + query = "select * from conv where dest_context=:ctx" + dbc.execute(query, {"ctx": context_dest}) + result = dbc.fetchall() + + db.close() + + if len(result) < 1: + return None + else: + c = result[0] + conv = {} + conv["dest"] = c[0] + conv["last_tx"] = c[1] + conv["last_rx"] = c[2] + conv["unread"] = c[3] + conv["type"] = c[4] + conv["trust"] = c[5] + conv["name"] = c[6].decode("utf-8") + conv["data"] = msgpack.unpackb(c[7]) + return conv + + + def _db_clear_conversation(self, context_dest): + RNS.log("Clearing conversation with "+RNS.prettyhexrep(context_dest)) + db = sqlite3.connect(self.db_path) + dbc = db.cursor() + + query = "delete from lxm where (dest=:ctx_dst or source=:ctx_dst);" + dbc.execute(query, {"ctx_dst": context_dest}) + db.commit() + + db.close() + + def _db_delete_conversation(self, context_dest): + RNS.log("Deleting conversation with "+RNS.prettyhexrep(context_dest)) + db = sqlite3.connect(self.db_path) + dbc = db.cursor() + + query = "delete from conv where (dest_context=:ctx_dst);" + dbc.execute(query, {"ctx_dst": context_dest}) + db.commit() + + db.close() + + def _db_create_conversation(self, context_dest, name = None, trust = False): + RNS.log("Creating conversation for "+RNS.prettyhexrep(context_dest)) + db = sqlite3.connect(self.db_path) + 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 = (context_dest, 0, 0, 0, SidebandCore.CONV_P2P, 0, def_name, msgpack.packb(None)) + + dbc.execute(query, data) + + db.commit() + db.close() + + if trust: + self._db_conversation_set_trusted(context_dest, True) + + if name != None and name != "": + self._db_conversation_set_name(context_dest, name) + + self.__event_conversations_changed() + + def _db_delete_message(self, msg_hash): + RNS.log("Deleting message "+RNS.prettyhexrep(msg_hash)) + db = sqlite3.connect(self.db_path) + dbc = db.cursor() + + query = "delete from lxm where (lxm_hash=:mhash);" + dbc.execute(query, {"mhash": msg_hash}) + db.commit() + + db.close() + + def _db_clean_messages(self): + RNS.log("Purging stale messages... "+str(self.db_path)) + db = sqlite3.connect(self.db_path) + dbc = db.cursor() + + query = "delete from lxm where (state=:outbound_state or state=:sending_state);" + dbc.execute(query, {"outbound_state": LXMF.LXMessage.OUTBOUND, "sending_state": LXMF.LXMessage.SENDING}) + db.commit() + + db.close() + + def _db_message_set_state(self, lxm_hash, state): + db = sqlite3.connect(self.db_path) + dbc = db.cursor() + + query = "UPDATE lxm set state = ? where lxm_hash = ?" + data = (state, lxm_hash) + dbc.execute(query, data) + db.commit() + result = dbc.fetchall() + + db.close() + + def _db_message_set_method(self, lxm_hash, method): + db = sqlite3.connect(self.db_path) + dbc = db.cursor() + + query = "UPDATE lxm set method = ? where lxm_hash = ?" + data = (method, lxm_hash) + dbc.execute(query, data) + db.commit() + result = dbc.fetchall() + + db.close() + + def message(self, msg_hash): + return self._db_message(msg_hash) + + def _db_message(self, msg_hash): + db = sqlite3.connect(self.db_path) + dbc = db.cursor() + + query = "select * from lxm where lxm_hash=:mhash" + dbc.execute(query, {"mhash": msg_hash}) + result = dbc.fetchall() + + db.close() + + if len(result) < 1: + return None + else: + entry = result[0] + lxm = LXMF.LXMessage.unpack_from_bytes(entry[10]) + message = { + "hash": lxm.hash, + "dest": lxm.destination_hash, + "source": lxm.source_hash, + "title": lxm.title, + "content": lxm.content, + "received": entry[5], + "sent": lxm.timestamp, + "state": entry[6], + "method": entry[7], + "lxm": lxm + } + return message + + def _db_messages(self, context_dest, after = None): + db = sqlite3.connect(self.db_path) + dbc = db.cursor() + + if after == None: + query = "select * from lxm where dest=:context_dest or source=:context_dest" + dbc.execute(query, {"context_dest": context_dest}) + else: + query = "select * from lxm where (dest=:context_dest or source=:context_dest) and rx_ts>:after_ts" + dbc.execute(query, {"context_dest": context_dest, "after_ts": after}) + + result = dbc.fetchall() + + db.close() + + if len(result) < 1: + return None + else: + messages = [] + for entry in result: + lxm = LXMF.LXMessage.unpack_from_bytes(entry[10]) + message = { + "hash": lxm.hash, + "dest": lxm.destination_hash, + "source": lxm.source_hash, + "title": lxm.title, + "content": lxm.content, + "received": entry[5], + "sent": lxm.timestamp, + "state": entry[6], + "method": entry[7], + "lxm": lxm + } + messages.append(message) + + return messages + + + def _db_save_lxm(self, lxm, context_dest): + state = lxm.state + + db = sqlite3.connect(self.db_path) + dbc = db.cursor() + + if not lxm.packed: + lxm.pack() + + query = "INSERT INTO lxm (lxm_hash, dest, source, title, tx_ts, rx_ts, state, method, t_encrypted, t_encryption, data) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" + data = ( + lxm.hash, + lxm.destination_hash, + lxm.source_hash, + lxm.title, + lxm.timestamp, + time.time(), + state, + lxm.method, + lxm.transport_encrypted, + lxm.transport_encryption, + lxm.packed + ) + + dbc.execute(query, data) + + db.commit() + db.close() + + self.__event_conversation_changed(context_dest) + + def _db_save_announce(self, destination_hash, app_data): + db = sqlite3.connect(self.db_path) + dbc = db.cursor() + + query = "INSERT INTO announce (received, source, data) values (?, ?, ?)" + data = ( + time.time(), + destination_hash, + app_data, + ) + + dbc.execute(query, data) + + query = "delete from announce where id not in (select id from announce order by received desc limit "+str(self.MAX_ANNOUNCES)+")" + dbc.execute(query) + + db.commit() + db.close() + + def lxmf_announce(self): + self.lxmf_destination.announce() + + def is_known(self, dest_hash): + try: + source_identity = RNS.Identity.recall(dest_hash) + + if source_identity: + return True + else: + return False + + except Exception as e: + return False + + def request_key(self, dest_hash): + try: + RNS.Transport.request_path(dest_hash) + return True + + except Exception as e: + RNS.log("Error while querying for key: "+str(e), RNS.LOG_ERROR) + return False + + def __start_jobs_deferred(self): + if self.config["start_announce"]: + self.lxmf_destination.announce() + + def __start_jobs_immediate(self): + self.reticulum = RNS.Reticulum(configdir=self.rns_configdir) + RNS.log("Reticulum started, activating LXMF...") + + self.message_router = LXMF.LXMRouter(identity = self.identity, storagepath = self.lxmf_storage, autopeer = True) + self.message_router.register_delivery_callback(self.lxmf_delivery) + + self.lxmf_destination = self.message_router.register_delivery_identity(self.identity, display_name=self.config["display_name"]) + self.lxmf_destination.set_default_app_data(self.get_display_name_bytes) + + self.rns_dir = RNS.Reticulum.configdir + + def message_notification(self, message): + if message.state == LXMF.LXMessage.FAILED and hasattr(message, "try_propagation_on_fail") and message.try_propagation_on_fail: + RNS.log("Direct delivery of "+str(message)+" failed. Retrying as propagated message.", RNS.LOG_VERBOSE) + message.try_propagation_on_fail = None + message.delivery_attempts = 0 + del message.next_delivery_attempt + message.packed = None + message.desired_method = LXMF.LXMessage.PROPAGATED + self._db_message_set_method(message.hash, LXMF.LXMessage.PROPAGATED) + self.message_router.handle_outbound(message) + else: + self.lxm_ingest(message, originator=True) + + + def send_message(self, content, destination_hash, propagation): + try: + if content == "": + raise ValueError("Message content cannot be empty") + + dest_identity = RNS.Identity.recall(destination_hash) + dest = RNS.Destination(dest_identity, RNS.Destination.OUT, RNS.Destination.SINGLE, "lxmf", "delivery") + source = self.lxmf_destination + + # TODO: Add setting + if propagation: + desired_method = LXMF.LXMessage.PROPAGATED + else: + desired_method = LXMF.LXMessage.DIRECT + + lxm = LXMF.LXMessage(dest, source, content, title="", desired_method=desired_method) + lxm.register_delivery_callback(self.message_notification) + lxm.register_failed_callback(self.message_notification) + + if self.message_router.get_outbound_propagation_node() != None: + lxm.try_propagation_on_fail = True + + self.message_router.handle_outbound(lxm) + self.lxm_ingest(lxm, originator=True) + + return True + + except Exception as e: + RNS.log("Error while sending message: "+str(e), RNS.LOG_ERROR) + return False + + def new_conversation(self, dest_str, name = "", trusted = False): + if len(dest_str) != RNS.Reticulum.TRUNCATED_HASHLENGTH//8*2: + return False + + try: + addr_b = bytes.fromhex(dest_str) + self._db_create_conversation(addr_b, name, trusted) + + except Exception as e: + RNS.log("Error while creating conversation: "+str(e), RNS.LOG_ERROR) + return False + + return True + + def create_conversation(self, context_dest, name = None, trusted = False): + try: + self._db_create_conversation(context_dest, name, trusted) + + except Exception as e: + RNS.log("Error while creating conversation: "+str(e), RNS.LOG_ERROR) + return False + + return True + + def lxm_ingest(self, message, originator = False): + if originator: + context_dest = message.destination_hash + else: + context_dest = message.source_hash + + if self._db_message(message.hash): + RNS.log("Message exists, setting state to: "+str(message.state)) + self._db_message_set_state(message.hash, message.state) + else: + RNS.log("Message does not exist, saving") + self._db_save_lxm(message, context_dest) + + if self._db_conversation(context_dest) == None: + self._db_create_conversation(context_dest) + self.owner_app.flag_new_conversations = True + + if self.owner_app.root.ids.screen_manager.current == "messages_screen": + if self.owner_app.root.ids.messages_scrollview.active_conversation != context_dest: + self.unread_conversation(context_dest) + self.owner_app.flag_unread_conversations = True + else: + self.unread_conversation(context_dest) + self.owner_app.flag_unread_conversations = True + + try: + self.owner_app.conversation_update(context_dest) + except Exception as e: + RNS.log("Error in conversation update callback: "+str(e)) + + + def start(self): + self._db_clean_messages() + self.__start_jobs_immediate() + + if self.config["lxmf_propagation_node"] != None and self.config["lxmf_propagation_node"] != "": + self.set_active_propagation_node(self.config["lxmf_propagation_node"]) + else: + if self.config["last_lxmf_propagation_node"] != None and self.config["last_lxmf_propagation_node"] != "": + self.set_active_propagation_node(self.config["last_lxmf_propagation_node"]) + else: + self.set_active_propagation_node(None) + + thread = threading.Thread(target=self.__start_jobs_deferred) + thread.setDaemon(True) + thread.start() + RNS.log("Sideband Core "+str(self)+" started") + + def request_lxmf_sync(self, limit = None): + if self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_IDLE or self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_COMPLETE: + self.message_router.request_messages_from_propagation_node(self.identity, max_messages = limit) + RNS.log("LXMF message sync requested from propagation node "+RNS.prettyhexrep(self.message_router.get_outbound_propagation_node())+" for "+str(self.identity)) + return True + else: + return False + + def cancel_lxmf_sync(self): + if self.message_router.propagation_transfer_state != LXMF.LXMRouter.PR_IDLE: + self.message_router.cancel_propagation_node_requests() + + def get_sync_progress(self): + return self.message_router.propagation_transfer_progress + + + def lxmf_delivery(self, message): + time_string = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(message.timestamp)) + signature_string = "Signature is invalid, reason undetermined" + if message.signature_validated: + signature_string = "Validated" + else: + if message.unverified_reason == LXMF.LXMessage.SIGNATURE_INVALID: + signature_string = "Invalid signature" + if message.unverified_reason == LXMF.LXMessage.SOURCE_UNKNOWN: + signature_string = "Cannot verify, source is unknown" + + RNS.log("LXMF delivery "+str(time_string)+". "+str(signature_string)+".") + + try: + self.lxm_ingest(message) + except Exception as e: + RNS.log("Error while ingesting LXMF message "+RNS.prettyhexrep(message.hash)+" to database: "+str(e)) + + def get_display_name_bytes(self): + return self.config["display_name"].encode("utf-8") + + def get_sync_status(self): + if self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_IDLE: + return "Idle" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_PATH_REQUESTED: + return "Path requested" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_LINK_ESTABLISHING: + return "Establishing link" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_LINK_ESTABLISHED: + return "Link established" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_REQUEST_SENT: + return "Sync request sent" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_RECEIVING: + return "Receiving messages" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_RESPONSE_RECEIVED: + return "Messages received" + elif self.message_router.propagation_transfer_state == LXMF.LXMRouter.PR_COMPLETE: + new_msgs = self.message_router.propagation_transfer_last_result + if new_msgs == 0: + return "Done, no new messages" + else: + return "Downloaded "+str(new_msgs)+" new messages" + else: + return "Unknown" + +rns_config = """ +[reticulum] +enable_transport = False +share_instance = Yes +shared_instance_port = 37428 +instance_control_port = 37429 +panic_on_interface_error = No + +[logging] +loglevel = 7 + +[interfaces] + [[Default Interface]] + type = AutoInterface + interface_enabled = True +""".encode("utf-8") diff --git a/ui/announces.py b/ui/announces.py new file mode 100644 index 0000000..ce7a362 --- /dev/null +++ b/ui/announces.py @@ -0,0 +1,151 @@ +import time +import RNS + +from kivy.metrics import dp +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 kivy.uix.gridlayout import GridLayout +from kivy.uix.boxlayout import BoxLayout + +from kivymd.uix.button import MDFlatButton +from kivymd.uix.dialog import MDDialog + +from ui.helpers import ts_format + +class Announces(): + def __init__(self, app): + self.app = app + self.context_dests = [] + self.added_item_dests = [] + self.list = None + self.update() + + def reload(self): + self.clear_list() + self.update() + + def clear_list(self): + if self.list != None: + self.list.clear_widgets() + + self.context_dests = [] + self.added_item_dests = [] + + def update(self): + self.clear_list() + self.announces = self.app.sideband.list_announces() + self.update_widget() + + self.app.flag_new_announces = False + + def update_widget(self): + if self.list == None: + self.list = MDList() + + for announce in self.announces: + context_dest = announce["dest"] + ts = announce["time"] + a_data = announce["data"] + + if not context_dest in self.added_item_dests: + if self.app.sideband.is_trusted(context_dest): + trust_icon = "account-check" + else: + trust_icon = "account-question" + + def gen_info(ts, dest, name): + def x(sender): + yes_button = MDFlatButton( + text="OK", + ) + + dialog = MDDialog( + text="Announce Received: "+ts+"\nAnnounced Name: "+name+"\nLXMF Address: "+RNS.prettyhexrep(dest), + buttons=[ yes_button ], + ) + def dl_yes(s): + dialog.dismiss() + + yes_button.bind(on_release=dl_yes) + item.dmenu.dismiss() + dialog.open() + return x + + time_string = time.strftime(ts_format, time.localtime(ts)) + disp_name = self.app.sideband.peer_display_name(context_dest) + iconl = IconLeftWidget(icon=trust_icon) + item = OneLineAvatarIconListItem(text=time_string+": "+disp_name, on_release=gen_info(time_string, context_dest, a_data)) + item.add_widget(iconl) + item.sb_uid = context_dest + + def gen_del(dest, item): + def x(): + yes_button = MDFlatButton( + text="Yes", + ) + no_button = MDFlatButton( + text="No", + ) + dialog = MDDialog( + text="Delete announce?", + buttons=[ yes_button, no_button ], + ) + def dl_yes(s): + dialog.dismiss() + self.app.sideband.delete_announce(dest) + self.reload() + def dl_no(s): + dialog.dismiss() + + yes_button.bind(on_release=dl_yes) + no_button.bind(on_release=dl_no) + item.dmenu.dismiss() + dialog.open() + return x + + def gen_conv(dest, item): + def x(): + item.dmenu.dismiss() + self.app.conversation_from_announce_action(dest) + return x + + dm_items = [ + { + "viewclass": "OneLineListItem", + "text": "Converse", + "height": dp(64), + "on_release": gen_conv(context_dest, item) + }, + # { + # "text": "Delete Announce", + # "viewclass": "OneLineListItem", + # "height": dp(64), + # "on_release": gen_del(context_dest, item) + # } + ] + + item.iconr = IconRightWidget(icon="dots-vertical"); + + item.dmenu = MDDropdownMenu( + caller=item.iconr, + items=dm_items, + position="center", + width_mult=4, + ) + + def callback_factory(ref): + def x(sender): + ref.dmenu.open() + return x + + item.iconr.bind(on_release=callback_factory(item)) + + item.add_widget(item.iconr) + + self.added_item_dests.append(context_dest) + self.list.add_widget(item) + + def get_widget(self): + return self.list \ No newline at end of file diff --git a/ui/conversations.py b/ui/conversations.py new file mode 100644 index 0000000..ae39451 --- /dev/null +++ b/ui/conversations.py @@ -0,0 +1,228 @@ +import RNS + +from kivy.metrics import dp +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 kivy.uix.gridlayout import GridLayout +from kivy.uix.boxlayout import BoxLayout + +from kivymd.uix.button import MDFlatButton +from kivymd.uix.dialog import MDDialog + + +class NewConv(BoxLayout): + pass + + +class MsgSync(BoxLayout): + pass + + +class ConvSettings(BoxLayout): + disp_name = StringProperty() + trusted = BooleanProperty() + + +class Conversations(): + def __init__(self, app): + self.app = app + self.context_dests = [] + self.added_item_dests = [] + self.list = None + self.update() + + def reload(self): + self.clear_list() + self.update() + + def clear_list(self): + if self.list != None: + self.list.clear_widgets() + + self.context_dests = [] + self.added_item_dests = [] + + def update(self): + if self.app.flag_unread_conversations: + self.clear_list() + + self.context_dests = self.app.sideband.list_conversations() + self.update_widget() + + self.app.flag_new_conversations = False + self.app.flag_unread_conversations = False + + def update_widget(self): + if self.list == None: + self.list = MDList() + + for conv in self.context_dests: + context_dest = conv["dest"] + unread = conv["unread"] + + if not context_dest in self.added_item_dests: + if self.app.sideband.is_trusted(context_dest): + if unread: + trust_icon = "email-seal" + else: + trust_icon = "account-check" + else: + if unread: + trust_icon = "email" + else: + trust_icon = "account-question" + + iconl = IconLeftWidget(icon=trust_icon) + item = OneLineAvatarIconListItem(text=self.app.sideband.peer_display_name(context_dest), on_release=self.app.conversation_action) + item.add_widget(iconl) + item.sb_uid = context_dest + + def gen_edit(dest, item): + def x(): + try: + disp_name = self.app.sideband.raw_display_name(dest) + is_trusted = self.app.sideband.is_trusted(dest) + + yes_button = MDFlatButton( + text="Save", + font_size=dp(20), + ) + no_button = MDFlatButton( + text="Cancel", + font_size=dp(20), + ) + dialog_content = ConvSettings(disp_name=disp_name, trusted=is_trusted) + dialog = MDDialog( + title="Conversation with "+RNS.prettyhexrep(dest), + type="custom", + content_cls=dialog_content, + buttons=[ yes_button, no_button ], + ) + dialog.d_content = dialog_content + def dl_yes(s): + try: + name = dialog.d_content.ids["name_field"].text + trusted = dialog.d_content.ids["trusted_switch"].active + if trusted: + RNS.log("Setting Trusted "+str(trusted)) + self.app.sideband.trusted_conversation(dest) + else: + RNS.log("Setting Untrusted "+str(trusted)) + self.app.sideband.untrusted_conversation(dest) + + RNS.log("Name="+name) + self.app.sideband.named_conversation(name, dest) + + except Exception as e: + RNS.log("Error while saving conversation settings: "+str(e), RNS.LOG_ERROR) + + dialog.dismiss() + self.reload() + + def dl_no(s): + dialog.dismiss() + + yes_button.bind(on_release=dl_yes) + no_button.bind(on_release=dl_no) + item.dmenu.dismiss() + dialog.open() + except Exception as e: + RNS.log("Error while creating conversation settings: "+str(e), RNS.LOG_ERROR) + + return x + + def gen_clear(dest, item): + def x(): + yes_button = MDFlatButton( + text="Yes", + ) + no_button = MDFlatButton( + text="No", + ) + dialog = MDDialog( + text="Clear all messages in conversation?", + buttons=[ yes_button, no_button ], + ) + def dl_yes(s): + dialog.dismiss() + self.app.sideband.clear_conversation(dest) + def dl_no(s): + dialog.dismiss() + + yes_button.bind(on_release=dl_yes) + no_button.bind(on_release=dl_no) + item.dmenu.dismiss() + dialog.open() + return x + + def gen_del(dest, item): + def x(): + yes_button = MDFlatButton( + text="Yes", + ) + no_button = MDFlatButton( + text="No", + ) + dialog = MDDialog( + text="Delete conversation?", + buttons=[ yes_button, no_button ], + ) + def dl_yes(s): + dialog.dismiss() + self.app.sideband.delete_conversation(dest) + self.reload() + def dl_no(s): + dialog.dismiss() + + yes_button.bind(on_release=dl_yes) + no_button.bind(on_release=dl_no) + item.dmenu.dismiss() + dialog.open() + return x + + dm_items = [ + { + "viewclass": "OneLineListItem", + "text": "Edit", + "height": dp(64), + "on_release": gen_edit(context_dest, item) + }, + { + "text": "Clear Messages", + "viewclass": "OneLineListItem", + "height": dp(64), + "on_release": gen_clear(context_dest, item) + }, + { + "text": "Delete Conversation", + "viewclass": "OneLineListItem", + "height": dp(64), + "on_release": gen_del(context_dest, item) + } + ] + + item.iconr = IconRightWidget(icon="dots-vertical"); + + item.dmenu = MDDropdownMenu( + caller=item.iconr, + items=dm_items, + position="center", + width_mult=4, + ) + + def callback_factory(ref): + def x(sender): + ref.dmenu.open() + return x + + item.iconr.bind(on_release=callback_factory(item)) + + item.add_widget(item.iconr) + + self.added_item_dests.append(context_dest) + self.list.add_widget(item) + + def get_widget(self): + return self.list \ No newline at end of file diff --git a/ui/helpers.py b/ui/helpers.py new file mode 100644 index 0000000..8422d7a --- /dev/null +++ b/ui/helpers.py @@ -0,0 +1,30 @@ +from kivy.utils import get_color_from_hex +from kivymd.color_definitions import colors +from kivy.uix.screenmanager import ScreenManager, Screen +from kivymd.theming import ThemableBehavior +from kivymd.uix.list import OneLineIconListItem, MDList, IconLeftWidget, IconRightWidget +from kivy.properties import StringProperty + +ts_format = "%Y-%m-%d %H:%M:%S" + +def mdc(color, hue=None): + if hue == None: + hue = "400" + return get_color_from_hex(colors[color][hue]) + +color_received = "Green" +color_delivered = "Indigo" +color_propagated = "Blue" +color_failed = "Red" +color_unknown = "Gray" +intensity_msgs = "600" + +class ContentNavigationDrawer(Screen): + pass + +class DrawerList(ThemableBehavior, MDList): + pass + +class IconListItem(OneLineIconListItem): + icon = StringProperty() + diff --git a/ui/layouts.py b/ui/layouts.py new file mode 100644 index 0000000..5be6b81 --- /dev/null +++ b/ui/layouts.py @@ -0,0 +1,675 @@ +root_layout = """ +MDNavigationLayout: + + ScreenManager: + id: screen_manager + + MDScreen: + name: "conversations_screen" + + BoxLayout: + orientation: "vertical" + + MDToolbar: + title: "Conversations" + elevation: 10 + pos_hint: {"top": 1} + left_action_items: + [['menu', lambda x: nav_drawer.set_state("open")]] + right_action_items: + [ + ['access-point', lambda x: root.ids.screen_manager.app.announce_now_action(self)], + ['email-sync', lambda x: root.ids.screen_manager.app.lxmf_sync_action(self)], + ['account-plus', lambda x: root.ids.screen_manager.app.new_conversation_action(self)], + ] + + ScrollView: + id: conversations_scrollview + + + MDScreen: + name: "messages_screen" + + BoxLayout: + orientation: "vertical" + + MDToolbar: + id: messages_toolbar + title: "Messages" + elevation: 10 + pos_hint: {"top": 1} + left_action_items: + [['menu', lambda x: nav_drawer.set_state("open")]] + right_action_items: + [ + ['lan-connect', lambda x: root.ids.screen_manager.app.message_propagation_action(self)], + ['close', lambda x: root.ids.screen_manager.app.close_messages_action(self)], + ] + + ScrollView: + id: messages_scrollview + do_scroll_x: False + do_scroll_y: True + + BoxLayout: + orientation: "vertical" + id: no_keys_part + padding: [dp(28), dp(16), dp(16), dp(16)] + spacing: dp(24) + size_hint_y: None + height: self.minimum_height + dp(64) + + MDLabel: + id: nokeys_text + text: "" + + MDRectangleFlatIconButton: + icon: "key-wireless" + text: "Query Network For Keys" + # padding: [dp(16), dp(16), dp(16), dp(16)] + on_release: root.ids.screen_manager.app.key_query_action(self) + + + BoxLayout: + id: message_input_part + # orientation: "vertical" + padding: [dp(28), dp(16), dp(16), dp(16)] + spacing: dp(24) + size_hint_y: None + height: self.minimum_height + + MDTextField: + id: message_text + multiline: True + hint_text: "Write message" + mode: "rectangle" + max_height: dp(100) + + MDRectangleFlatIconButton: + icon: "transfer-up" + text: "Send" + # padding: [dp(16), dp(16), dp(16), dp(16)] + on_release: root.ids.screen_manager.app.message_send_action(self) + + + MDScreen: + name: "broadcasts_screen" + + BoxLayout: + orientation: "vertical" + + MDToolbar: + title: "Local Broadcasts" + elevation: 10 + pos_hint: {"top": 1} + left_action_items: + [['menu', lambda x: nav_drawer.set_state("open")]] + + ScrollView: + id: broadcasts_scrollview + + MDBoxLayout: + orientation: "vertical" + spacing: "24dp" + size_hint_y: None + height: self.minimum_height + padding: dp(64) + + MDLabel: + id: broadcasts_info + markup: True + text: "" + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] + + + MDScreen: + name: "connectivity_screen" + + BoxLayout: + orientation: "vertical" + + MDToolbar: + title: "Connectivity" + elevation: 10 + pos_hint: {"top": 1} + left_action_items: + [['menu', lambda x: nav_drawer.set_state("open")]] + + ScrollView: + id:connectivity_scrollview + + MDBoxLayout: + orientation: "vertical" + spacing: "24dp" + size_hint_y: None + height: self.minimum_height + padding: dp(64) + + MDLabel: + id: connectivity_info + markup: True + text: "" + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] + + + MDScreen: + name: "guide_screen" + + BoxLayout: + orientation: "vertical" + + MDToolbar: + title: "Guide" + elevation: 10 + pos_hint: {"top": 1} + left_action_items: + [['menu', lambda x: nav_drawer.set_state("open")]] + + ScrollView: + id:guide_scrollview + + MDBoxLayout: + orientation: "vertical" + spacing: "24dp" + size_hint_y: None + height: self.minimum_height + padding: dp(64) + + MDLabel: + id: guide_info + markup: True + text: "" + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] + + + MDScreen: + name: "information_screen" + + BoxLayout: + orientation: "vertical" + + MDToolbar: + title: "App & Version Information" + elevation: 10 + pos_hint: {"top": 1} + left_action_items: + [['menu', lambda x: nav_drawer.set_state("open")]] + + ScrollView: + id:information_scrollview + + MDBoxLayout: + orientation: "vertical" + spacing: "24dp" + size_hint_y: None + height: self.minimum_height + padding: dp(64) + + MDLabel: + id: information_info + markup: True + text: "" + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] + + + MDScreen: + name: "map_screen" + + BoxLayout: + orientation: "vertical" + + MDToolbar: + title: "Local Area Map" + elevation: 10 + pos_hint: {"top": 1} + left_action_items: + [['menu', lambda x: nav_drawer.set_state("open")]] + + ScrollView: + id:information_scrollview + + MDBoxLayout: + orientation: "vertical" + spacing: "24dp" + size_hint_y: None + height: self.minimum_height + padding: dp(64) + + MDLabel: + id: map_info + markup: True + text: "" + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] + + + MDScreen: + name: "keys_screen" + + BoxLayout: + orientation: "vertical" + + MDToolbar: + title: "Encryption Keys" + elevation: 10 + pos_hint: {"top": 1} + left_action_items: + [['menu', lambda x: nav_drawer.set_state("open")]] + + ScrollView: + id:keys_scrollview + + MDBoxLayout: + orientation: "vertical" + spacing: "24dp" + size_hint_y: None + height: self.minimum_height + padding: dp(64) + + MDLabel: + id: keys_info + markup: True + text: "" + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] + + + MDScreen: + name: "announces_screen" + + BoxLayout: + orientation: "vertical" + + MDToolbar: + title: "Announce Stream" + elevation: 10 + pos_hint: {"top": 1} + left_action_items: + [['menu', lambda x: nav_drawer.set_state("open")]] + right_action_items: + [ + ['close', lambda x: root.ids.screen_manager.app.close_settings_action(self)], + ] + # [['eye-off', lambda x: root.ids.screen_manager.app.announce_filter_action(self)]] + + ScrollView: + id: announces_scrollview + + MDBoxLayout: + orientation: "vertical" + spacing: "24dp" + size_hint_y: None + height: self.minimum_height + padding: dp(64) + + MDLabel: + id: announces_info + markup: True + text: "" + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] + + + MDScreen: + name: "settings_screen" + + BoxLayout: + orientation: "vertical" + + MDToolbar: + title: "Settings" + elevation: 10 + pos_hint: {"top": 1} + left_action_items: + [['menu', lambda x: nav_drawer.set_state("open")]] + right_action_items: + [ + ['close', lambda x: root.ids.screen_manager.app.close_settings_action(self)], + ] + + ScrollView: + id: settings_scrollview + + MDBoxLayout: + orientation: "vertical" + spacing: "24dp" + size_hint_y: None + height: self.minimum_height + padding: dp(16) + + MDLabel: + text: "" + font_style: "H6" + + MDLabel: + text: "User Options" + font_style: "H6" + + MDTextField: + id: settings_display_name + hint_text: "Display Name" + text: "" + max_text_length: 128 + font_size: dp(24) + + MDTextField: + id: settings_lxmf_address + hint_text: "Your LXMF Address" + text: "" + disabled: False + max_text_length: 20 + font_size: dp(24) + + MDTextField: + id: settings_propagation_node_address + hint_text: "LXMF Propagation Node" + disabled: False + text: "" + max_text_length: 20 + font_size: dp(24) + + MDTextField: + id: settings_home_node_address + hint_text: "Nomad Network Home Node" + disabled: False + text: "" + max_text_length: 20 + font_size: dp(24) + + MDBoxLayout: + orientation: "horizontal" + # spacing: "24dp" + size_hint_y: None + height: dp(48) + + MDLabel: + text: "Announce At App Startup" + font_style: "H6" + + MDSwitch: + id: settings_start_announce + active: False + + MDBoxLayout: + orientation: "horizontal" + # spacing: "24dp" + size_hint_y: None + height: dp(48) + + MDLabel: + text: "Deliver via LXMF Propagation Node by default" + font_style: "H6" + + MDSwitch: + id: settings_lxmf_delivery_by_default + disabled: False + active: False + + MDBoxLayout: + orientation: "horizontal" + # spacing: "24dp" + size_hint_y: None + height: dp(48) + + MDLabel: + text: "Limit each sync to 3 messages" + font_style: "H6" + + MDSwitch: + id: settings_lxmf_sync_limit + disabled: False + active: False + + MDBoxLayout: + orientation: "horizontal" + # spacing: "24dp" + size_hint_y: None + height: dp(48) + + MDLabel: + text: "Use Home Node as Broadcast Repeater" + font_style: "H6" + + MDSwitch: + id: settings_home_node_as_broadcast_repeater + active: False + disabled: True + + MDBoxLayout: + orientation: "horizontal" + # spacing: "24dp" + size_hint_y: None + height: dp(48) + + MDLabel: + text: "Send Telemetry to Home Node" + font_style: "H6" + + MDSwitch: + id: settings_telemetry_to_home_node + disabled: True + active: False + + + MDNavigationDrawer: + id: nav_drawer + + ContentNavigationDrawer: + ScrollView: + DrawerList: + id: md_list + + MDList: + OneLineIconListItem: + text: "Conversations" + on_release: root.ids.screen_manager.app.conversations_action(self) + + IconLeftWidget: + icon: "email" + on_release: root.ids.screen_manager.app.conversations_action(self) + + + OneLineIconListItem: + text: "Announce Stream" + on_release: root.ids.screen_manager.app.announces_action(self) + + IconLeftWidget: + icon: "account-voice" + on_release: root.ids.screen_manager.app.announces_action(self) + + + OneLineIconListItem: + text: "Local Broadcasts" + on_release: root.ids.screen_manager.app.broadcasts_action(self) + + IconLeftWidget: + icon: "radio-tower" + on_release: root.ids.screen_manager.app.broadcasts_action(self) + + + OneLineIconListItem: + text: "Local Area Map" + on_release: root.ids.screen_manager.app.map_action(self) + + IconLeftWidget: + icon: "map" + on_release: root.ids.screen_manager.app.map_action(self) + + + OneLineIconListItem: + text: "Connectivity" + on_release: root.ids.screen_manager.app.connectivity_action(self) + _no_ripple_effect: True + + IconLeftWidget: + icon: "wifi" + on_release: root.ids.screen_manager.app.connectivity_action(self) + + + OneLineIconListItem: + text: "Settings" + on_release: root.ids.screen_manager.app.settings_action(self) + _no_ripple_effect: True + + IconLeftWidget: + icon: "cog" + on_release: root.ids.screen_manager.app.settings_action(self) + + + OneLineIconListItem: + text: "Encryption Keys" + on_release: root.ids.screen_manager.app.keys_action(self) + _no_ripple_effect: True + + IconLeftWidget: + icon: "key-chain" + on_release: root.ids.screen_manager.app.keys_action(self) + + + OneLineIconListItem: + text: "Guide" + on_release: root.ids.screen_manager.app.guide_action(self) + _no_ripple_effect: True + + IconLeftWidget: + icon: "book-open" + on_release: root.ids.screen_manager.app.guide_action(self) + + + OneLineIconListItem: + id: app_version_info + text: "" + on_release: root.ids.screen_manager.app.information_action(self) + _no_ripple_effect: True + + IconLeftWidget: + icon: "information" + on_release: root.ids.screen_manager.app.information_action(self) + + + OneLineIconListItem: + text: "Shutdown" + on_release: root.ids.screen_manager.app.quit_action(self) + _no_ripple_effect: True + + IconLeftWidget: + icon: "power" + on_release: root.ids.screen_manager.app.quit_action(self) + +: + padding: dp(8) + size_hint: 1.0, None + height: content_text.height + heading_text.height + dp(32) + pos_hint: {"center_x": .5, "center_y": .5} + + MDRelativeLayout: + size_hint: 1.0, None + # size: root.size + + MDIconButton: + id: msg_submenu + icon: "dots-vertical" + pos: + root.width - (self.width + root.padding[0] + dp(4)), root.height - (self.height + root.padding[0] + dp(4)) + + MDLabel: + id: heading_text + markup: True + text: root.heading + adaptive_size: True + pos: 0, root.height - (self.height + root.padding[0] + dp(8)) + + MDLabel: + id: content_text + text: root.text + # adaptive_size: True + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] + + + orientation: "vertical" + spacing: "24dp" + size_hint_y: None + height: self.minimum_height+dp(24) + + MDLabel: + id: sync_status + hint_text: "Name" + text: "Initiating sync..." + + MDProgressBar: + id: sync_progress + value: 0 + + + orientation: "vertical" + spacing: "24dp" + size_hint_y: None + height: dp(148) + + MDTextField: + id: name_field + hint_text: "Name" + text: root.disp_name + font_size: dp(24) + + MDBoxLayout: + orientation: "horizontal" + # spacing: "24dp" + size_hint_y: None + height: dp(48) + MDLabel: + id: trusted_switch_label + text: "Trusted" + font_style: "H6" + + MDSwitch: + id: trusted_switch + active: root.trusted + + + orientation: "vertical" + spacing: "24dp" + size_hint_y: None + height: dp(250) + + MDTextField: + id: n_address_field + max_text_length: 20 + hint_text: "Address" + helper_text: "Error, check your input" + helper_text_mode: "on_error" + text: "" + font_size: dp(24) + + MDTextField: + id: n_name_field + hint_text: "Name" + text: "" + font_size: dp(24) + + MDBoxLayout: + orientation: "horizontal" + size_hint_y: None + height: dp(48) + MDLabel: + id: "trusted_switch_label" + text: "Trusted" + font_style: "H6" + + MDSwitch: + id: n_trusted + active: False +""" \ No newline at end of file diff --git a/ui/messages.py b/ui/messages.py new file mode 100644 index 0000000..bc47e2e --- /dev/null +++ b/ui/messages.py @@ -0,0 +1,207 @@ +import time +import RNS +import LXMF + +from kivy.metrics import dp +from kivy.core.clipboard import Clipboard +from kivymd.uix.card import MDCard +from kivymd.uix.menu import MDDropdownMenu +from kivymd.uix.behaviors import RoundedRectangularElevationBehavior +from kivy.properties import StringProperty, BooleanProperty +from kivy.uix.gridlayout import GridLayout +from kivy.uix.boxlayout import BoxLayout + +from kivymd.uix.button import MDFlatButton +from kivymd.uix.dialog import MDDialog + +from ui.helpers import ts_format, mdc +from ui.helpers import color_received, color_delivered, color_propagated, color_failed, color_unknown, intensity_msgs + +class ListLXMessageCard(MDCard, RoundedRectangularElevationBehavior): + text = StringProperty() + heading = StringProperty() + +class Messages(): + def __init__(self, app, context_dest): + self.app = app + self.context_dest = context_dest + self.messages = [] + self.added_item_hashes = [] + self.latest_message_timestamp = None + self.list = None + self.widgets = [] + self.send_error_dialog = None + self.update() + + def reload(self): + if self.list != None: + self.list.clear_widgets() + + self.messages = [] + self.added_item_hashes = [] + self.latest_message_timestamp = None + self.widgets = [] + + self.update() + + def update(self): + self.messages = self.app.sideband.list_messages(self.context_dest, self.latest_message_timestamp) + if self.list == None: + layout = GridLayout(cols=1, spacing=16, padding=16, size_hint_y=None) + layout.bind(minimum_height=layout.setter('height')) + self.list = layout + + if len(self.messages) > 0: + self.update_widget() + + for w in self.widgets: + m = w.m + if m["state"] == LXMF.LXMessage.SENDING or m["state"] == LXMF.LXMessage.OUTBOUND: + msg = self.app.sideband.message(m["hash"]) + if msg["state"] == LXMF.LXMessage.DELIVERED: + w.md_bg_color = msg_color = mdc(color_delivered, intensity_msgs) + txstr = time.strftime(ts_format, time.localtime(msg["sent"])) + titlestr = "" + if msg["title"]: + titlestr = "[b]Title[/b] "+msg["title"].decode("utf-8")+"\n" + w.heading = titlestr+"[b]Sent[/b] "+txstr+" [b]State[/b] Delivered" + m["state"] = msg["state"] + + if msg["method"] == LXMF.LXMessage.PROPAGATED and msg["state"] == LXMF.LXMessage.SENT: + w.md_bg_color = msg_color = mdc(color_propagated, intensity_msgs) + txstr = time.strftime(ts_format, time.localtime(msg["sent"])) + titlestr = "" + if msg["title"]: + titlestr = "[b]Title[/b] "+msg["title"].decode("utf-8")+"\n" + w.heading = titlestr+"[b]Sent[/b] "+txstr+" [b]State[/b] On Propagation Net" + m["state"] = msg["state"] + + if msg["state"] == LXMF.LXMessage.FAILED: + w.md_bg_color = msg_color = mdc(color_failed, intensity_msgs) + txstr = time.strftime(ts_format, time.localtime(msg["sent"])) + titlestr = "" + if msg["title"]: + titlestr = "[b]Title[/b] "+msg["title"].decode("utf-8")+"\n" + w.heading = titlestr+"[b]Sent[/b] "+txstr+" [b]State[/b] Failed" + m["state"] = msg["state"] + + + def update_widget(self): + for m in self.messages: + if not m["hash"] in self.added_item_hashes: + txstr = time.strftime(ts_format, time.localtime(m["sent"])) + rxstr = time.strftime(ts_format, time.localtime(m["received"])) + titlestr = "" + + if m["title"]: + titlestr = "[b]Title[/b] "+m["title"].decode("utf-8")+"\n" + + if m["source"] == self.app.sideband.lxmf_destination.hash: + if m["state"] == LXMF.LXMessage.DELIVERED: + msg_color = mdc(color_delivered, intensity_msgs) + heading_str = titlestr+"[b]Sent[/b] "+txstr+" [b]State[/b] Delivered" + + elif m["method"] == LXMF.LXMessage.PROPAGATED and m["state"] == LXMF.LXMessage.SENT: + msg_color = mdc(color_propagated, intensity_msgs) + heading_str = titlestr+"[b]Sent[/b] "+txstr+" [b]State[/b] On Propagation Net" + + elif m["state"] == LXMF.LXMessage.FAILED: + msg_color = mdc(color_failed, intensity_msgs) + heading_str = titlestr+"[b]Sent[/b] "+txstr+" [b]State[/b] Failed" + + elif m["state"] == LXMF.LXMessage.OUTBOUND or m["state"] == LXMF.LXMessage.SENDING: + msg_color = mdc(color_unknown, intensity_msgs) + heading_str = titlestr+"[b]Sent[/b] "+txstr+" [b]State[/b] Sending " + + else: + msg_color = mdc(color_unknown, intensity_msgs) + heading_str = titlestr+"[b]Sent[/b] "+txstr+" [b]State[/b] Unknown" + + else: + msg_color = mdc("Green", intensity_msgs) + heading_str = titlestr+"[b]Sent[/b] "+txstr+"\n[b]Received[/b] "+rxstr + + item = ListLXMessageCard( + text=m["content"].decode("utf-8"), + heading=heading_str, + md_bg_color=msg_color, + ) + item.sb_uid = m["hash"] + item.m = m + + def gen_del(mhash, item): + def x(): + yes_button = MDFlatButton( + text="Yes", + ) + no_button = MDFlatButton( + text="No", + ) + dialog = MDDialog( + text="Delete message?", + buttons=[ yes_button, no_button ], + ) + def dl_yes(s): + dialog.dismiss() + self.app.sideband.delete_message(mhash) + self.reload() + def dl_no(s): + dialog.dismiss() + + yes_button.bind(on_release=dl_yes) + no_button.bind(on_release=dl_no) + item.dmenu.dismiss() + dialog.open() + return x + + def gen_copy(msg, item): + def x(): + Clipboard.copy(msg) + RNS.log(str(item)) + item.dmenu.dismiss() + + return x + + dm_items = [ + { + "viewclass": "OneLineListItem", + "text": "Copy", + "height": dp(64), + "on_release": gen_copy(m["content"].decode("utf-8"), item) + }, + { + "text": "Delete", + "viewclass": "OneLineListItem", + "height": dp(64), + "on_release": gen_del(m["hash"], item) + } + ] + + item.dmenu = MDDropdownMenu( + caller=item.ids.msg_submenu, + items=dm_items, + position="center", + width_mult=4, + ) + + def callback_factory(ref): + def x(sender): + ref.dmenu.open() + return x + + # Bind menu open + item.ids.msg_submenu.bind(on_release=callback_factory(item)) + + self.added_item_hashes.append(m["hash"]) + self.widgets.append(item) + self.list.add_widget(item) + + if self.latest_message_timestamp == None or m["received"] > self.latest_message_timestamp: + self.latest_message_timestamp = m["received"] + + def get_widget(self): + return self.list + + def close_send_error_dialog(self, sender=None): + if self.send_error_dialog: + self.send_error_dialog.dismiss() \ No newline at end of file