Initial commit

This commit is contained in:
Mark Qvist 2022-04-07 21:03:53 +02:00
parent 765f72a3ab
commit 70e49c8e6f
13 changed files with 2827 additions and 0 deletions

29
Makefile Normal file
View File

@ -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)

13
README.md Normal file
View File

@ -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.

BIN
assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
assets/presplash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

574
main.py Normal file
View File

@ -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()

30
setup.py Normal file
View File

@ -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',
)

2
sideband/_version.py Normal file
View File

@ -0,0 +1,2 @@
__version__ = "0.1.2"
__variant__ = "alpha"

888
sideband/core.py Normal file
View File

@ -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")

151
ui/announces.py Normal file
View File

@ -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

228
ui/conversations.py Normal file
View File

@ -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

30
ui/helpers.py Normal file
View File

@ -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()

675
ui/layouts.py Normal file
View File

@ -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)
<ListLXMessageCard>:
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]
<MsgSync>
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
<ConvSettings>
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
<NewConv>
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
"""

207
ui/messages.py Normal file
View File

@ -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()