Sideband/sbapp/ui/messages.py
2024-12-09 20:25:50 +01:00

1385 lines
63 KiB
Python

import time
import RNS
import LXMF
from kivy.metrics import dp,sp
from kivy.core.clipboard import Clipboard
from kivy.core.image import Image as CoreImage
from kivymd.uix.card import MDCard
from kivymd.uix.menu import MDDropdownMenu
# from kivymd.uix.behaviors import RoundedRectangularElevationBehavior, FakeRectangularElevationBehavior
from kivymd.uix.behaviors import CommonElevationBehavior
from kivy.properties import StringProperty, BooleanProperty
from kivy.uix.gridlayout import GridLayout
from kivy.uix.boxlayout import BoxLayout
from kivy.clock import Clock
from kivy.utils import escape_markup
from kivymd.uix.button import MDRectangleFlatButton, MDRectangleFlatIconButton
from kivymd.uix.dialog import MDDialog
if RNS.vendor.platformutils.get_platform() == "android":
from ui.helpers import multilingual_markup
else:
from .helpers import multilingual_markup
import io
import os
import subprocess
import shlex
from kivy.graphics.opengl import glGetIntegerv, GL_MAX_TEXTURE_SIZE
if RNS.vendor.platformutils.get_platform() == "android":
import plyer
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
from kivy.lang.builder import Builder
from kivymd.uix.list import OneLineIconListItem, IconLeftWidget
class DialogItem(OneLineIconListItem):
divider = None
icon = StringProperty()
class ListLXMessageCard(MDCard):
# class ListLXMessageCard(MDCard, FakeRectangularElevationBehavior):
text = StringProperty()
heading = StringProperty()
class Messages():
def __init__(self, app, context_dest):
self.app = app
self.context_dest = context_dest
self.source_dest = context_dest
self.is_trusted = self.app.sideband.is_trusted(self.context_dest)
self.ptt_enabled = self.app.sideband.ptt_enabled(self.context_dest)
self.screen = self.app.root.ids.screen_manager.get_screen("messages_screen")
self.ids = self.screen.ids
self.max_texture_size = glGetIntegerv(GL_MAX_TEXTURE_SIZE)[0]
self.new_messages = []
self.added_item_hashes = []
self.added_messages = 0
self.latest_message_timestamp = None
self.earliest_message_timestamp = time.time()
self.loading_earlier_messages = False
self.list = None
self.widgets = []
self.send_error_dialog = None
self.load_more_button = None
self.details_dialog = None
self.update()
def reload(self):
if self.list != None:
self.list.clear_widgets()
self.new_messages = []
self.added_item_hashes = []
self.added_messages = 0
self.latest_message_timestamp = None
self.widgets = []
self.update()
def load_more(self, dt):
for new_message in self.app.sideband.list_messages(self.context_dest, before=self.earliest_message_timestamp,limit=5):
self.new_messages.append(new_message)
if len(self.new_messages) > 0:
self.loading_earlier_messages = True
self.list.remove_widget(self.load_more_button)
def message_details_dialog(self, lxm_hash):
ss = int(dp(16))
ms = int(dp(14))
msg = self.app.sideband.message(lxm_hash)
if msg:
close_button = MDRectangleFlatButton(text="Close", font_size=dp(18))
# d_items = [ ]
# d_items.append(DialogItem(IconLeftWidget(icon="postage-stamp"), text="[size="+str(ss)+"]Stamp[/size]"))
d_text = ""
if "lxm" in msg and msg["lxm"] != None:
size_str = RNS.prettysize(msg["lxm"].packed_size)
d_text += f"[size={ss}][b]Message size[/b] {size_str}[/size]\n"
if msg["lxm"].signature_validated:
d_text += f"[size={ss}][b]Signature[/b] validated successfully[/size]\n"
else:
d_text += f"[size={ss}][b]Signature[/b] is invalid[/size]\n"
ratchet_method = ""
if "method" in msg:
if msg["method"] == LXMF.LXMessage.UNKNOWN:
d_text += f"[size={ss}][b]Delivered[/b] via unknown method[/size]\n"
if msg["method"] == LXMF.LXMessage.OPPORTUNISTIC:
ratchet_method = "with ratchet"
d_text += f"[size={ss}][b]Delivered[/b] opportunistically[/size]\n"
if msg["method"] == LXMF.LXMessage.DIRECT:
ratchet_method = "by link"
d_text += f"[size={ss}][b]Delivered[/b] over direct link[/size]\n"
if msg["method"] == LXMF.LXMessage.PROPAGATED:
ratchet_method = "with ratchet"
d_text += f"[size={ss}][b]Delivered[/b] to propagation network[/size]\n"
if msg["extras"] != None and "ratchet_id" in msg["extras"]:
r_str = RNS.prettyhexrep(msg["extras"]["ratchet_id"])
d_text += f"[size={ss}][b]Encrypted[/b] {ratchet_method} {r_str}[/size]\n"
else:
if msg["method"] == LXMF.LXMessage.OPPORTUNISTIC or msg["method"] == LXMF.LXMessage.PROPAGATED:
d_text += f"[size={ss}][b]Encrypted[/b] with destination identity key[/size]\n"
else:
d_text += f"[size={ss}][b]Encryption[/b] status unknown[/size]\n"
if msg["extras"] != None and "stamp_checked" in msg["extras"]:
valid_str = " is not valid"
if msg["extras"]["stamp_valid"] == True:
valid_str = " is valid"
sv = msg["extras"]["stamp_value"]
if sv == None:
if "stamp_raw" in msg["extras"]:
sv_str = ""
valid_str = "is not valid"
else:
sv_str = ""
valid_str = "was not included in the message"
elif sv > 255:
sv_str = "generated from ticket"
else:
sv_str = f"with value {sv}"
if msg["extras"]["stamp_checked"] == True:
d_text += f"[size={ss}][b]Stamp[/b] {sv_str}{valid_str}[/size]\n"
else:
sv = msg["extras"]["stamp_value"]
if sv == None:
pass
elif sv > 255:
d_text += f"[size={ss}][b]Stamp[/b] generated from ticket[/size]\n"
else:
d_text += f"[size={ss}][b]Value[/b] of stamp is {sv}[/size]\n"
# Stamp details
if "stamp_raw" in msg["extras"] and type(msg["extras"]["stamp_raw"]) == bytes:
sstr = RNS.hexrep(msg["extras"]["stamp_raw"])
sstr1 = RNS.hexrep(msg["extras"]["stamp_raw"][:16])
sstr2 = RNS.hexrep(msg["extras"]["stamp_raw"][16:])
d_text += f"[size={ss}]\n[b]Raw stamp[/b]\n[/size][size={ms}][font=RobotoMono-Regular]{sstr1}\n{sstr2}[/font][/size]\n"
self.details_dialog = MDDialog(
title="Message Details",
type="simple",
text=d_text,
# items=d_items,
buttons=[ close_button ],
width_offset=dp(32),
)
close_button.bind(on_release=self.details_dialog.dismiss)
self.details_dialog.open()
def update(self, limit=8):
if self.app.sideband.config["block_predictive_text"]:
if self.ids.message_text.input_type != "null":
self.ids.message_text.input_type = "null"
self.ids.message_text.keyboard_suggestions = False
else:
if self.ids.message_text.input_type != "text":
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)
self.db_message_count = self.app.sideband.count_messages(self.context_dest)
if self.load_more_button == None:
self.load_more_button = MDRectangleFlatIconButton(
icon="message-text-clock-outline",
text="Load earlier messages",
font_size=dp(18),
theme_text_color="Custom",
size_hint=[1.0, None],
)
def lmcb(sender):
Clock.schedule_once(self.load_more, 0.15)
self.load_more_button.bind(on_release=lmcb)
if self.list == None:
layout = GridLayout(cols=1, spacing=dp(16), padding=dp(16), size_hint_y=None)
layout.bind(minimum_height=layout.setter('height'))
self.list = layout
if RNS.vendor.platformutils.is_darwin() or RNS.vendor.platformutils.is_windows():
self.hide_widget(self.ids.message_ptt, True)
else:
if self.ptt_enabled:
self.hide_widget(self.ids.message_ptt, False)
else:
self.hide_widget(self.ids.message_ptt, True)
c_ts = time.time()
if len(self.new_messages) > 0:
self.update_widget()
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["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:
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
if self.app.sideband.config["dark_ui"]:
w.line_color = (1.0, 1.0, 1.0, 0.25)
else:
w.line_color = (1.0, 1.0, 1.0, 0.5)
if m["state"] == LXMF.LXMessage.SENDING or m["state"] == LXMF.LXMessage.OUTBOUND or m["state"] == LXMF.LXMessage.SENT:
msg = self.app.sideband.message(m["hash"])
if msg != None:
delivery_syms = ""
# if msg["extras"] != None and "ratchet_id" in m["extras"]:
# delivery_syms += " ⚙️"
if msg["method"] == LXMF.LXMessage.OPPORTUNISTIC:
delivery_syms += " 📨"
if msg["method"] == LXMF.LXMessage.DIRECT:
delivery_syms += " 🔗"
if msg["method"] == LXMF.LXMessage.PROPAGATED:
delivery_syms += " 📦"
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(c_unknown, intensity_msgs)
txstr = time.strftime(ts_format, time.localtime(msg["sent"]))
titlestr = ""
prgstr = ""
sphrase = "Sending"
prg = self.app.sideband.get_lxm_progress(msg["hash"])
if prg != None:
prgstr = ", "+str(round(prg*100, 1))+"% done"
if prg <= 0.00:
stamp_cost = self.app.sideband.get_lxm_stamp_cost(msg["hash"])
if stamp_cost:
sphrase = f"Generating stamp with cost {stamp_cost}"
prgstr = ""
else:
sphrase = "Waiting for path"
elif prg <= 0.01:
sphrase = "Waiting for path"
elif prg <= 0.03:
sphrase = "Establishing link"
elif prg <= 0.05:
sphrase = "Link established"
elif prg >= 0.05:
sphrase = "Sending"
if msg["title"]:
titlestr = "[b]Title[/b] "+msg["title"].decode("utf-8")+"\n"
w.heading = titlestr+"[b]Sent[/b] "+txstr+"\n[b]State[/b] "+sphrase+prgstr+" "
if w.has_audio:
alstr = RNS.prettysize(w.audio_size)
w.heading += f"\n[b]Audio Message[/b] ({alstr})"
m["state"] = msg["state"]
if msg["state"] == LXMF.LXMessage.DELIVERED:
w.md_bg_color = msg_color = mdc(c_delivered, intensity_delivered)
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+delivery_syms+"\n[b]State[/b] Delivered"
if w.has_audio:
alstr = RNS.prettysize(w.audio_size)
w.heading += f"\n[b]Audio Message[/b] ({alstr})"
m["state"] = msg["state"]
if msg["method"] == LXMF.LXMessage.PAPER:
w.md_bg_color = msg_color = mdc(c_paper, 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+"\n[b]State[/b] Paper Message"
m["state"] = msg["state"]
if msg["method"] == LXMF.LXMessage.PROPAGATED and msg["state"] == LXMF.LXMessage.SENT:
w.md_bg_color = msg_color = mdc(c_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+delivery_syms+"\n[b]State[/b] On Propagation Net"
if w.has_audio:
alstr = RNS.prettysize(w.audio_size)
w.heading += f"\n[b]Audio Message[/b] ({alstr})"
m["state"] = msg["state"]
if msg["state"] == LXMF.LXMessage.FAILED:
w.md_bg_color = msg_color = mdc(c_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+"\n[b]State[/b] Failed"
m["state"] = msg["state"]
if w.has_audio:
alstr = RNS.prettysize(w.audio_size)
w.heading += f"\n[b]Audio Message[/b] ({alstr})"
w.dmenu.items.append(w.dmenu.retry_item)
def hide_widget(self, wid, dohide=True):
if hasattr(wid, 'saved_attrs'):
if not dohide:
wid.height, wid.size_hint_y, wid.opacity, wid.disabled = wid.saved_attrs
del wid.saved_attrs
elif dohide:
wid.saved_attrs = wid.height, wid.size_hint_y, wid.opacity, wid.disabled
wid.height, wid.size_hint_y, wid.opacity, wid.disabled = 0, None, 0, True
def update_widget(self):
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:
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
if self.loading_earlier_messages:
self.new_messages.reverse()
for m in self.new_messages:
if not m["hash"] in self.added_item_hashes:
try:
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"]
except Exception as e:
RNS.log(f"Message content could not be decoded: {e}", RNS.LOG_DEBUG)
message_input = b""
if message_input.strip() == b"":
if not ("lxm" in m and m["lxm"] != None and m["lxm"].fields != None and LXMF.FIELD_COMMANDS in m["lxm"].fields):
message_input = "[i]This message contains no text content[/i]".encode("utf-8")
message_markup = multilingual_markup(message_input)
txstr = time.strftime(ts_format, time.localtime(m["sent"]))
rxstr = time.strftime(ts_format, time.localtime(m["received"]))
titlestr = ""
extra_content = ""
extra_telemetry = {}
telemeter = None
image_field = None
audio_field = None
has_image = False
has_audio = False
attachments_field = None
has_attachment = False
force_markup = False
signature_valid = False
stamp_valid = False
stamp_value = None
delivery_syms = ""
# if m["extras"] != None and "ratchet_id" in m["extras"]:
# delivery_syms += " ⚙️"
if m["method"] == LXMF.LXMessage.OPPORTUNISTIC:
delivery_syms += " 📨"
if m["method"] == LXMF.LXMessage.DIRECT:
delivery_syms += " 🔗"
if m["method"] == LXMF.LXMessage.PROPAGATED:
delivery_syms += " 📦"
delivery_syms = multilingual_markup(delivery_syms.encode("utf-8")).decode("utf-8")
if "lxm" in m and m["lxm"] != None and m["lxm"].signature_validated:
signature_valid = True
if "extras" in m and m["extras"] != None and "packed_telemetry" in m["extras"]:
try:
telemeter = Telemeter.from_packed(m["extras"]["packed_telemetry"])
except Exception as e:
pass
if "extras" in m and m["extras"] != None and "stamp_checked" in m["extras"] and m["extras"]["stamp_checked"] == True:
stamp_valid = m["extras"]["stamp_valid"]
stamp_value = m["extras"]["stamp_value"]
if "lxm" in m and m["lxm"] != None and m["lxm"].fields != None and LXMF.FIELD_COMMANDS in m["lxm"].fields:
try:
commands = m["lxm"].fields[LXMF.FIELD_COMMANDS]
for command in commands:
if Commands.ECHO in command:
extra_content = "[font=RobotoMono-Regular]> echo "+command[Commands.ECHO].decode("utf-8")+"[/font]\n"
if Commands.PING in command:
extra_content = "[font=RobotoMono-Regular]> ping[/font]\n"
if Commands.SIGNAL_REPORT in command:
extra_content = "[font=RobotoMono-Regular]> sig[/font]\n"
if Commands.PLUGIN_COMMAND in command:
cmd_content = command[Commands.PLUGIN_COMMAND]
extra_content = "[font=RobotoMono-Regular]> "+str(cmd_content)+"[/font]\n"
extra_content = extra_content[:-1]
force_markup = True
except Exception as e:
RNS.log("Error while generating command display: "+str(e), RNS.LOG_ERROR)
if telemeter == None and "lxm" in m and m["lxm"] and m["lxm"].fields != None and LXMF.FIELD_TELEMETRY in m["lxm"].fields:
try:
packed_telemetry = m["lxm"].fields[LXMF.FIELD_TELEMETRY]
telemeter = Telemeter.from_packed(packed_telemetry)
except Exception as e:
pass
if "lxm" in m and m["lxm"] and m["lxm"].fields != None and LXMF.FIELD_IMAGE in m["lxm"].fields:
try:
image_field = m["lxm"].fields[LXMF.FIELD_IMAGE]
has_image = True
except Exception as e:
pass
if "lxm" in m and m["lxm"] and m["lxm"].fields != None and LXMF.FIELD_AUDIO in m["lxm"].fields:
try:
audio_field = m["lxm"].fields[LXMF.FIELD_AUDIO]
has_audio = True
except Exception as e:
pass
if "lxm" in m and m["lxm"] and m["lxm"].fields != None and LXMF.FIELD_FILE_ATTACHMENTS in m["lxm"].fields:
if len(m["lxm"].fields[LXMF.FIELD_FILE_ATTACHMENTS]) > 0:
try:
attachments_field = m["lxm"].fields[LXMF.FIELD_FILE_ATTACHMENTS]
has_attachment = True
except Exception as e:
pass
rcvd_d_str = ""
trcvd = telemeter.read("received") if telemeter else None
if trcvd and "distance" in trcvd:
d = trcvd["distance"]
if "euclidian" in d:
edst = d["euclidian"]
if edst != None:
rcvd_d_str = "\n[b]Distance[/b] "+RNS.prettydistance(edst)
elif "geodesic" in d:
gdst = d["geodesic"]
if gdst != None:
rcvd_d_str = "\n[b]Distance[/b] "+RNS.prettydistance(gdst) + " (geodesic)"
phy_stats_str = ""
if "extras" in m and m["extras"] != None:
phy_stats = m["extras"]
if "q" in phy_stats:
try:
lq = round(float(phy_stats["q"]), 1)
phy_stats_str += "[b]Link Quality[/b] "+str(lq)+"% "
extra_telemetry["quality"] = lq
except:
pass
if "rssi" in phy_stats:
try:
lr = round(float(phy_stats["rssi"]), 1)
phy_stats_str += "[b]RSSI[/b] "+str(lr)+"dBm "
extra_telemetry["rssi"] = lr
except:
pass
if "snr" in phy_stats:
try:
ls = round(float(phy_stats["snr"]), 1)
phy_stats_str += "[b]SNR[/b] "+str(ls)+"dB "
extra_telemetry["snr"] = ls
except:
pass
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(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(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(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(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(c_unknown, intensity_msgs)
heading_str = titlestr+"[b]Sent[/b] "+txstr+"\n[b]State[/b] Sending "
else:
msg_color = mdc(c_unknown, intensity_msgs)
heading_str = titlestr+"[b]Sent[/b] "+txstr+"\n[b]State[/b] Unknown"
else:
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"
# TODO: Remove
# if stamp_valid:
# txstr += f" [b]Stamp[/b] value is {stamp_value} "
heading_str += "[b]Sent[/b] "+txstr+delivery_syms
heading_str += "\n[b]Received[/b] "+rxstr
if rcvd_d_str != "":
heading_str += rcvd_d_str
pre_content = ""
if not signature_valid:
identity_known = False
if RNS.Identity.recall(m["hash"]) != None:
identity_known = True
if identity_known == True:
pre_content += "[b]Warning![/b] The signature for this message could not be validated. [b]This message is likely to be fake[/b].\n\n"
force_markup = True
if has_attachment:
heading_str += "\n[b]Attachments[/b] "
for attachment in attachments_field:
heading_str += str(attachment[0])+", "
heading_str = heading_str[:-2]
if has_audio:
alstr = RNS.prettysize(len(audio_field[1]))
heading_str += f"\n[b]Audio Message[/b] ({alstr})"
item = ListLXMessageCard(
text=pre_content+message_markup.decode("utf-8")+extra_content,
heading=heading_str,
md_bg_color=msg_color,
)
item.lsource = m["source"]
item.has_audio = False
if has_attachment:
item.attachments_field = attachments_field
if has_audio:
def play_audio(sender):
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(c_delivered, intensity_play)
else:
sender.md_bg_color = mdc(c_received, intensity_play)
def cb(dt):
sender.md_bg_color = stored_color
Clock.schedule_once(cb, 0.25)
item.has_audio = True
item.audio_size = len(audio_field[1])
item.audio_field = audio_field
item.bind(on_release=play_audio)
if image_field != None:
item.has_image = True
item.image_field = image_field
img = item.ids.message_image
img.source = ""
# Convert to PNG format on OSX, since support
# for webp seems rather flaky.
if RNS.vendor.platformutils.is_darwin():
im = PilImage.open(io.BytesIO(image_field[1]))
buf = io.BytesIO()
im.save(buf, format="png")
image_field[1] = buf.getvalue()
image_field[0] = "png"
img.texture = CoreImage(io.BytesIO(image_field[1]), ext=image_field[0]).texture
img.reload()
else:
item.has_image = False
def check_textures(w, val):
try:
if w.texture_size[0] > 360 and w.texture_size[1] >= self.max_texture_size:
w.text = "[i]The content of this message is too large to display in the message stream. You can copy the message content into another program by using the context menu of this message, and selecting [b]Copy[/b].[/i]"
if w.owner.has_image:
img = w.owner.ids.message_image
img.size_hint_x = 1
img.size_hint_y = None
img_w = w.owner.size[0]
img_ratio = img.texture_size[0] / img.texture_size[1]
img.size = (img_w,img_w/img_ratio)
img.fit_mode = "contain"
except Exception as e:
RNS.log("An error occurred while scaling message display textures:", RNS.LOG_ERROR)
RNS.trace_exception(e)
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)
item.sb_uid = m["hash"]
item.m = m
item.ids.heading_text.theme_text_color = "Custom"
item.ids.heading_text.text_color = mt_color
item.ids.content_text.theme_text_color = "Custom"
item.ids.content_text.text_color = mt_color
item.ids.msg_submenu.theme_text_color = "Custom"
item.ids.msg_submenu.text_color = mt_color
def gen_del(mhash, item):
def x():
yes_button = MDRectangleFlatButton(text="Yes",font_size=dp(18), theme_text_color="Custom", line_color=self.app.color_reject, text_color=self.app.color_reject)
no_button = MDRectangleFlatButton(text="No",font_size=dp(18))
dialog = MDDialog(
title="Delete message?",
buttons=[ yes_button, no_button ],
# elevation=0,
)
def dl_yes(s):
dialog.dismiss()
self.app.sideband.delete_message(mhash)
def cb(dt):
self.reload()
Clock.schedule_once(cb, 0.2)
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_retry(mhash, mcontent, item):
def x():
self.app.messages_view.ids.message_text.text = mcontent.decode("utf-8")
self.app.sideband.delete_message(mhash)
self.app.message_send_action()
item.dmenu.dismiss()
def cb(dt):
self.reload()
Clock.schedule_once(cb, 0.2)
return x
def gen_details(mhash, item):
def x():
item.dmenu.dismiss()
def cb(dt):
self.message_details_dialog(mhash)
Clock.schedule_once(cb, 0.2)
return x
def gen_copy(msg, item):
def x():
Clipboard.copy(msg)
item.dmenu.dismiss()
return x
def gen_save_image(item):
if RNS.vendor.platformutils.is_android():
def x():
image_field = item.image_field
extension = str(image_field[0]).replace(".", "")
filename = time.strftime("LXM_%Y_%m_%d_%H_%M_%S", time.localtime(time.time()))+"."+str(extension)
self.app.share_image(image_field[1], filename)
item.dmenu.dismiss()
return x
else:
def x():
image_field = item.image_field
try:
extension = str(image_field[0]).replace(".", "")
filename = time.strftime("LXM_%Y_%m_%d_%H_%M_%S", time.localtime(time.time()))+"."+str(extension)
if RNS.vendor.platformutils.is_darwin():
save_path = str(plyer.storagepath.get_downloads_dir()+filename).replace("file://", "")
else:
save_path = plyer.storagepath.get_downloads_dir()+"/"+filename
with open(save_path, "wb") as save_file:
save_file.write(image_field[1])
item.dmenu.dismiss()
ok_button = MDRectangleFlatButton(text="OK",font_size=dp(18))
dialog = MDDialog(
title="Image Saved",
text="The image has been saved to: "+save_path+"",
buttons=[ ok_button ],
# elevation=0,
)
def dl_ok(s):
dialog.dismiss()
ok_button.bind(on_release=dl_ok)
dialog.open()
except Exception as e:
item.dmenu.dismiss()
ok_button = MDRectangleFlatButton(text="OK",font_size=dp(18))
dialog = MDDialog(
title="Error",
text="Could not save the image:\n\n"+save_path+"\n\n"+str(e),
buttons=[ ok_button ],
# elevation=0,
)
def dl_ok(s):
dialog.dismiss()
ok_button.bind(on_release=dl_ok)
dialog.open()
item.dmenu.dismiss()
return x
def gen_save_attachment(item):
def x():
attachments_field = item.attachments_field
if isinstance(attachments_field, list):
try:
if RNS.vendor.platformutils.is_darwin():
output_path = str(plyer.storagepath.get_downloads_dir()).replace("file://", "")
else:
output_path = plyer.storagepath.get_downloads_dir()+"/"
if len(attachments_field) == 1:
saved_text = "The attached file has been saved to: "+output_path
saved_title = "Attachment Saved"
else:
saved_text = "The attached files have been saved to: "+output_path
saved_title = "Attachment Saved"
for attachment in attachments_field:
filename = str(attachment[0]).replace("../", "").replace("..\\", "")
if RNS.vendor.platformutils.is_darwin():
save_path = str(plyer.storagepath.get_downloads_dir()+filename).replace("file://", "")
else:
save_path = plyer.storagepath.get_downloads_dir()+"/"+filename
name_counter = 1
pre_count = save_path
while os.path.exists(save_path):
save_path = str(pre_count)+"."+str(name_counter)
name_counter += 1
saved_text = "The attached file has been saved to: "+save_path
with open(save_path, "wb") as save_file:
save_file.write(attachment[1])
item.dmenu.dismiss()
ok_button = MDRectangleFlatButton(text="OK",font_size=dp(18))
dialog = MDDialog(
title=saved_title,
text=saved_text,
buttons=[ ok_button ],
# elevation=0,
)
def dl_ok(s):
dialog.dismiss()
ok_button.bind(on_release=dl_ok)
dialog.open()
except Exception as e:
item.dmenu.dismiss()
ok_button = MDRectangleFlatButton(text="OK",font_size=dp(18))
dialog = MDDialog(
title="Error",
text="Could not save the attachment:\n\n"+save_path+"\n\n"+str(e),
buttons=[ ok_button ],
# elevation=0,
)
def dl_ok(s):
dialog.dismiss()
ok_button.bind(on_release=dl_ok)
dialog.open()
item.dmenu.dismiss()
return x
def gen_copy_telemetry(telemeter, extra_telemetry, item):
def x():
try:
telemeter
if extra_telemetry and len(extra_telemetry) != 0:
physical_link = extra_telemetry
telemeter.synthesize("physical_link")
if "rssi" in physical_link: telemeter.sensors["physical_link"].rssi = physical_link["rssi"]
if "snr" in physical_link: telemeter.sensors["physical_link"].snr = physical_link["snr"]
if "quality" in physical_link: telemeter.sensors["physical_link"].q = physical_link["quality"]
telemeter.sensors["physical_link"].update_data()
tlm = telemeter.read_all()
Clipboard.copy(str(tlm))
item.dmenu.dismiss()
except Exception as e:
RNS.log("An error occurred while decoding telemetry. The contained exception was: "+str(e), RNS.LOG_ERROR)
Clipboard.copy("Could not decode telemetry")
return x
def gen_copy_lxm_uri(lxm, item):
def x():
Clipboard.copy(lxm.as_uri())
item.dmenu.dismiss()
return x
def gen_save_qr(lxm, item):
if RNS.vendor.platformutils.is_android():
def x():
qr_image = lxm.as_qr()
hash_str = RNS.hexrep(lxm.hash[-2:], delimit=False)
filename = "Paper_Message_"+time.strftime(file_ts_format, time.localtime(m["sent"]))+"_"+hash_str+".png"
# filename = "Paper_Message.png"
self.app.share_image(qr_image, filename)
item.dmenu.dismiss()
return x
else:
def x():
try:
qr_image = lxm.as_qr()
hash_str = RNS.hexrep(lxm.hash[-2:], delimit=False)
filename = "Paper_Message_"+time.strftime(file_ts_format, time.localtime(m["sent"]))+"_"+hash_str+".png"
if RNS.vendor.platformutils.is_darwin():
save_path = str(plyer.storagepath.get_downloads_dir()+filename).replace("file://", "")
else:
save_path = plyer.storagepath.get_downloads_dir()+"/"+filename
qr_image.save(save_path)
item.dmenu.dismiss()
ok_button = MDRectangleFlatButton(text="OK",font_size=dp(18))
dialog = MDDialog(
title="QR Code Saved",
text="The paper message has been saved to: "+save_path+"",
buttons=[ ok_button ],
# elevation=0,
)
def dl_ok(s):
dialog.dismiss()
ok_button.bind(on_release=dl_ok)
dialog.open()
except Exception as e:
item.dmenu.dismiss()
ok_button = MDRectangleFlatButton(text="OK",font_size=dp(18))
dialog = MDDialog(
title="Error",
text="Could not save the paper message QR-code to:\n\n"+save_path+"\n\n"+str(e),
buttons=[ ok_button ],
# elevation=0,
)
def dl_ok(s):
dialog.dismiss()
ok_button.bind(on_release=dl_ok)
dialog.open()
return x
def gen_print_qr(lxm, item):
if RNS.vendor.platformutils.is_android():
def x():
item.dmenu.dismiss()
return x
else:
def x():
try:
qr_image = lxm.as_qr()
qr_tmp_path = self.app.sideband.tmp_dir+"/"+str(RNS.hexrep(lxm.hash, delimit=False))
qr_image.save(qr_tmp_path)
print_command = self.app.sideband.config["print_command"]+" "+qr_tmp_path
return_code = subprocess.call(shlex.split(print_command), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
os.unlink(qr_tmp_path)
item.dmenu.dismiss()
except Exception as e:
item.dmenu.dismiss()
ok_button = MDRectangleFlatButton(text="OK",font_size=dp(18))
dialog = MDDialog(
title="Error",
text="Could not print the paper message QR-code.\n\n"+str(e),
buttons=[ ok_button ],
# elevation=0,
)
def dl_ok(s):
dialog.dismiss()
ok_button.bind(on_release=dl_ok)
dialog.open()
return x
retry_item = {
"viewclass": "OneLineListItem",
"text": "Retry",
"height": dp(40),
"on_release": gen_retry(m["hash"], m["content"], item)
}
details_item = {
"viewclass": "OneLineListItem",
"text": "Details",
"height": dp(40),
"on_release": gen_details(m["hash"], item)
}
if m["method"] == LXMF.LXMessage.PAPER:
if RNS.vendor.platformutils.is_android():
qr_save_text = "Share QR Code"
dm_items = [
{
"viewclass": "OneLineListItem",
"text": "Share QR Code",
"height": dp(40),
"on_release": gen_save_qr(m["lxm"], item)
},
{
"viewclass": "OneLineListItem",
"text": "Copy LXM URI",
"height": dp(40),
"on_release": gen_copy_lxm_uri(m["lxm"], item)
},
{
"viewclass": "OneLineListItem",
"text": "Copy message text",
"height": dp(40),
"on_release": gen_copy(message_input.decode("utf-8"), item)
},
{
"text": "Delete",
"viewclass": "OneLineListItem",
"height": dp(40),
"on_release": gen_del(m["hash"], item)
}
]
else:
dm_items = [
{
"viewclass": "OneLineListItem",
"text": "Print QR Code",
"height": dp(40),
"on_release": gen_print_qr(m["lxm"], item)
},
{
"viewclass": "OneLineListItem",
"text": "Save QR Code",
"height": dp(40),
"on_release": gen_save_qr(m["lxm"], item)
},
{
"viewclass": "OneLineListItem",
"text": "Copy LXM URI",
"height": dp(40),
"on_release": gen_copy_lxm_uri(m["lxm"], item)
},
{
"viewclass": "OneLineListItem",
"text": "Copy message text",
"height": dp(40),
"on_release": gen_copy(message_input.decode("utf-8"), item)
},
{
"text": "Delete",
"viewclass": "OneLineListItem",
"height": dp(40),
"on_release": gen_del(m["hash"], item)
}
]
else:
if m["state"] == LXMF.LXMessage.FAILED:
dm_items = [
retry_item,
{
"viewclass": "OneLineListItem",
"text": "Copy",
"height": dp(40),
"on_release": gen_copy(message_input.decode("utf-8"), item)
},
{
"text": "Delete",
"viewclass": "OneLineListItem",
"height": dp(40),
"on_release": gen_del(m["hash"], item)
}
]
else:
if telemeter != None:
dm_items = [
details_item,
{
"viewclass": "OneLineListItem",
"text": "Copy",
"height": dp(40),
"on_release": gen_copy(message_input.decode("utf-8"), item)
},
{
"viewclass": "OneLineListItem",
"text": "Copy telemetry",
"height": dp(40),
"on_release": gen_copy_telemetry(telemeter, extra_telemetry, item)
},
{
"text": "Delete",
"viewclass": "OneLineListItem",
"height": dp(40),
"on_release": gen_del(m["hash"], item)
}
]
else:
dm_items = [
details_item,
{
"viewclass": "OneLineListItem",
"text": "Copy",
"height": dp(40),
"on_release": gen_copy(message_input.decode("utf-8"), item)
},
{
"text": "Delete",
"viewclass": "OneLineListItem",
"height": dp(40),
"on_release": gen_del(m["hash"], item)
}
]
if has_image:
extra_item = {
"viewclass": "OneLineListItem",
"text": "Save image",
"height": dp(40),
"on_release": gen_save_image(item)
}
dm_items.append(extra_item)
if has_attachment:
extra_item = {
"viewclass": "OneLineListItem",
"text": "Save attachment",
"height": dp(40),
"on_release": gen_save_attachment(item)
}
dm_items.append(extra_item)
item.dmenu = MDDropdownMenu(
caller=item.ids.msg_submenu,
items=dm_items,
position="auto",
width=dp(256),
elevation=0,
radius=dp(3),
)
item.dmenu.md_bg_color = self.app.color_hover
item.dmenu.retry_item = retry_item
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))
if self.loading_earlier_messages:
insert_pos = len(self.list.children)
else:
insert_pos = 0
self.added_item_hashes.append(m["hash"])
self.widgets.append(item)
self.list.add_widget(item, insert_pos)
if self.latest_message_timestamp == None or m["received"] > self.latest_message_timestamp:
self.latest_message_timestamp = m["received"]
if self.earliest_message_timestamp == None or m["received"] < self.earliest_message_timestamp:
self.earliest_message_timestamp = m["received"]
self.added_messages += len(self.new_messages)
self.new_messages = []
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()
messages_screen_kv = """
MDScreen:
name: "messages_screen"
BoxLayout:
orientation: "vertical"
MDTopAppBar:
id: messages_toolbar
anchor_title: "left"
title: "Messages"
elevation: 0
left_action_items:
[['menu', lambda x: root.app.nav_drawer.set_state("open")],]
right_action_items:
[
['attachment-plus', lambda x: root.app.message_attachment_action(self)],
['map-marker-path', lambda x: root.app.peer_show_telemetry_action(self)],
['map-search', lambda x: root.app.peer_show_location_action(self)],
['lan-connect', lambda x: root.app.message_propagation_action(self)],
['close', lambda x: root.app.close_settings_action(self)],
]
ScrollView:
id: messages_scrollview
do_scroll_x: False
do_scroll_y: True
BoxLayout:
id: no_keys_part
orientation: "vertical"
padding: [dp(16), dp(0), 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"
on_release: root.app.key_query_action(self)
BoxLayout:
id: message_ptt
padding: [dp(16), dp(8), dp(16), dp(8)]
spacing: dp(24)
size_hint_y: None
height: self.minimum_height
MDRectangleFlatIconButton:
id: message_ptt_button
icon: "microphone"
text: "PTT"
size_hint_x: 1.0
padding: [dp(10), dp(13), dp(10), dp(14)]
icon_size: dp(24)
font_size: dp(16)
on_press: root.app.message_ptt_down_action(self)
on_release: root.app.message_ptt_up_action(self)
_no_ripple_effect: True
background_normal: ""
background_down: ""
BoxLayout:
id: message_input_part
padding: [dp(16), dp(0), dp(16), dp(16)]
spacing: dp(24)
size_hint_y: None
height: self.minimum_height
MDTextField:
id: message_text
input_type: "text"
keyboard_suggestions: True
multiline: True
hint_text: "Write message"
mode: "rectangle"
max_height: dp(100)
MDRectangleFlatIconButton:
id: message_send_button
icon: "transfer-up"
text: "Send"
padding: [dp(10), dp(13), dp(10), dp(14)]
icon_size: dp(24)
font_size: dp(16)
on_release: root.app.message_send_action(self)
"""
Builder.load_string("""
<ListLXMessageCard>:
style: "outlined"
padding: dp(8)
radius: dp(4)
size_hint: 1.0, None
height: content_text.height + heading_text.height + message_image.size[1] + dp(32)
pos_hint: {"center_x": .5, "center_y": .5}
MDRelativeLayout:
size_hint: 1.0, None
theme_text_color: "ContrastParentBackground"
MDIconButton:
id: msg_submenu
icon: "dots-vertical"
# theme_text_color: 'Custom'
# text_color: rgba(255,255,255,216)
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
# theme_text_color: 'Custom'
# text_color: rgba(255,255,255,100)
pos: 0, root.height - (self.height + root.padding[0] + dp(8))
Image:
id: message_image
size_hint_x: 0
size_hint_y: 0
pos: 0, root.height - (self.height + root.padding[0] + dp(8)) - heading_text.height - dp(8)
MDLabel:
id: content_text
text: root.text
markup: True
size_hint_y: None
text_size: self.width, None
height: self.texture_size[1]
<CustomOneLineIconListItem>
IconLeftWidget:
icon: root.icon
""")