diff --git a/sbapp/Makefile b/sbapp/Makefile index 5dbd321..4d2e7da 100644 --- a/sbapp/Makefile +++ b/sbapp/Makefile @@ -7,7 +7,7 @@ clean: -(rm ./__pycache__ -r) -(rm ./app_storage -r) -(rm ./share/pkg/* -r) - -(rm ./share/mirrors/* -r) + -(rm ./share/mirrors/* -rf) -(rm ./bin -r) cleanlibs: @@ -66,13 +66,14 @@ fetchshare: cp ../../dist_archive/lxmf-*-py3-none-any.whl ./share/pkg/ cp ../../dist_archive/nomadnet-*-py3-none-any.whl ./share/pkg/ cp ../../dist_archive/rnsh-*-py3-none-any.whl ./share/pkg/ -# cp ../../dist_archive/sbapp-*-py3-none-any.whl ./share/pkg/ cp ../../dist_archive/RNode_Firmware_*_Source.zip ./share/pkg/ zip --junk-paths ./share/pkg/example_plugins.zip ../docs/example_plugins/*.py cp -r ../../dist_archive/reticulum.network ./share/mirrors/ cp -r ../../dist_archive/unsigned.io ./share/mirrors/ cp ../../dist_archive/Reticulum\ Manual.pdf ./share/mirrors/Reticulum_Manual.pdf cp ../../dist_archive/Reticulum\ Manual.epub ./share/mirrors/Reticulum_Manual.epub + cp -r ../../rnode-flasher ./share/mirrors/ + -(rm ./share/mirrors/rnode-flasher/.git -rf) release: . venv/bin/activate; buildozer android release diff --git a/sbapp/assets/fonts/BigBlueTerm437NerdFont-Regular.ttf b/sbapp/assets/fonts/BigBlueTerm437NerdFont-Regular.ttf new file mode 100644 index 0000000..256563d Binary files /dev/null and b/sbapp/assets/fonts/BigBlueTerm437NerdFont-Regular.ttf differ diff --git a/sbapp/assets/fonts/RobotoMonoNerdFont-Regular.ttf b/sbapp/assets/fonts/RobotoMonoNerdFont-Regular.ttf new file mode 100644 index 0000000..11b95d3 Binary files /dev/null and b/sbapp/assets/fonts/RobotoMonoNerdFont-Regular.ttf differ diff --git a/sbapp/buildozer.spec b/sbapp/buildozer.spec index 93a0079..57d7d14 100644 --- a/sbapp/buildozer.spec +++ b/sbapp/buildozer.spec @@ -10,7 +10,7 @@ source.exclude_patterns = app_storage/*,venv/*,Makefile,./Makefil*,requirements, version.regex = __version__ = ['"](.*)['"] version.filename = %(source.dir)s/main.py -android.numeric_version = 20241020 +android.numeric_version = 20241213 requirements = kivy==2.3.0,libbz2,pillow==10.2.0,qrcode==7.3.1,usb4a,usbserial4a,able_recipe,libwebp,libogg,libopus,opusfile,numpy,cryptography,ffpyplayer,codec2,pycodec2,sh,pynacl,typing-extensions diff --git a/sbapp/main.py b/sbapp/main.py index 1491054..d6fd9d8 100644 --- a/sbapp/main.py +++ b/sbapp/main.py @@ -24,6 +24,9 @@ import base64 import threading import RNS.vendor.umsgpack as msgpack +WINDOW_DEFAULT_WIDTH = "494" +WINDOW_DEFAULT_HEIGHT = "800" + app_ui_scaling_path = None def apply_ui_scale(): global app_ui_scaling_path @@ -171,6 +174,11 @@ if not args.daemon: local = os.path.dirname(__file__) sys.path.append(local) + if not RNS.vendor.platformutils.is_android(): + from kivy.config import Config + Config.set("graphics", "width", WINDOW_DEFAULT_WIDTH) + Config.set("graphics", "height", WINDOW_DEFAULT_HEIGHT) + if args.daemon: from .sideband.core import SidebandCore class DaemonElement(): @@ -558,6 +566,15 @@ class SidebandApp(MDApp): fn_italic=fb_path+"NotoSans-Italic.ttf", fn_bolditalic=fb_path+"NotoSans-BoldItalic.ttf") + LabelBase.register(name="mono", + fn_regular=fb_path+"RobotoMonoNerdFont-Regular.ttf") + + LabelBase.register(name="term", + fn_regular=fb_path+"BigBlueTerm437NerdFont-Regular.ttf") + + LabelBase.register(name="nf", + fn_regular=fb_path+"RobotoMonoNerdFont-Regular.ttf") + def update_input_language(self): language = self.sideband.config["input_language"] if language == None: @@ -1406,6 +1423,8 @@ class SidebandApp(MDApp): self.close_sub_telemetry_action() elif self.root.ids.screen_manager.current == "rnstatus_screen": self.close_sub_utilities_action() + elif self.root.ids.screen_manager.current == "logviewer_screen": + self.close_sub_utilities_action() else: self.open_conversations(direction="right") @@ -2501,30 +2520,35 @@ class SidebandApp(MDApp): return "Could not retrieve connectivity status" def connectivity_status(self, sender): - hs = dp(22) + if RNS.vendor.platformutils.is_android(): + hs = dp(22) + yes_button = MDRectangleFlatButton(text="OK",font_size=dp(18)) + dialog = MDDialog( + title="Connectivity Status", + text=str(self.get_connectivity_text()), + buttons=[ yes_button ], + # elevation=0, + ) + def cs_updater(dt): + dialog.text = str(self.get_connectivity_text()) + def dl_yes(s): + self.connectivity_updater.cancel() + dialog.dismiss() + if self.connectivity_updater != None: + self.connectivity_updater.cancel() + + yes_button.bind(on_release=dl_yes) + dialog.open() - yes_button = MDRectangleFlatButton(text="OK",font_size=dp(18)) - dialog = MDDialog( - title="Connectivity Status", - text=str(self.get_connectivity_text()), - buttons=[ yes_button ], - # elevation=0, - ) - def cs_updater(dt): - dialog.text = str(self.get_connectivity_text()) - def dl_yes(s): - self.connectivity_updater.cancel() - dialog.dismiss() if self.connectivity_updater != None: self.connectivity_updater.cancel() - yes_button.bind(on_release=dl_yes) - dialog.open() + self.connectivity_updater = Clock.schedule_interval(cs_updater, 2.0) - if self.connectivity_updater != None: - self.connectivity_updater.cancel() - - self.connectivity_updater = Clock.schedule_interval(cs_updater, 2.0) + else: + if not self.utilities_ready: + self.utilities_init() + self.utilities_screen.rnstatus_action() def ingest_lxm_action(self, sender): def cb(dt): @@ -3009,6 +3033,11 @@ class SidebandApp(MDApp): self.sideband.save_configuration() self.update_ui_theme() + def save_classic_message_colors(sender=None, event=None): + self.sideband.config["classic_message_colors"] = self.settings_screen.ids.settings_classic_message_colors.active + self.sideband.save_configuration() + self.update_ui_theme() + def save_display_style_in_contact_list(sender=None, event=None): self.sideband.config["display_style_in_contact_list"] = self.settings_screen.ids.display_style_in_contact_list.active self.sideband.save_configuration() @@ -3019,6 +3048,10 @@ class SidebandApp(MDApp): self.sideband.save_configuration() self.sideband.setstate("wants.viewupdate.conversations", True) + def save_trusted_markup_only(sender=None, event=None): + self.sideband.config["trusted_markup_only"] = self.settings_screen.ids.settings_trusted_markup_only.active + self.sideband.save_configuration() + def save_advanced_stats(sender=None, event=None): self.sideband.config["advanced_stats"] = self.settings_screen.ids.settings_advanced_statistics.active self.sideband.save_configuration() @@ -3170,6 +3203,9 @@ class SidebandApp(MDApp): self.settings_screen.ids.settings_eink_mode.active = self.sideband.config["eink_mode"] self.settings_screen.ids.settings_eink_mode.bind(active=save_eink_mode) + self.settings_screen.ids.settings_classic_message_colors.active = self.sideband.config["classic_message_colors"] + self.settings_screen.ids.settings_classic_message_colors.bind(active=save_classic_message_colors) + self.settings_screen.ids.display_style_in_contact_list.active = self.sideband.config["display_style_in_contact_list"] self.settings_screen.ids.display_style_in_contact_list.bind(active=save_display_style_in_contact_list) @@ -3191,6 +3227,9 @@ class SidebandApp(MDApp): self.settings_screen.ids.settings_lxmf_ignore_unknown.active = self.sideband.config["lxmf_ignore_unknown"] self.settings_screen.ids.settings_lxmf_ignore_unknown.bind(active=save_lxmf_ignore_unknown) + self.settings_screen.ids.settings_trusted_markup_only.active = self.sideband.config["trusted_markup_only"] + self.settings_screen.ids.settings_trusted_markup_only.bind(active=save_trusted_markup_only) + self.settings_screen.ids.settings_ignore_invalid_stamps.active = self.sideband.config["lxmf_ignore_invalid_stamps"] self.settings_screen.ids.settings_ignore_invalid_stamps.bind(active=save_lxmf_ignore_invalid_stamps) @@ -3639,6 +3678,7 @@ class SidebandApp(MDApp): ###################################### def repository_action(self, sender=None, direction="left"): if self.repository_ready: + self.repository_update_info() self.repository_open(direction=direction) else: self.loader_action(direction=direction) @@ -3675,9 +3715,9 @@ class SidebandApp(MDApp): info += "If you want to share the openCom Companion application itself via the repository server, you must first download it into the local repository, using the \"Update Content\" button below.\n\n" info += "To make the repository available on your local network, simply start it below, and it will become browsable on a local IP address for anyone connected to the same WiFi or wired network.\n\n" if self.sideband.webshare_server != None: - if RNS.vendor.platformutils.is_android(): - def getIP(): - adrs = [] + def getIP(): + adrs = [] + if RNS.vendor.platformutils.is_android(): try: from jnius import autoclass import ipaddress @@ -3700,24 +3740,30 @@ class SidebandApp(MDApp): RNS.log("Error while getting repository IP address: "+str(e), RNS.LOG_ERROR) return None - return adrs - - ips = getIP() - if ips == None or len(ips) == 0: - info += "The repository server is running, but the local device IP address could not be determined.\n\nYou can access the repository by pointing a browser to: http://DEVICE_IP:4444/" - self.reposository_url = None else: - ipstr = "" - for ip in ips: - ipstr += "http://"+str(ip)+":4444/\n" - self.reposository_url = ipstr + import socket + adrs.append(socket.gethostbyname(socket.gethostname())) - ms = "" if len(ips) == 1 else "es" - info += "The repository server is running at the following address"+ms+":\n [u][ref=link]"+ipstr+"[/ref][u]" - self.repository_screen.ids.repository_info.bind(on_ref_press=self.repository_link_action) + return adrs - self.repository_screen.ids.repository_enable_button.disabled = True - self.repository_screen.ids.repository_disable_button.disabled = False + ips = getIP() + if ips == None or len(ips) == 0: + info += "The repository server is running, but the local device IP address could not be determined.\n\nYou can access the repository by pointing a browser to: https://DEVICE_IP:4444/" + self.reposository_url = None + else: + ipstr = "" + for ip in ips: + ipstr += "https://"+str(ip)+":4444/\n" + self.reposository_url = ipstr + + ms = "" if len(ips) == 1 else "es" + info += "The repository server is running at the following address"+ms+":\n [u][ref=link]"+ipstr+"[/ref][u]" + self.repository_screen.ids.repository_info.bind(on_ref_press=self.repository_link_action) + + def cb(dt): + self.repository_screen.ids.repository_enable_button.disabled = True + self.repository_screen.ids.repository_disable_button.disabled = False + Clock.schedule_once(cb, 0.1) else: self.repository_screen.ids.repository_enable_button.disabled = False @@ -3739,39 +3785,85 @@ class SidebandApp(MDApp): def update_job(sender=None): try: import requests + ### RNode Firmwares ########### + if True: + downloads = [] + try: + release_url = "https://api.github.com/repos/markqvist/rnode_firmware/releases" + with requests.get(release_url) as response: + releases = response.json() + release = releases[0] + assets = release["assets"] + for asset in assets: + if asset["name"].lower().startswith("rnode_firmware"): + fw_url = asset["browser_download_url"] + pkgname = asset["name"] + fw_version = release["tag_name"] + RNS.log(f"Found version {fw_version} artefact {pkgname} at {fw_url}", RNS.LOG_DEBUG) + downloads.append([fw_url, pkgname, fw_version]) - # Get release info - apk_version = None - apk_url = None - pkgname = None - try: - release_url = "https://api.github.com/repos/markqvist/sideband/releases" - with requests.get(release_url) as response: - releases = response.json() - release = releases[0] - assets = release["assets"] - for asset in assets: - if asset["name"].lower().endswith(".apk"): - apk_url = asset["browser_download_url"] - pkgname = asset["name"] - apk_version = release["tag_name"] - RNS.log(f"Found version {apk_version} artefact {pkgname} at {apk_url}") - except Exception as e: - self.repository_screen.ids.repository_update.text = f"Downloading release info failed with the error:\n"+str(e) - return + except Exception as e: + self.repository_screen.ids.repository_update.text = f"Downloading RNode firmware release info failed with the error:\n"+str(e) + return - self.repository_screen.ids.repository_update.text = "Downloading: "+str(apk_url) - with requests.get(apk_url, stream=True) as response: - with open("./dl_tmp", "wb") as tmp_file: - cs = 32*1024 - tds = 0 - for chunk in response.iter_content(chunk_size=cs): - tmp_file.write(chunk) - tds += cs - self.repository_screen.ids.repository_update.text = "Downloaded "+RNS.prettysize(tds)+" of "+str(pkgname) + try: + for download in downloads: + fw_url = download[0] + pkgname = download[1] + self.repository_screen.ids.repository_update.text = "Downloading: "+str(pkgname) + with requests.get(fw_url, stream=True) as response: + with open("./dl_tmp", "wb") as tmp_file: + cs = 32*1024 + tds = 0 + for chunk in response.iter_content(chunk_size=cs): + tmp_file.write(chunk) + tds += cs + self.repository_screen.ids.repository_update.text = "Downloaded "+RNS.prettysize(tds)+" of "+str(pkgname) + + os.rename("./dl_tmp", f"{self.sideband.webshare_dir}/pkg/{pkgname}") + self.repository_screen.ids.repository_update.text = f"Added {pkgname} to the repository!" + + except Exception as e: + self.repository_screen.ids.repository_update.text = f"Downloading RNode firmware failed with the error:\n"+str(e) + return + + ### Sideband APK File ######### + if True: + # Get release info + apk_version = None + apk_url = None + pkgname = None + try: + release_url = "https://api.github.com/repos/markqvist/sideband/releases" + with requests.get(release_url) as response: + releases = response.json() + release = releases[0] + assets = release["assets"] + for asset in assets: + if asset["name"].lower().endswith(".apk"): + apk_url = asset["browser_download_url"] + pkgname = asset["name"] + apk_version = release["tag_name"] + RNS.log(f"Found version {apk_version} artefact {pkgname} at {apk_url}", RNS.LOG_DEBUG) + except Exception as e: + self.repository_screen.ids.repository_update.text = f"Downloading Sideband APK release info failed with the error:\n"+str(e) + return + + self.repository_screen.ids.repository_update.text = "Downloading: "+str(pkgname) + with requests.get(apk_url, stream=True) as response: + with open("./dl_tmp", "wb") as tmp_file: + cs = 32*1024 + tds = 0 + for chunk in response.iter_content(chunk_size=cs): + tmp_file.write(chunk) + tds += cs + self.repository_screen.ids.repository_update.text = "Downloaded "+RNS.prettysize(tds)+" of "+str(pkgname) + + os.rename("./dl_tmp", f"{self.sideband.webshare_dir}/pkg/{pkgname}") + self.repository_screen.ids.repository_update.text = f"Added {pkgname} to the repository!" + + self.repository_screen.ids.repository_update.text = f"Repository contents updated successfully!" - os.rename("./dl_tmp", f"./share/pkg/{pkgname}") - self.repository_screen.ids.repository_update.text = f"Added {pkgname} to the repository!" except Exception as e: self.repository_screen.ids.repository_update.text = f"Downloading contents failed with the error:\n"+str(e) @@ -3788,15 +3880,7 @@ class SidebandApp(MDApp): self.root.ids.screen_manager.add_widget(self.repository_screen) self.repository_screen.ids.repository_scrollview.effect_cls = ScrollEffect - self.repository_update_info() - - if not RNS.vendor.platformutils.is_android(): - self.widget_hide(self.repository_screen.ids.repository_enable_button) - self.widget_hide(self.repository_screen.ids.repository_disable_button) - self.widget_hide(self.repository_screen.ids.repository_download_button) - self.repository_screen.ids.repository_info.text = "\nThe [b]Repository Webserver[/b] feature is currently only available on mobile devices." - self.repository_ready = True def close_repository_action(self, sender=None): diff --git a/sbapp/share/flasher.html b/sbapp/share/flasher.html new file mode 100644 index 0000000..9033cc9 --- /dev/null +++ b/sbapp/share/flasher.html @@ -0,0 +1,33 @@ + + + + + + +Sideband Repository + + + + +

Start | Software | RNode Flasher | Guides


+
Sideband includes a copy of the web-based RNode Flasher developed by Liam Cottle. You can use this flasher to install and provision the RNode firmware on any compatible boards.
+
+Please note! Your browser must support Web-USB for this to work.
+
+To use the flasher, you will need firmware packages for the boards you want use. You can obtain these in different ways: + +
+
+
+
+

+ diff --git a/sbapp/share/guides.html b/sbapp/share/guides.html index d318c35..dcae795 100644 --- a/sbapp/share/guides.html +++ b/sbapp/share/guides.html @@ -9,7 +9,7 @@ -

Start | Software | Guides


+

Start | Software | RNode Flasher | Guides



Welcome to the Guide Section!

From here, you can browse or download various included manuals, documentation, references and guides. +

diff --git a/sbapp/share/index.html b/sbapp/share/index.html index 34f6a62..157ea59 100644 --- a/sbapp/share/index.html +++ b/sbapp/share/index.html @@ -9,8 +9,9 @@ +<<<<<<< HEAD -

Start | Software | Guides


Hello!

+

Start | Software | RNode Flasher |Guides


Hello!

diff --git a/sbapp/share/pkgs.html b/sbapp/share/pkgs.html index 3c548ef..866346f 100644 --- a/sbapp/share/pkgs.html +++ b/sbapp/share/pkgs.html @@ -9,10 +9,10 @@ -

Start | Software | Guides


+

Start | Software | RNode Flasher | Guides



Welcome to the Software Library!

From here, you can download installable Python Wheel packages of Reticulum and various other auxillary programs and utilities. +

diff --git a/sbapp/sideband/certgen.py b/sbapp/sideband/certgen.py new file mode 100644 index 0000000..70e4802 --- /dev/null +++ b/sbapp/sideband/certgen.py @@ -0,0 +1,101 @@ +# MIT License +# +# Copyright (c) 2024 Mark Qvist / unsigned.io. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +KEY_PASSPHRASE = None +LOADED_KEY = None + +import os +import RNS +import datetime +from cryptography import x509 +from cryptography.x509.oid import NameOID +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.serialization import load_pem_private_key + +from cryptography import __version__ as cryptography_version_str +try: + cryptography_major_version = int(cryptography_version_str.split(".")[0]) +except: + RNS.log(f"Could not determine PyCA/cryptography version: {e}", RNS.LOG_ERROR) + RNS.log(f"Assuming recent version with automatic backend selection", RNS.LOG_ERROR) + +def get_key(key_path, force_reload=False): + KEY_PATH = key_path + key = None + if LOADED_KEY != None and not force_reload: + return LOADED_KEY + elif os.path.isfile(KEY_PATH): + with open(KEY_PATH, "rb") as f: + key = load_pem_private_key(f.read(), KEY_PASSPHRASE) + else: + if cryptography_major_version > 3: + key = ec.generate_private_key(curve=ec.SECP256R1()) + else: + from cryptography.hazmat.backends import default_backend + key = ec.generate_private_key(curve=ec.SECP256R1(), backend=default_backend()) + + if KEY_PASSPHRASE == None: + key_encryption = serialization.NoEncryption() + else: + key_encryption = serialization.BestAvailableEncryption(KEY_PASSPHRASE) + + with open(KEY_PATH, "wb") as f: + f.write(key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=key_encryption)) + + return key + +def gen_cert(cert_path, key): + CERT_PATH = cert_path + cert_attrs = [x509.NameAttribute(NameOID.COUNTRY_NAME, "NA"), + x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, "None"), + x509.NameAttribute(NameOID.LOCALITY_NAME, "Earth"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Sideband"), + x509.NameAttribute(NameOID.COMMON_NAME, "Sideband Repository")] + + issuer = x509.Name(cert_attrs) + subject = issuer + + cb = x509.CertificateBuilder() + cb = cb.subject_name(subject) + cb = cb.issuer_name(issuer) + cb = cb.public_key(key.public_key()) + cb = cb.serial_number(x509.random_serial_number()) + cb = cb.not_valid_before(datetime.datetime.now(datetime.timezone.utc)+datetime.timedelta(days=-14)) + cb = cb.not_valid_after(datetime.datetime.now(datetime.timezone.utc)+datetime.timedelta(days=3652)) + cb = cb.add_extension(x509.SubjectAlternativeName([x509.DNSName("localhost")]), critical=False) + if cryptography_major_version > 3: + cert = cb.sign(key, hashes.SHA256()) + else: + from cryptography.hazmat.backends import default_backend + cert = cb.sign(key, hashes.SHA256(), backend=default_backend()) + + with open(CERT_PATH, "wb") as f: + f.write(cert.public_bytes(serialization.Encoding.PEM)) + +def ensure_certificate(key_path, cert_path): + gen_cert(cert_path, get_key(key_path)) + return cert_path \ No newline at end of file diff --git a/sbapp/sideband/core.py b/sbapp/sideband/core.py index 7307e9d..7273e2a 100644 --- a/sbapp/sideband/core.py +++ b/sbapp/sideband/core.py @@ -16,6 +16,7 @@ import multiprocessing.connection from copy import deepcopy from threading import Lock +from collections import deque from .res import sideband_fb_data from .sense import Telemeter, Commands from .plugins import SidebandCommandPlugin, SidebandServicePlugin, SidebandTelemetryPlugin @@ -44,7 +45,7 @@ class PropagationNodeDetector(): aspect_filter = "lxmf.propagation" - def received_announce(self, destination_hash, announced_identity, app_data): + def received_announce(self, destination_hash, announced_identity, app_data, announce_packet_hash): try: if app_data != None and len(app_data) > 0: if pn_announce_data_is_valid(app_data): @@ -63,8 +64,12 @@ class PropagationNodeDetector(): # age = 0 pass + link_stats = {"rssi": self.owner_app.sideband.reticulum.get_packet_rssi(announce_packet_hash), + "snr": self.owner_app.sideband.reticulum.get_packet_snr(announce_packet_hash), + "q": self.owner_app.sideband.reticulum.get_packet_q(announce_packet_hash)} + RNS.log("Detected active propagation node "+RNS.prettyhexrep(destination_hash)+" emission "+str(age)+" seconds ago, "+str(hops)+" hops away") - self.owner.log_announce(destination_hash, app_data, dest_type=PropagationNodeDetector.aspect_filter) + self.owner.log_announce(destination_hash, app_data, dest_type=PropagationNodeDetector.aspect_filter, link_stats=link_stats) if self.owner.config["lxmf_propagation_node"] == None: if self.owner.active_propagation_node == None: @@ -110,11 +115,17 @@ class SidebandCore(): DEFAULT_APPEARANCE = ["account", [0,0,0,1], [1,1,1,1]] + LOG_DEQUE_MAXLEN = 128 + aspect_filter = "lxmf.delivery" - def received_announce(self, destination_hash, announced_identity, app_data): + def received_announce(self, destination_hash, announced_identity, app_data, announce_packet_hash): # Add the announce to the directory announce # stream logger + link_stats = {"rssi": self.reticulum.get_packet_rssi(announce_packet_hash), + "snr": self.reticulum.get_packet_snr(announce_packet_hash), + "q": self.reticulum.get_packet_q(announce_packet_hash)} + # This reformats the new v0.5.0 announce data back to the expected format # for Sidebands database and other handling functions. dn = LXMF.display_name_from_app_data(app_data) @@ -123,7 +134,7 @@ class SidebandCore(): if dn != None: app_data = dn.encode("utf-8") - self.log_announce(destination_hash, app_data, dest_type=SidebandCore.aspect_filter, stamp_cost=sc) + self.log_announce(destination_hash, app_data, dest_type=SidebandCore.aspect_filter, stamp_cost=sc, link_stats=link_stats) def __init__(self, owner_app, config_path = None, is_service=False, is_client=False, android_app_dir=None, verbose=False, owner_service=None, service_context=None, is_daemon=False, load_config_only=False): self.is_service = is_service @@ -142,6 +153,7 @@ class SidebandCore(): self.is_standalone = False self.log_verbose = verbose + self.log_deque = deque(maxlen=self.LOG_DEQUE_MAXLEN) self.owner_app = owner_app self.reticulum = None self.webshare_server = None @@ -156,6 +168,7 @@ class SidebandCore(): self.telemetry_send_blocked_until = 0 self.pending_telemetry_request = False self.telemetry_request_max_history = 7*24*60*60 + self.live_tracked_objects = {} self.default_lxm_limit = 128*1000 self.state_db = {} self.state_lock = Lock() @@ -167,6 +180,7 @@ class SidebandCore(): self.owner_service = owner_service self.allow_service_dispatch = True self.version_str = "" + self.config_template = rns_config if config_path == None: self.app_dir = plyer.storagepath.get_home_dir()+"/.config/occ" @@ -228,7 +242,14 @@ class SidebandCore(): self.log_dir = self.app_dir+"/app_storage/" self.tmp_dir = self.app_dir+"/app_storage/tmp" self.exports_dir = self.app_dir+"/exports" - self.webshare_dir = "./share/" + if RNS.vendor.platformutils.is_android(): + self.webshare_dir = "./share/" + else: + sideband_dir = os.path.dirname(os.path.abspath(__file__)) + self.webshare_dir = os.path.abspath(os.path.join(sideband_dir, "..", "share")) + + self.webshare_ssl_key_path = self.app_dir+"/app_storage/ssl_key.pem" + self.webshare_ssl_cert_path = self.app_dir+"/app_storage/ssl_cert.pem" self.first_run = True self.saving_configuration = False @@ -267,6 +288,29 @@ class SidebandCore(): if load_config_only: return + if RNS.vendor.platformutils.is_android(): + if self.config["config_template"] != None: + try: + if not os.path.isfile(self.rns_configdir+"/config_template_invalid"): + if self.is_service: + with open(self.rns_configdir+"/config_template_invalid", "w") as invalidation_file: + invalidation_file.write("\n") + + ct = self.config["config_template"] + RNS.log(f"Loading modified RNS config template", RNS.LOG_WARNING) + self.config_template = ct + + else: + RNS.log("Custom configuration template invalid, using default configuration template", RNS.LOG_WARNING) + self.config_template = rns_config + if self.is_service: + self.setstate("hardware_operation.error", "At the previous start, Sideband could not initialise Reticulum. Custom configuration template loading has been temporarily disabled. Please check and fix any errors in your configuration template.") + + except Exception as e: + RNS.log(f"An error occurred while setting RNS configuration template: {e}", RNS.LOG_ERROR) + RNS.log(f"Using default configuration template", RNS.LOG_ERROR) + self.config_template = rns_config + # Initialise Reticulum configuration if RNS.vendor.platformutils.get_platform() == "android": try: @@ -277,11 +321,10 @@ class SidebandCore(): RNS.log("Configuring Reticulum instance...") if self.config["connect_transport"]: RNS.log("Enabling Reticulum Transport") - generated_config = rns_config.replace("TRANSPORT_IS_ENABLED", "Yes") + generated_config = self.config_template.replace("TRANSPORT_IS_ENABLED", "Yes") else: RNS.log("Not enabling Reticulum Transport") - generated_config = rns_config.replace("TRANSPORT_IS_ENABLED", "No") - + generated_config = self.config_template.replace("TRANSPORT_IS_ENABLED", "No") config_file = open(self.rns_configdir+"/config", "wb") config_file.write(generated_config.encode("utf-8")) @@ -387,7 +430,7 @@ class SidebandCore(): self.config["debug"] = False self.config["display_name"] = "Anonymous Peer" self.config["notifications_on"] = True - self.config["dark_ui"] = False + self.config["dark_ui"] = True self.config["start_announce"] = True self.config["propagation_by_default"] = False self.config["home_node_as_broadcast_repeater"] = False @@ -403,8 +446,9 @@ class SidebandCore(): self.config["last_lxmf_propagation_node"] = None self.config["nn_home_node"] = None self.config["print_command"] = "lp" - self.config["eink_mode"] = False + self.config["eink_mode"] = True self.config["lxm_limit_1mb"] = True + self.config["trusted_markup_only"] = False # Connectivity self.config["connect_transport"] = False @@ -569,7 +613,7 @@ class SidebandCore(): if not "dark_ui" in self.config: self.config["dark_ui"] = True if not "advanced_stats" in self.config: - self.config["advanced_stats"] = False + self.config["advanced_stats"] = True if not "lxmf_periodic_sync" in self.config: self.config["lxmf_periodic_sync"] = False if not "lxmf_ignore_unknown" in self.config: @@ -589,13 +633,17 @@ class SidebandCore(): if not "print_command" in self.config: self.config["print_command"] = "lp" if not "eink_mode" in self.config: - self.config["eink_mode"] = False + self.config["eink_mode"] = True + if not "classic_message_colors" in self.config: + self.config["classic_message_colors"] = False if not "display_style_in_contact_list" in self.config: - self.config["display_style_in_contact_list"] = False + self.config["display_style_in_contact_list"] = True if not "lxm_limit_1mb" in self.config: self.config["lxm_limit_1mb"] = True if not "hq_ptt" in self.config: self.config["hq_ptt"] = False + if not "trusted_markup_only" in self.config: + self.config["trusted_markup_only"] = False if not "input_language" in self.config: self.config["input_language"] = None @@ -604,6 +652,8 @@ class SidebandCore(): if not "block_predictive_text" in self.config: self.config["block_predictive_text"] = False + if not "config_template" in self.config: + self.config["config_template"] = None if not "connect_transport" in self.config: self.config["connect_transport"] = False if not "connect_rnode" in self.config: @@ -739,11 +789,11 @@ class SidebandCore(): if not "telemetry_bg" in self.config: self.config["telemetry_bg"] = SidebandCore.DEFAULT_APPEARANCE[2] if not "telemetry_send_appearance" in self.config: - self.config["telemetry_send_appearance"] = False + self.config["telemetry_send_appearance"] = True if not "telemetry_display_trusted_only" in self.config: self.config["telemetry_display_trusted_only"] = False if not "display_style_from_all" in self.config: - self.config["display_style_from_all"] = False + self.config["display_style_from_all"] = True if not "telemetry_receive_trusted_only" in self.config: self.config["telemetry_receive_trusted_only"] = False @@ -854,9 +904,8 @@ class SidebandCore(): time.sleep(0.15) try: self.saving_configuration = True - config_file = open(self.config_path, "wb") - config_file.write(msgpack.packb(self.config)) - config_file.close() + with open(self.config_path, "wb") as config_file: + config_file.write(msgpack.packb(self.config)) self.saving_configuration = False except Exception as e: self.saving_configuration = False @@ -987,14 +1036,14 @@ class SidebandCore(): else: plyer.notification.notify(title, content, app_icon=self.icon_32) - def log_announce(self, dest, app_data, dest_type, stamp_cost=None): + def log_announce(self, dest, app_data, dest_type, stamp_cost=None, link_stats=None): try: if app_data == None: app_data = b"" if type(app_data) != bytes: app_data = msgpack.packb([app_data, stamp_cost]) RNS.log("Received "+str(dest_type)+" announce for "+RNS.prettyhexrep(dest)+" with data: "+str(app_data), RNS.LOG_DEBUG) - self._db_save_announce(dest, app_data, dest_type) + self._db_save_announce(dest, app_data, dest_type, link_stats) self.setstate("app.flags.new_announces", True) except Exception as e: @@ -1316,7 +1365,7 @@ class SidebandCore(): else: return False - def request_latest_telemetry(self, from_addr=None): + def request_latest_telemetry(self, from_addr=None, is_livetrack=False): if self.allow_service_dispatch and self.is_client: try: return self._service_request_latest_telemetry(from_addr) @@ -1350,7 +1399,11 @@ class SidebandCore(): if self.config["telemetry_use_propagation_only"] == True: desired_method = LXMF.LXMessage.PROPAGATED else: - desired_method = LXMF.LXMessage.DIRECT + if not self.message_router.delivery_link_available(from_addr) and RNS.Identity.current_ratchet_id(from_addr) != None: + RNS.log(f"Have ratchet for {RNS.prettyhexrep(from_addr)}, requesting opportunistic delivery of telemetry request", RNS.LOG_DEBUG) + desired_method = LXMF.LXMessage.OPPORTUNISTIC + else: + desired_method = LXMF.LXMessage.DIRECT request_timebase = self.getpersistent(f"telemetry.{RNS.hexrep(from_addr, delimit=False)}.timebase") or now - self.telemetry_request_max_history lxm_fields = { LXMF.FIELD_COMMANDS: [ @@ -1363,7 +1416,7 @@ class SidebandCore(): lxm.register_failed_callback(self.telemetry_request_finished) if self.message_router.get_outbound_propagation_node() != None: - if self.config["telemetry_try_propagation_on_fail"]: + if self.config["telemetry_try_propagation_on_fail"] and not is_livetrack: lxm.try_propagation_on_fail = True RNS.log(f"Sending telemetry request with timebase {request_timebase}", RNS.LOG_DEBUG) @@ -1375,6 +1428,62 @@ class SidebandCore(): else: return "not_sent" + def _is_tracking(self, object_addr): + return object_addr in self.live_tracked_objects + + def is_tracking(self, object_addr, allow_cache=False): + if not RNS.vendor.platformutils.is_android(): + return self._is_tracking(object_addr) + else: + if self.is_service: + return self._is_tracking(object_addr) + else: + try: + return self.service_rpc_request({"is_tracking": object_addr}) + except Exception as e: + ed = "Error while getting tracking state over RPC: "+str(e) + RNS.log(ed, RNS.LOG_DEBUG) + return ed + + def _start_tracking(self, object_addr, interval, duration): + RNS.log("Starting tracking of "+RNS.prettyhexrep(object_addr), RNS.LOG_DEBUG) + self.live_tracked_objects[object_addr] = [interval, 0, time.time()+duration] + + def start_tracking(self, object_addr, interval, duration, allow_cache=False): + if not RNS.vendor.platformutils.is_android(): + return self._start_tracking(object_addr, interval, duration) + else: + if self.is_service: + return self._start_tracking(object_addr, interval, duration) + else: + try: + args = {"object_addr": object_addr, "interval": interval, "duration": duration} + return self.service_rpc_request({"start_tracking": args}) + except Exception as e: + ed = "Error while starting tracking over RPC: "+str(e) + RNS.log(ed, RNS.LOG_DEBUG) + return ed + + def _stop_tracking(self, object_addr): + RNS.log("Stopping tracking of "+RNS.prettyhexrep(object_addr), RNS.LOG_DEBUG) + if object_addr in self.live_tracked_objects: + self.live_tracked_objects.pop(object_addr) + + def stop_tracking(self, object_addr, allow_cache=False): + if not RNS.vendor.platformutils.is_android(): + return self._stop_tracking(object_addr) + else: + if self.is_service: + return self._stop_tracking(object_addr) + else: + try: + args = {"object_addr": object_addr} + return self.service_rpc_request({"stop_tracking": args}) + except Exception as e: + ed = "Error while stopping tracking over RPC: "+str(e) + RNS.log(ed, RNS.LOG_DEBUG) + return ed + def _service_send_latest_telemetry(self, to_addr=None, stream=None, is_authorized_telemetry_request=False): if not RNS.vendor.platformutils.is_android(): return False @@ -1430,7 +1539,11 @@ class SidebandCore(): if self.config["telemetry_use_propagation_only"] == True: desired_method = LXMF.LXMessage.PROPAGATED else: - desired_method = LXMF.LXMessage.DIRECT + if not self.message_router.delivery_link_available(to_addr) and RNS.Identity.current_ratchet_id(to_addr) != None: + RNS.log(f"Have ratchet for {RNS.prettyhexrep(to_addr)}, requesting opportunistic delivery of telemetry", RNS.LOG_DEBUG) + desired_method = LXMF.LXMessage.OPPORTUNISTIC + else: + desired_method = LXMF.LXMessage.DIRECT lxm_fields = self.get_message_fields(to_addr, is_authorized_telemetry_request=is_authorized_telemetry_request, signal_already_sent=True) if lxm_fields == False and stream == None: @@ -1841,6 +1954,14 @@ class SidebandCore(): elif "get_lxm_stamp_cost" in call: args = call["get_lxm_stamp_cost"] connection.send(self.get_lxm_stamp_cost(args["lxm_hash"])) + elif "is_tracking" in call: + connection.send(self.is_tracking(call["is_tracking"])) + elif "start_tracking" in call: + args = call["start_tracking"] + connection.send(self.start_tracking(object_addr=args["object_addr"], interval=args["interval"], duration=args["duration"])) + elif "stop_tracking" in call: + args = call["stop_tracking"] + connection.send(self.stop_tracking(object_addr=args["object_addr"])) else: connection.send(None) @@ -1934,10 +2055,10 @@ class SidebandCore(): # TODO: Remove this again at some point in the future db = self.__db_connect() dbc = db.cursor() - dbc.execute("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'lxm' AND sql LIKE '%extra%'") + dbc.execute("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'announce' AND sql LIKE '%extra%'") result = dbc.fetchall() if len(result) == 0: - dbc.execute("ALTER TABLE lxm ADD COLUMN extra BLOB") + dbc.execute("ALTER TABLE announce ADD COLUMN extra BLOB") db.commit() def _db_initstate(self): @@ -2494,8 +2615,16 @@ class SidebandCore(): for entry in result: try: if not entry[2] in added_dests: - app_data = entry[3] + app_data = entry[3] dest_type = entry[4] + if entry[5] != None: + try: + extras = msgpack.unpackb(entry[5]) + except Exception as e: + RNS.log(f"Error while unpacking extras from announce: {e}", RNS.LOG_ERROR) + extras = None + else: + extras = None if dest_type == "lxmf.delivery": announced_name = LXMF.display_name_from_app_data(app_data) announced_cost = self.message_router.get_outbound_stamp_cost(entry[2]) @@ -2503,11 +2632,12 @@ class SidebandCore(): announced_name = None announced_cost = None announce = { - "dest": entry[2], - "name": announced_name, - "cost": announced_cost, - "time": entry[1], - "type": dest_type + "dest" : entry[2], + "name" : announced_name, + "cost" : announced_cost, + "time" : entry[1], + "type" : dest_type, + "extras": extras, } added_dests.append(entry[2]) announces.append(announce) @@ -2921,7 +3051,7 @@ class SidebandCore(): self.__event_conversation_changed(context_dest) - def _db_save_announce(self, destination_hash, app_data, dest_type="lxmf.delivery"): + def _db_save_announce(self, destination_hash, app_data, dest_type="lxmf.delivery", link_stats = None): with self.db_lock: db = self.__db_connect() dbc = db.cursor() @@ -2935,14 +3065,16 @@ class SidebandCore(): now = time.time() hash_material = str(time).encode("utf-8")+destination_hash+app_data+dest_type.encode("utf-8") announce_hash = RNS.Identity.full_hash(hash_material) + extras = msgpack.packb({"link_stats": link_stats}) - query = "INSERT INTO announce (id, received, source, data, dest_type) values (?, ?, ?, ?, ?)" + query = "INSERT INTO announce (id, received, source, data, dest_type, extra) values (?, ?, ?, ?, ?, ?)" data = ( announce_hash, now, destination_hash, app_data, dest_type, + extras, ) dbc.execute(query, data) @@ -3466,6 +3598,28 @@ class SidebandCore(): except Exception as e: RNS.log("An error occurred while requesting scheduled telemetry from collector: "+str(e), RNS.LOG_ERROR) + stale_entries = [] + if len(self.live_tracked_objects) > 0: + now = time.time() + for object_hash in self.live_tracked_objects: + tracking_entry = self.live_tracked_objects[object_hash] + tracking_int = tracking_entry[0] + tracking_last = tracking_entry[1] + tracking_end = tracking_entry[2] + + if now < tracking_end: + if now > tracking_last+tracking_int: + RNS.log("Next live tracking request time reached for "+str(RNS.prettyhexrep(object_hash))) + self.request_latest_telemetry(from_addr=object_hash, is_livetrack=True) + tracking_entry[1] = time.time() + else: + stale_entries.append(object_hash) + + for object_hash in stale_entries: + RNS.log("Terminating live tracking for "+RNS.prettyhexrep(object_hash)+", tracking duration reached", RNS.LOG_DEBUG) + self.live_tracked_objects.pop(object_hash) + + def __start_jobs_deferred(self): if self.is_service: self.service_thread = threading.Thread(target=self._service_jobs, daemon=True) @@ -3724,6 +3878,14 @@ class SidebandCore(): if self.is_client: self.service_rpc_set_debug(debug) + def _log_handler(self, message): + self.log_deque.append(message) + print(message) + + # TODO: Get service log on Android + def get_log(self): + return "\n".join(self.log_deque) + def __start_jobs_immediate(self): if self.log_verbose: selected_level = 7 # debugging purposes @@ -3731,7 +3893,20 @@ class SidebandCore(): selected_level = 2 self.setstate("init.loadingstate", "Substantiating Reticulum") - self.reticulum = RNS.Reticulum(configdir=self.rns_configdir, loglevel=selected_level) + + try: + self.reticulum = RNS.Reticulum(configdir=self.rns_configdir, loglevel=selected_level, logdest=self._log_handler) + if RNS.vendor.platformutils.is_android(): + if self.is_service: + if os.path.isfile(self.rns_configdir+"/config_template_invalid"): + os.unlink(self.rns_configdir+"/config_template_invalid") + else: + pass + + except Exception as e: + RNS.log(f"Error while instantiating Reticulum: {e}", RNS.LOG_ERROR) + RNS.log(f"Local configuration template changes will be ignored on next start", RNS.LOG_ERROR) + exit(255) if self.is_service: self.__start_rpc_listener() @@ -4615,6 +4790,7 @@ class SidebandCore(): from http import server import socketserver import json + import ssl webshare_dir = self.webshare_dir port = 4444 @@ -4635,7 +4811,7 @@ class SidebandCore(): self.send_response(200) self.send_header("Content-type", "text/json") self.end_headers() - json_result = json.dumps(os.listdir(serve_root+"/pkg")) + json_result = json.dumps(sorted(os.listdir(serve_root+"/pkg"))) self.wfile.write(json_result.encode("utf-8")) except Exception as e: self.send_response(500) @@ -4650,6 +4826,8 @@ class SidebandCore(): self.send_response(200) if path.lower().endswith(".apk"): self.send_header("Content-type", "application/vnd.android.package-archive") + elif path.lower().endswith(".js"): + self.send_header("Content-type", "text/javascript") self.end_headers() self.wfile.write(data) except Exception as e: @@ -4659,7 +4837,36 @@ class SidebandCore(): es = "Error" self.wfile.write(es.encode("utf-8")) - with socketserver.TCPServer(("", port), RequestHandler) as webserver: + ####################################################### + # Override BaseHTTPRequestHandler method to squelch + # excessive exception logging when client signals + # invalid certificate to the server. This will always + # happen from some clients when using a self-signed + # certificate, so we don't care. + if not hasattr(server.BaseHTTPRequestHandler, "handle_orig"): + server.BaseHTTPRequestHandler.handle_orig = server.BaseHTTPRequestHandler.handle + def handle(self): + try: + self.handle_orig() + except ssl.SSLError: + pass + except Exception as e: + RNS.log("HTTP server exception: "+str(e), RNS.LOG_ERROR) + server.BaseHTTPRequestHandler.handle = handle + ####################################################### + + socketserver.TCPServer.allow_reuse_address = True + class ThreadedHTTPServer(socketserver.ThreadingMixIn, server.HTTPServer): + daemon_threads = True + + with ThreadedHTTPServer(("", port), RequestHandler) as webserver: + from sideband.certgen import ensure_certificate + + ensure_certificate(self.webshare_ssl_key_path, self.webshare_ssl_cert_path) + ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + ssl_context.load_cert_chain(certfile=self.webshare_ssl_cert_path, keyfile=self.webshare_ssl_key_path) + webserver.socket = ssl_context.wrap_socket(webserver.socket, do_handshake_on_connect=False, server_side=True) + self.webshare_server = webserver webserver.serve_forever() self.webshare_server = None @@ -4864,15 +5071,41 @@ class SidebandCore(): if not self.reticulum.is_connected_to_shared_instance: RNS.Transport.detach_interfaces() -rns_config = """ +rns_config = """# This template is used to generate a +# running configuration for Sideband's +# internal RNS instance. Incorrect changes +# or addition here may cause Sideband to +# fail starting up or working properly. +# +# If Sideband detects that Reticulum +# aborts at startup, due to an error in +# configuration, any template changes +# will be reset to this default. + [reticulum] -enable_transport = TRANSPORT_IS_ENABLED -share_instance = Yes -shared_instance_port = 37428 -instance_control_port = 37429 -panic_on_interface_error = No + # Don't change this line, use the UI + # setting for selecting whether RNS + # transport is enabled or disabled + enable_transport = TRANSPORT_IS_ENABLED + # Changing this setting will cause + # Sideband to not work. + share_instance = Yes + + # Changing these options should only + # be done if you know what you're doing. + shared_instance_port = 37428 + instance_control_port = 37429 + panic_on_interface_error = No + +# Logging is controlled by settings +# in the UI, so this section is mostly +# not relevant in Sideband. [logging] -loglevel = 3 + loglevel = 3 +# No additional interfaces are currently +# defined, but you can use this section +# to do so. +[interfaces] """ diff --git a/sbapp/sideband/sense.py b/sbapp/sideband/sense.py index 843149e..82b7ddb 100644 --- a/sbapp/sideband/sense.py +++ b/sbapp/sideband/sense.py @@ -778,9 +778,9 @@ class Location(Sensor): return [ struct.pack("!i", int(round(d["latitude"], 6)*1e6)), struct.pack("!i", int(round(d["longitude"], 6)*1e6)), - struct.pack("!I", int(round(d["altitude"], 2)*1e2)), + struct.pack("!i", int(round(d["altitude"], 2)*1e2)), struct.pack("!I", int(round(d["speed"], 2)*1e2)), - struct.pack("!I", int(round(d["bearing"], 2)*1e2)), + struct.pack("!i", int(round(d["bearing"], 2)*1e2)), struct.pack("!H", int(round(d["accuracy"], 2)*1e2)), d["last_update"], ] @@ -796,9 +796,9 @@ class Location(Sensor): return { "latitude": struct.unpack("!i", packed[0])[0]/1e6, "longitude": struct.unpack("!i", packed[1])[0]/1e6, - "altitude": struct.unpack("!I", packed[2])[0]/1e2, + "altitude": struct.unpack("!i", packed[2])[0]/1e2, "speed": struct.unpack("!I", packed[3])[0]/1e2, - "bearing": struct.unpack("!I", packed[4])[0]/1e2, + "bearing": struct.unpack("!i", packed[4])[0]/1e2, "accuracy": struct.unpack("!H", packed[5])[0]/1e2, "last_update": packed[6], } diff --git a/sbapp/ui/announces.py b/sbapp/ui/announces.py index 5e58dbb..f9bc28d 100644 --- a/sbapp/ui/announces.py +++ b/sbapp/ui/announces.py @@ -18,9 +18,9 @@ from kivy.lang.builder import Builder from kivy.utils import escape_markup if RNS.vendor.platformutils.get_platform() == "android": - from ui.helpers import multilingual_markup + from ui.helpers import multilingual_markup, sig_icon_for_q else: - from .helpers import multilingual_markup + from .helpers import multilingual_markup, sig_icon_for_q if RNS.vendor.platformutils.get_platform() == "android": from ui.helpers import ts_format @@ -92,6 +92,25 @@ class Announces(): a_name = announce["name"] a_cost = announce["cost"] dest_type = announce["type"] + a_rssi = None + a_snr = None + a_q = None + + link_extras_str = "" + link_extras_full = "" + if "extras" in announce and announce["extras"] != None: + extras = announce["extras"] + if "link_stats" in extras: + link_stats = extras["link_stats"] + if "rssi" in link_stats and "snr" in link_stats and "q" in link_stats: + a_rssi = link_stats["rssi"] + a_snr = link_stats["snr"] + a_q = link_stats["q"] + if a_rssi != None and a_snr != None and a_q != None: + link_extras_str = f" ([b]RSSI[/b] {a_rssi} [b]SNR[/b] {a_snr})" + link_extras_full = f"\n[b]Link Quality[/b] {a_q}%[/b]\n[b]RSSI[/b] {a_rssi}\n[b]SNR[/b] {a_snr}" + + sig_icon = multilingual_markup(sig_icon_for_q(a_q).encode("utf-8")).decode("utf-8") if not context_dest in self.added_item_dests: if self.app.sideband.is_trusted(context_dest): @@ -99,16 +118,16 @@ class Announces(): else: trust_icon = "account-question" - def gen_info(ts, dest, name, cost, dtype): + def gen_info(ts, dest, name, cost, dtype, link_extras): name = multilingual_markup(escape_markup(str(name)).encode("utf-8")).decode("utf-8") cost = str(cost) def x(sender): yes_button = MDRectangleFlatButton(text="OK",font_size=dp(18)) if dtype == "lxmf.delivery": - ad_text = "[size=22dp]LXMF Peer[/size]\n\n[b]Received[/b] "+ts+"\n[b]Address[/b] "+RNS.prettyhexrep(dest)+"\n[b]Name[/b] "+name+"\n[b]Stamp Cost[/b] "+cost + ad_text = "[size=22dp]LXMF Peer[/size]\n\n[b]Received[/b] "+ts+"\n[b]Address[/b] "+RNS.prettyhexrep(dest)+"\n[b]Name[/b] "+name+"\n[b]Stamp Cost[/b] "+cost+link_extras if dtype == "lxmf.propagation": - ad_text = "[size=22dp]LXMF Propagation Node[/size]\n\n[b]Received[/b] "+ts+"\n[b]Address[/b] "+RNS.prettyhexrep(dest) + ad_text = "[size=22dp]LXMF Propagation Node[/size]\n\n[b]Received[/b] "+ts+"\n[b]Address[/b] "+RNS.prettyhexrep(dest)+link_extras dialog = MDDialog( text=ad_text, @@ -123,7 +142,8 @@ class Announces(): dialog.open() return x - time_string = time.strftime(ts_format, time.localtime(ts)) + time_string = sig_icon + " " + time.strftime(ts_format, time.localtime(ts)) + link_extras_str + time_string_plain = time.strftime(ts_format, time.localtime(ts)) if dest_type == "lxmf.delivery": disp_name = multilingual_markup(escape_markup(str(self.app.sideband.peer_display_name(context_dest))).encode("utf-8")).decode("utf-8") @@ -137,7 +157,7 @@ class Announces(): disp_name = "Unknown Announce" iconl = IconLeftWidget(icon="progress-question") - item = TwoLineAvatarIconListItem(text=time_string, secondary_text=disp_name, on_release=gen_info(time_string, context_dest, a_name, a_cost, dest_type)) + item = TwoLineAvatarIconListItem(text=time_string, secondary_text=disp_name, on_release=gen_info(time_string_plain, context_dest, a_name, a_cost, dest_type, link_extras_full)) item.add_widget(iconl) item.sb_uid = context_dest item.ts = ts diff --git a/sbapp/ui/conversations.py b/sbapp/ui/conversations.py index d624100..cc58db1 100644 --- a/sbapp/ui/conversations.py +++ b/sbapp/ui/conversations.py @@ -133,6 +133,7 @@ class Conversations(): unread = conv["unread"] last_activity = conv["last_activity"] trusted = conv["trust"] == 1 + appearance_from_all = self.app.sideband.config["display_style_from_all"] appearance = self.app.sideband.peer_appearance(context_dest, conv=conv) is_object = self.app.sideband.is_object(context_dest, conv_data=conv) da = self.app.sideband.DEFAULT_APPEARANCE @@ -141,7 +142,7 @@ class Conversations(): conv_icon = self.trust_icon(conv) fg = None; bg = None; ti_color = None - if trusted and self.app.sideband.config["display_style_in_contact_list"] and appearance != None and appearance != da: + if (trusted or appearance_from_all) and self.app.sideband.config["display_style_in_contact_list"] and appearance != None and appearance != da: fg = appearance[1] or da[1]; bg = appearance[2] or da[2] ti_color = "Custom" else: diff --git a/sbapp/ui/helpers.py b/sbapp/ui/helpers.py index 68f73d4..80d2971 100644 --- a/sbapp/ui/helpers.py +++ b/sbapp/ui/helpers.py @@ -4,6 +4,7 @@ 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 +import re ts_format = "%Y-%m-%d %H:%M:%S" file_ts_format = "%Y_%m_%d_%H_%M_%S" @@ -25,6 +26,19 @@ intensity_msgs_light = "500" intensity_play_dark = "600" intensity_play_light = "300" + +intensity_msgs_dark_alt = "800" +intensity_msgs_light_alt = "400" +intensity_delivered_alt_dark = "800" +color_received_alt = "BlueGray" +color_received_alt_light = "BlueGray" +color_delivered_alt = "Indigo" +color_propagated_alt = "DeepPurple" +color_paper_alt = "DeepPurple" +color_playing_alt = "Amber" +color_failed_alt = "Red" +color_unknown_alt = "Gray" + class ContentNavigationDrawer(Screen): pass @@ -45,13 +59,12 @@ def strip_emojis(str_input): return output def multilingual_markup(data): - # TODO: Remove - # import time - # ts = time.time() - do = "" rfont = "default" ds = data.decode("utf-8") + di = 0 + persistent_regions = [(m.start(), m.end()) for m in re.finditer("(?s)\[font=(?:nf|term)\].*?\[/font\]", ds)] + for cp in ds: match = False switch = False @@ -63,6 +76,10 @@ def multilingual_markup(data): switch = True rfont = "emoji" + in_persistent = False + if any(x[0] < di and x[1] > di for x in persistent_regions): + in_persistent = True + if not match: for range_start in codepoint_map: range_end = codepoint_map[range_start][0] @@ -71,8 +88,9 @@ def multilingual_markup(data): if range_end >= ord(cp) >= range_start: match = True if rfont != mapped_font: - rfont = mapped_font - switch = True + if not in_persistent: + rfont = mapped_font + switch = True break if (not match) and rfont != "default": @@ -86,15 +104,30 @@ def multilingual_markup(data): do += "[font="+str(rfont)+"]" do += cp + di += 1 if rfont != "default": do += "[/font]" - # TODO: Remove - # print(do+"\n\n"+str(time.time()-ts)) - return do.encode("utf-8") +def sig_icon_for_q(q): + if q == None: + return "󰴽" + elif q > 90: + return "󰣺" + elif q > 70: + return "󰣸" + elif q > 50: + return "󰣶" + elif q > 30: + return "󰣴" + elif q > 10: + return "󰣾" + +persistent_fonts = ["nf", "term"] +nf_mapped = "nf" + codepoint_map = { 0x0590: [0x05ff, "hebrew"], 0x2e3a: [0x2e3b, "chinese"], @@ -128,6 +161,29 @@ codepoint_map = { 0xac00: [0xd7af, "korean"], 0xd7b0: [0xd7ff, "korean"], 0x0900: [0x097f, "combined"], # Devanagari + 0xe5fa: [0xe6b7, nf_mapped], # Seti-UI + Custom + 0xe700: [0xe8ef, nf_mapped], # Devicons + 0xed00: [0xf2ff, nf_mapped], # Font Awesome + 0xe200: [0xe2a9, nf_mapped], # Font Awesome Extension + 0xf0001: [0xf1af0, nf_mapped], # Material Design Icons + 0xe300: [0xe3e3, nf_mapped], # Weather + 0xf400: [0xf533, nf_mapped], # Octicons + 0x2665: [0x2665, nf_mapped], # Octicons + 0x26a1: [0x26a1, nf_mapped], # Octicons + 0xe0a0: [0xe0a2, nf_mapped], # Powerline Symbols + 0xe0b0: [0xe0b3, nf_mapped], # Powerline Symbols + 0xe0a3: [0xe0a3, nf_mapped], # Powerline Extra Symbols + 0xe0b4: [0xe0c8, nf_mapped], # Powerline Extra Symbols + 0xe0ca: [0xe0ca, nf_mapped], # Powerline Extra Symbols + 0xe0cc: [0xe0d7, nf_mapped], # Powerline Extra Symbols + 0x23fb: [0x23fe, nf_mapped], # IEC Power Symbols + 0x2b58: [0x2b58, nf_mapped], # IEC Power Symbols + 0xf300: [0xf381, nf_mapped], # Font logos + 0xe000: [0xe00a, nf_mapped], # Pomicons + 0xea60: [0xec1e, nf_mapped], # Codicons + 0x276c: [0x2771, nf_mapped], # Heavy Angle Brackets + 0x2500: [0x259f, nf_mapped], # Box Drawing + 0xee00: [0xee0b, nf_mapped], # Progress } emoji_lookup = [ diff --git a/sbapp/ui/layouts.py b/sbapp/ui/layouts.py index 7ad90df..2d7f700 100644 --- a/sbapp/ui/layouts.py +++ b/sbapp/ui/layouts.py @@ -80,13 +80,13 @@ MDNavigationLayout: on_release: root.ids.screen_manager.app.map_action(self) - OneLineIconListItem: - text: "Overview" - on_release: root.ids.screen_manager.app.overview_action(self) + # OneLineIconListItem: + # text: "Overview" + # on_release: root.ids.screen_manager.app.overview_action(self) - IconLeftWidget: - icon: "view-dashboard-outline" - on_release: root.ids.screen_manager.app.overview_action(self) + # IconLeftWidget: + # icon: "view-dashboard-outline" + # on_release: root.ids.screen_manager.app.overview_action(self) OneLineIconListItem: @@ -1287,7 +1287,7 @@ layout_settings_screen = """ MDLabel: id: scaling_info markup: True - text: "You can scale the entire Sideband UI by specifying a scaling factor in the field below. After setting it, restart sideband for the scaling to take effect.\\n\\nSet to 0.0 to disable scaling adjustments." + text: "You can scale the entire Sideband UI by specifying a scaling factor in the field below. After setting it, restart sideband for the scaling to take effect.\\n\\nSet to 0.0 to disable scaling adjustments.\\n\\n[b]Please note![/b] On some devices, the default scaling factor will be higher than 1.0, and setting a smaller value will result in miniscule UI elements." size_hint_y: None text_size: self.width, None height: self.texture_size[1] @@ -1494,6 +1494,21 @@ MDScreen: pos_hint: {"center_y": 0.3} active: False + MDBoxLayout: + orientation: "horizontal" + size_hint_y: None + padding: [0,0,dp(24),dp(0)] + height: dp(48) + + MDLabel: + text: "Classic message colors" + font_style: "H6" + + MDSwitch: + id: settings_classic_message_colors + pos_hint: {"center_y": 0.3} + active: False + MDBoxLayout: orientation: "horizontal" size_hint_y: None @@ -1568,7 +1583,7 @@ MDScreen: height: dp(48) MDLabel: - text: "Announce Automatically" + text: "Announce automatically" font_style: "H6" MDSwitch: @@ -1583,7 +1598,7 @@ MDScreen: height: dp(48) MDLabel: - text: "Try propagation on direct delivery failure" + text: "Try propagation automatically" font_style: "H6" MDSwitch: @@ -1624,6 +1639,22 @@ MDScreen: disabled: False active: False + MDBoxLayout: + orientation: "horizontal" + size_hint_y: None + padding: [0,0,dp(24),dp(0)] + height: dp(48) + + MDLabel: + text: "Only render markup from trusted" + font_style: "H6" + + MDSwitch: + id: settings_trusted_markup_only + pos_hint: {"center_y": 0.3} + disabled: False + active: False + MDBoxLayout: orientation: "horizontal" size_hint_y: None @@ -1752,21 +1783,21 @@ MDScreen: disabled: False active: False - MDBoxLayout: - orientation: "horizontal" - size_hint_y: None - padding: [0,0,dp(24),dp(0)] - height: dp(48) + # MDBoxLayout: + # orientation: "horizontal" + # size_hint_y: None + # padding: [0,0,dp(24),dp(0)] + # height: dp(48) - MDLabel: - text: "Use Home Node as Broadcast Repeater" - font_style: "H6" + # MDLabel: + # text: "Use Home Node as Broadcast Repeater" + # font_style: "H6" - MDSwitch: - id: settings_home_node_as_broadcast_repeater - pos_hint: {"center_y": 0.3} - active: False - disabled: True + # MDSwitch: + # id: settings_home_node_as_broadcast_repeater + # pos_hint: {"center_y": 0.3} + # active: False + # disabled: True MDBoxLayout: orientation: "horizontal" diff --git a/sbapp/ui/messages.py b/sbapp/ui/messages.py index 2047ecf..08093cf 100644 --- a/sbapp/ui/messages.py +++ b/sbapp/ui/messages.py @@ -35,11 +35,13 @@ if RNS.vendor.platformutils.get_platform() == "android": from sideband.sense import Telemeter, Commands from ui.helpers import ts_format, file_ts_format, mdc from ui.helpers import color_playing, color_received, color_delivered, color_propagated, color_paper, color_failed, color_unknown, intensity_msgs_dark, intensity_msgs_light, intensity_play_dark, intensity_play_light + from ui.helpers import color_received_alt, color_received_alt_light, color_delivered_alt, color_propagated_alt, color_paper_alt, color_failed_alt, color_unknown_alt, color_playing_alt, intensity_msgs_dark_alt, intensity_msgs_light_alt, intensity_delivered_alt_dark else: import sbapp.plyer as plyer from sbapp.sideband.sense import Telemeter, Commands from .helpers import ts_format, file_ts_format, mdc from .helpers import color_playing, color_received, color_delivered, color_propagated, color_paper, color_failed, color_unknown, intensity_msgs_dark, intensity_msgs_light, intensity_play_dark, intensity_play_light + from .helpers import color_received_alt, color_received_alt_light, color_delivered_alt, color_propagated_alt, color_paper_alt, color_failed_alt, color_unknown_alt, color_playing_alt, intensity_msgs_dark_alt, intensity_msgs_light_alt, intensity_delivered_alt_dark if RNS.vendor.platformutils.is_darwin(): from PIL import Image as PilImage @@ -203,6 +205,23 @@ class Messages(): self.ids.message_text.input_type = "text" self.ids.message_text.keyboard_suggestions = True + if not self.app.sideband.config["classic_message_colors"]: + c_delivered = color_delivered_alt + c_received = color_received_alt + c_propagated = color_propagated_alt + c_playing = color_playing_alt + c_paper = color_paper_alt + c_unknown = color_unknown_alt + c_failed = color_failed_alt + else: + c_delivered = color_delivered + c_received = color_received + c_propagated = color_propagated + c_playing = color_playing + c_paper = color_paper + c_unknown = color_unknown + c_failed = color_failed + for new_message in self.app.sideband.list_messages(self.context_dest, after=self.latest_message_timestamp,limit=limit): self.new_messages.append(new_message) @@ -241,12 +260,24 @@ class Messages(): if (len(self.added_item_hashes) < self.db_message_count) and not self.load_more_button in self.list.children: self.list.add_widget(self.load_more_button, len(self.list.children)) - if self.app.sideband.config["dark_ui"]: - intensity_msgs = intensity_msgs_dark - intensity_play = intensity_play_dark + if self.app.sideband.config["classic_message_colors"]: + if self.app.sideband.config["dark_ui"]: + intensity_msgs = intensity_msgs_dark + intensity_play = intensity_play_dark + intensity_delivered = intensity_msgs + else: + intensity_msgs = intensity_msgs_light + intensity_play = intensity_play_light + intensity_delivered = intensity_msgs else: - intensity_msgs = intensity_msgs_light - intensity_play = intensity_play_light + if self.app.sideband.config["dark_ui"]: + intensity_msgs = intensity_msgs_dark_alt + intensity_play = intensity_play_dark + intensity_delivered = intensity_delivered_alt_dark + else: + intensity_msgs = intensity_msgs_light_alt + intensity_play = intensity_play_light + intensity_delivered = intensity_msgs for w in self.widgets: m = w.m @@ -271,7 +302,7 @@ class Messages(): delivery_syms = multilingual_markup(delivery_syms.encode("utf-8")).decode("utf-8") if msg["state"] == LXMF.LXMessage.OUTBOUND or msg["state"] == LXMF.LXMessage.SENDING or msg["state"] == LXMF.LXMessage.SENT: - w.md_bg_color = msg_color = mdc(color_unknown, intensity_msgs) + w.md_bg_color = msg_color = mdc(c_unknown, intensity_msgs) txstr = time.strftime(ts_format, time.localtime(msg["sent"])) titlestr = "" prgstr = "" @@ -305,7 +336,7 @@ class Messages(): if msg["state"] == LXMF.LXMessage.DELIVERED: - w.md_bg_color = msg_color = mdc(color_delivered, intensity_msgs) + w.md_bg_color = msg_color = mdc(c_delivered, intensity_delivered) txstr = time.strftime(ts_format, time.localtime(msg["sent"])) titlestr = "" if msg["title"]: @@ -317,7 +348,7 @@ class Messages(): m["state"] = msg["state"] if msg["method"] == LXMF.LXMessage.PAPER: - w.md_bg_color = msg_color = mdc(color_paper, intensity_msgs) + w.md_bg_color = msg_color = mdc(c_paper, intensity_msgs) txstr = time.strftime(ts_format, time.localtime(msg["sent"])) titlestr = "" if msg["title"]: @@ -326,7 +357,7 @@ class Messages(): 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) + w.md_bg_color = msg_color = mdc(c_propagated, intensity_msgs) txstr = time.strftime(ts_format, time.localtime(msg["sent"])) titlestr = "" if msg["title"]: @@ -338,7 +369,7 @@ class Messages(): m["state"] = msg["state"] if msg["state"] == LXMF.LXMessage.FAILED: - w.md_bg_color = msg_color = mdc(color_failed, intensity_msgs) + w.md_bg_color = msg_color = mdc(c_failed, intensity_msgs) txstr = time.strftime(ts_format, time.localtime(msg["sent"])) titlestr = "" if msg["title"]: @@ -361,14 +392,49 @@ class Messages(): wid.height, wid.size_hint_y, wid.opacity, wid.disabled = 0, None, 0, True def update_widget(self): - if self.app.sideband.config["dark_ui"]: - intensity_msgs = intensity_msgs_dark - intensity_play = intensity_play_dark - mt_color = [1.0, 1.0, 1.0, 0.8] + + if self.app.sideband.config["classic_message_colors"]: + if self.app.sideband.config["dark_ui"]: + intensity_msgs = intensity_msgs_dark + intensity_play = intensity_play_dark + intensity_delivered = intensity_msgs + mt_color = [1.0, 1.0, 1.0, 0.8] + else: + intensity_msgs = intensity_msgs_light + intensity_play = intensity_play_light + intensity_delivered = intensity_msgs + mt_color = [1.0, 1.0, 1.0, 0.95] else: - intensity_msgs = intensity_msgs_light - intensity_play = intensity_play_light - mt_color = [1.0, 1.0, 1.0, 0.95] + if self.app.sideband.config["dark_ui"]: + intensity_msgs = intensity_msgs_dark_alt + intensity_play = intensity_play_dark + intensity_delivered = intensity_delivered_alt_dark + mt_color = [1.0, 1.0, 1.0, 0.8] + else: + intensity_msgs = intensity_msgs_light_alt + intensity_play = intensity_play_light + intensity_delivered = intensity_msgs + mt_color = [1.0, 1.0, 1.0, 0.95] + + if not self.app.sideband.config["classic_message_colors"]: + if self.app.sideband.config["dark_ui"]: + c_received = color_received_alt + else: + c_received = color_received_alt_light + c_delivered = color_delivered_alt + c_propagated = color_propagated_alt + c_playing = color_playing_alt + c_paper = color_paper_alt + c_unknown = color_unknown_alt + c_failed = color_failed_alt + else: + c_delivered = color_delivered + c_received = color_received + c_propagated = color_propagated + c_playing = color_playing + c_paper = color_paper + c_unknown = color_unknown + c_failed = color_failed self.ids.message_text.font_name = self.app.input_font @@ -378,7 +444,7 @@ class Messages(): for m in self.new_messages: if not m["hash"] in self.added_item_hashes: try: - if not self.is_trusted: + if self.app.sideband.config["trusted_markup_only"] and not self.is_trusted: message_input = str( escape_markup(m["content"].decode("utf-8")) ).encode("utf-8") else: message_input = m["content"] @@ -524,31 +590,31 @@ class Messages(): if m["source"] == self.app.sideband.lxmf_destination.hash: if m["state"] == LXMF.LXMessage.DELIVERED: - msg_color = mdc(color_delivered, intensity_msgs) + msg_color = mdc(c_delivered, intensity_delivered) heading_str = titlestr+"[b]Sent[/b] "+txstr+delivery_syms+"\n[b]State[/b] Delivered" elif m["method"] == LXMF.LXMessage.PROPAGATED and m["state"] == LXMF.LXMessage.SENT: - msg_color = mdc(color_propagated, intensity_msgs) + msg_color = mdc(c_propagated, intensity_msgs) heading_str = titlestr+"[b]Sent[/b] "+txstr+delivery_syms+"\n[b]State[/b] On Propagation Net" elif m["method"] == LXMF.LXMessage.PAPER: - msg_color = mdc(color_paper, intensity_msgs) + msg_color = mdc(c_paper, intensity_msgs) heading_str = titlestr+"[b]Created[/b] "+txstr+"\n[b]State[/b] Paper Message" elif m["state"] == LXMF.LXMessage.FAILED: - msg_color = mdc(color_failed, intensity_msgs) + msg_color = mdc(c_failed, intensity_msgs) heading_str = titlestr+"[b]Sent[/b] "+txstr+"\n[b]State[/b] Failed" elif m["state"] == LXMF.LXMessage.OUTBOUND or m["state"] == LXMF.LXMessage.SENDING: - msg_color = mdc(color_unknown, intensity_msgs) + msg_color = mdc(c_unknown, intensity_msgs) heading_str = titlestr+"[b]Sent[/b] "+txstr+"\n[b]State[/b] Sending " else: - msg_color = mdc(color_unknown, intensity_msgs) + msg_color = mdc(c_unknown, intensity_msgs) heading_str = titlestr+"[b]Sent[/b] "+txstr+"\n[b]State[/b] Unknown" else: - msg_color = mdc(color_received, intensity_msgs) + msg_color = mdc(c_received, intensity_msgs) heading_str = titlestr if phy_stats_str != "" and self.app.sideband.config["advanced_stats"]: heading_str += phy_stats_str+"\n" @@ -598,9 +664,9 @@ class Messages(): self.app.play_audio_field(sender.audio_field) stored_color = sender.md_bg_color if sender.lsource == self.app.sideband.lxmf_destination.hash: - sender.md_bg_color = mdc(color_delivered, intensity_play) + sender.md_bg_color = mdc(c_delivered, intensity_play) else: - sender.md_bg_color = mdc(color_received, intensity_play) + sender.md_bg_color = mdc(c_received, intensity_play) def cb(dt): sender.md_bg_color = stored_color @@ -653,6 +719,19 @@ class Messages(): item.ids.content_text.owner = item item.ids.content_text.bind(texture_size=check_textures) + def cbf(w): + def x(dt): + if w.texture_size[0] == 0 and w.texture_size[1] == 0: + w.markup = False + escaped_content = escape_markup(w.text) + def deferred(dt): + w.text = "[i]This message could not be rendered correctly, likely due to an error in its markup. Falling back to plain-text rendering.[/i]\n\n"+escaped_content + w.markup = True + Clock.schedule_once(deferred, 0.1) + return x + + Clock.schedule_once(cbf(item.ids.content_text), 0.25) + if not RNS.vendor.platformutils.is_android(): item.radius = dp(5) diff --git a/sbapp/ui/objectdetails.py b/sbapp/ui/objectdetails.py index 38d0074..3ae6009 100644 --- a/sbapp/ui/objectdetails.py +++ b/sbapp/ui/objectdetails.py @@ -148,6 +148,15 @@ class ObjectDetails(): else: self.from_objects = False + if self.viewing_self: + self.screen.ids.track_button.disabled = True + else: + self.screen.ids.track_button.disabled = False + if self.app.sideband.is_tracking(source_dest): + self.screen.ids.track_button.text = "Stop Live Tracking" + else: + self.screen.ids.track_button.text = "Start Live Tracking" + self.coords = None self.telemetry_list.data = [] pds = multilingual_markup(escape_markup(str(self.app.sideband.peer_display_name(source_dest))).encode("utf-8")).decode("utf-8") @@ -218,6 +227,15 @@ class ObjectDetails(): self.clear_widget() self.update() + def live_tracking(self, sender): + if not self.viewing_self: + if not self.app.sideband.is_tracking(self.object_hash): + self.app.sideband.start_tracking(self.object_hash, interval=59, duration=7*24*60*60) + self.screen.ids.track_button.text = "Stop Live Tracking" + else: + self.app.sideband.stop_tracking(self.object_hash) + self.screen.ids.track_button.text = "Start Live Tracking" + def send_update(self): if not self.viewing_self: result = self.app.sideband.send_latest_telemetry(to_addr=self.object_hash) @@ -643,10 +661,9 @@ class RVDetails(MDRecycleView): alt_str = RNS.prettydistance(alt) formatted_values = f"Coordinates [b]{fcoords}[/b], altitude [b]{alt_str}[/b]" if speed != None: - if speed > 0.02: + if speed > 0.1: speed_formatted_values = f"Speed [b]{speed} Km/h[/b], heading [b]{heading}°[/b]" else: - # speed_formatted_values = f"Speed [b]0 Km/h[/b]" speed_formatted_values = f"Object is [b]stationary[/b]" else: speed_formatted_values = None @@ -972,22 +989,22 @@ MDScreen: on_release: root.delegate.request_update() disabled: False - # MDBoxLayout: - # orientation: "horizontal" - # spacing: dp(16) - # size_hint_y: None - # height: self.minimum_height - # padding: [dp(24), dp(16), dp(24), dp(24)] + MDBoxLayout: + orientation: "horizontal" + spacing: dp(16) + size_hint_y: None + height: self.minimum_height + padding: [dp(24), dp(0), dp(24), dp(24)] - # MDRectangleFlatIconButton: - # id: delete_button - # icon: "trash-can-outline" - # text: "Delete All Telemetry" - # 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.copy_telemetry(self) - # disabled: False + MDRectangleFlatIconButton: + id: track_button + icon: "crosshairs-gps" + text: "Start Live Tracking" + 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.live_tracking(self) + disabled: False """ \ No newline at end of file diff --git a/sbapp/ui/utilities.py b/sbapp/ui/utilities.py index c192ed6..a35b6bd 100644 --- a/sbapp/ui/utilities.py +++ b/sbapp/ui/utilities.py @@ -9,7 +9,10 @@ from kivy.utils import escape_markup from kivymd.uix.recycleview import MDRecycleView from kivymd.uix.list import OneLineIconListItem from kivymd.uix.pickers import MDColorPicker +from kivymd.uix.button import MDRectangleFlatButton +from kivymd.uix.dialog import MDDialog from kivymd.icon_definitions import md_icons +from kivymd.toast import toast from kivy.properties import StringProperty, BooleanProperty from kivy.effects.scroll import ScrollEffect from kivy.clock import Clock @@ -29,6 +32,7 @@ class Utilities(): self.screen = None self.rnstatus_screen = None self.rnstatus_instance = None + self.logviewer_screen = None if not self.app.root.ids.screen_manager.has_screen("utilities_screen"): self.screen = Builder.load_string(layout_utilities_screen) @@ -37,13 +41,40 @@ class Utilities(): self.app.root.ids.screen_manager.add_widget(self.screen) self.screen.ids.telemetry_scrollview.effect_cls = ScrollEffect - info = "\nYou can use various RNS utilities from Sideband. " - info += "" + info = "This section contains various utilities and diagnostics tools, " + info += "that can be helpful while using Sideband and Reticulum." if self.app.theme_cls.theme_style == "Dark": info = "[color=#"+self.app.dark_theme_text_color+"]"+info+"[/color]" - self.screen.ids.telemetry_info.text = info + self.screen.ids.utilities_info.text = info + + + ### RNode Flasher + ###################################### + + def flasher_action(self, sender=None): + yes_button = MDRectangleFlatButton(text="Launch",font_size=dp(18), theme_text_color="Custom", line_color=self.app.color_accept, text_color=self.app.color_accept) + no_button = MDRectangleFlatButton(text="Back",font_size=dp(18)) + dialog = MDDialog( + title="RNode Flasher", + text="You can use the included web-based RNode flasher, by starting Sideband's built-in repository server, and accessing the RNode Flasher page.", + buttons=[ no_button, yes_button ], + # elevation=0, + ) + def dl_yes(s): + dialog.dismiss() + self.app.sideband.start_webshare() + def cb(dt): + self.app.repository_action() + Clock.schedule_once(cb, 0.6) + + def dl_no(s): + dialog.dismiss() + + yes_button.bind(on_release=dl_yes) + no_button.bind(on_release=dl_no) + dialog.open() ### rnstatus screen @@ -72,24 +103,91 @@ class Utilities(): import io from contextlib import redirect_stdout - output_marker = "===begin rnstatus output===" output = "None" with io.StringIO() as buffer, redirect_stdout(buffer): - print(output_marker, end="") - self.rnstatus_instance.main(rns_instance=RNS.Reticulum.get_instance()) - output = buffer.getvalue() - - remainder = output[:output.find(output_marker)] - output = output[output.find(output_marker)+len(output_marker):] - print(remainder, end="") + with RNS.logging_lock: + self.rnstatus_instance.main(rns_instance=RNS.Reticulum.get_instance()) + output = buffer.getvalue() def cb(dt): - self.rnstatus_screen.ids.rnstatus_output.text = f"[font=RobotoMono-Regular]{output}[/font]" + self.rnstatus_screen.ids.rnstatus_output.text = f"[font=RobotoMono-Regular][size={int(dp(12))}]{output}[/size][/font]" Clock.schedule_once(cb, 0.2) if self.app.root.ids.screen_manager.current == "rnstatus_screen": Clock.schedule_once(self.update_rnstatus, 1) + ### Advanced Configuration screen + ###################################### + + def advanced_action(self, sender=None): + if not self.app.root.ids.screen_manager.has_screen("advanced_screen"): + self.advanced_screen = Builder.load_string(layout_advanced_screen) + self.advanced_screen.app = self.app + self.advanced_screen.delegate = self + self.app.root.ids.screen_manager.add_widget(self.advanced_screen) + + self.app.root.ids.screen_manager.transition.direction = "left" + self.app.root.ids.screen_manager.current = "advanced_screen" + self.app.sideband.setstate("app.displaying", self.app.root.ids.screen_manager.current) + + self.update_advanced() + + def update_advanced(self, sender=None): + if RNS.vendor.platformutils.is_android(): + ct = self.app.sideband.config["config_template"] + self.advanced_screen.ids.config_template.text = f"[font=RobotoMono-Regular][size={int(dp(12))}]{ct}[/size][/font]" + else: + self.advanced_screen.ids.config_template.text = f"[font=RobotoMono-Regular][size={int(dp(12))}]On this platform, Reticulum configuration is managed by the system. You can change the configuration by editing the file located at:\n\n{self.app.sideband.reticulum.configpath}[/size][/font]" + + def copy_config(self, sender=None): + if RNS.vendor.platformutils.is_android(): + Clipboard.copy(self.app.sideband.config_template) + + def paste_config(self, sender=None): + if RNS.vendor.platformutils.is_android(): + self.app.sideband.config_template = Clipboard.paste() + self.app.sideband.config["config_template"] = self.app.sideband.config_template + self.app.sideband.save_configuration() + self.update_advanced() + + ### Log viewer screen + ###################################### + + def logviewer_action(self, sender=None): + if not self.app.root.ids.screen_manager.has_screen("logviewer_screen"): + self.logviewer_screen = Builder.load_string(layout_logviewer_screen) + self.logviewer_screen.app = self.app + self.logviewer_screen.delegate = self + self.app.root.ids.screen_manager.add_widget(self.logviewer_screen) + + self.app.root.ids.screen_manager.transition.direction = "left" + self.app.root.ids.screen_manager.current = "logviewer_screen" + self.app.sideband.setstate("app.displaying", self.app.root.ids.screen_manager.current) + + self.update_logviewer() + + def update_logviewer(self, sender=None): + threading.Thread(target=self.update_logviewer_job, daemon=True).start() + + def update_logviewer_job(self, sender=None): + try: + output = self.app.sideband.get_log() + except Exception as e: + output = f"An error occurred while retrieving log entries:\n{e}" + + self.logviewer_screen.log_contents = output + def cb(dt): + self.logviewer_screen.ids.logviewer_output.text = f"[font=RobotoMono-Regular][size={int(dp(12))}]{output}[/size][/font]" + Clock.schedule_once(cb, 0.2) + + if self.app.root.ids.screen_manager.current == "logviewer_screen": + Clock.schedule_once(self.update_logviewer, 1) + + def logviewer_copy(self, sender=None): + Clipboard.copy(self.logviewer_screen.log_contents) + if True or RNS.vendor.platformutils.is_android(): + toast("Log copied to clipboard") + layout_utilities_screen = """ MDScreen: @@ -116,14 +214,14 @@ MDScreen: orientation: "vertical" size_hint_y: None height: self.minimum_height - padding: [dp(28), dp(48), dp(28), dp(16)] + padding: [dp(28), dp(32), dp(28), dp(16)] + + # MDLabel: + # text: "Utilities & Tools" + # font_style: "H6" MDLabel: - text: "Utilities & Tools" - font_style: "H6" - - MDLabel: - id: telemetry_info + id: utilities_info markup: True text: "" size_hint_y: None @@ -156,8 +254,30 @@ MDScreen: icon_size: dp(24) font_size: dp(16) size_hint: [1.0, None] - on_release: root.delegate.rnstatus_action(self) - disabled: True + on_release: root.delegate.logviewer_action(self) + disabled: False + + MDRectangleFlatIconButton: + id: flasher_button + icon: "radio-handheld" + text: "RNode Flasher" + 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.flasher_action(self) + disabled: False + + MDRectangleFlatIconButton: + id: advanced_button + icon: "network-pos" + text: "Advanced RNS Configuration" + 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.advanced_action(self) + disabled: False """ @@ -177,12 +297,12 @@ MDScreen: [['menu', lambda x: root.app.nav_drawer.set_state("open")]] right_action_items: [ - ['refresh', lambda x: root.delegate.update_rnstatus()], + # ['refresh', lambda x: root.delegate.update_rnstatus()], ['close', lambda x: root.app.close_sub_utilities_action(self)], ] MDScrollView: - id: sensors_scrollview + id: rnstatus_scrollview size_hint_x: 1 size_hint_y: None size: [root.width, root.height-root.ids.top_bar.height] @@ -202,4 +322,120 @@ MDScreen: size_hint_y: None text_size: self.width, None height: self.texture_size[1] -""" \ No newline at end of file +""" + +layout_logviewer_screen = """ +MDScreen: + name: "logviewer_screen" + + BoxLayout: + orientation: "vertical" + + MDTopAppBar: + id: top_bar + title: "Log Viewer" + anchor_title: "left" + elevation: 0 + left_action_items: + [['menu', lambda x: root.app.nav_drawer.set_state("open")]] + right_action_items: + [ + ['content-copy', lambda x: root.delegate.logviewer_copy()], + ['close', lambda x: root.app.close_sub_utilities_action(self)], + ] + + MDScrollView: + id: logviewer_scrollview + size_hint_x: 1 + size_hint_y: None + size: [root.width, root.height-root.ids.top_bar.height] + do_scroll_x: False + do_scroll_y: True + + MDGridLayout: + cols: 1 + padding: [dp(28), dp(14), dp(28), dp(28)] + size_hint_y: None + height: self.minimum_height + + MDLabel: + id: logviewer_output + markup: True + text: "" + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] +""" + +layout_advanced_screen = """ +MDScreen: + name: "advanced_screen" + + BoxLayout: + orientation: "vertical" + + MDTopAppBar: + id: top_bar + title: "RNS Configuration" + anchor_title: "left" + elevation: 0 + left_action_items: + [['menu', lambda x: root.app.nav_drawer.set_state("open")]] + right_action_items: + [ + # ['refresh', lambda x: root.delegate.update_rnstatus()], + ['close', lambda x: root.app.close_sub_utilities_action(self)], + ] + + MDScrollView: + id: advanced_scrollview + size_hint_x: 1 + size_hint_y: None + size: [root.width, root.height-root.ids.top_bar.height] + do_scroll_x: False + do_scroll_y: True + + MDGridLayout: + cols: 1 + padding: [dp(28), dp(14), dp(28), dp(28)] + size_hint_y: None + height: self.minimum_height + + MDBoxLayout: + orientation: "horizontal" + spacing: dp(24) + size_hint_y: None + height: self.minimum_height + padding: [dp(0), dp(14), dp(0), dp(24)] + + MDRectangleFlatIconButton: + id: telemetry_button + icon: "content-copy" + text: "Copy Configuration" + 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.copy_config(self) + disabled: False + + MDRectangleFlatIconButton: + id: coordinates_button + icon: "download" + text: "Paste Configuration" + 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.paste_config(self) + disabled: False + + + MDLabel: + id: config_template + markup: True + text: "" + size_hint_y: None + text_size: self.width, None + height: self.texture_size[1] +""" diff --git a/setup.py b/setup.py index 4430f31..235a466 100644 --- a/setup.py +++ b/setup.py @@ -47,6 +47,20 @@ def glob_paths(pattern): return out_files +def glob_share(): + out_files = [] + src_path = os.path.join(os.path.dirname(__file__), "sbapp/share") + print(src_path) + + for root, dirs, files in os.walk(src_path): + for file in files: + filepath = os.path.join(str(Path(*Path(root).parts[1:])), file) + + if not "mirrors/unsigned.io" in str(filepath): + out_files.append(filepath.split(f"sbapp{os.sep}")[1]) + + return out_files + packages = setuptools.find_packages( exclude=[ "sbapp.plyer.platforms.android", @@ -63,6 +77,7 @@ package_data = { "kivymd/images/*", "kivymd/*", "mapview/icons/*", + *glob_share(), *glob_paths(".kv") ] } @@ -99,7 +114,7 @@ setuptools.setup( ] }, install_requires=[ - "rns>=0.8.7", + "rns>=0.8.8", "lxmf>=0.5.8", "kivy>=2.3.0", "pillow>=10.2.0",