diff --git a/internal_filesystem/apps/com.micropythonos.nostr/META-INF/MANIFEST.JSON b/internal_filesystem/apps/com.micropythonos.nostr/META-INF/MANIFEST.JSON new file mode 100644 index 00000000..8ba7214e --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.nostr/META-INF/MANIFEST.JSON @@ -0,0 +1,23 @@ +{ +"name": "Nostr", +"publisher": "MicroPythonOS", +"short_description": "Nostr", +"long_description": "Notest and Other Stuff Transmitted by Relays", +"icon_url": "https://apps.micropythonos.com/apps/com.micropythonos.nostr/icons/com.micropythonos.nostr_0.1.0_64x64.png", +"download_url": "https://apps.micropythonos.com/apps/com.micropythonos.nostr/mpks/com.micropythonos.nostr_0.1.0.mpk", +"fullname": "com.micropythonos.nostr", +"version": "0.1.0", +"category": "communication", +"activities": [ + { + "entrypoint": "assets/nostr.py", + "classname": "Nostr", + "intent_filters": [ + { + "action": "main", + "category": "launcher" + } + ] + } + ] +} diff --git a/internal_filesystem/apps/com.micropythonos.nostr/assets/confetti.py b/internal_filesystem/apps/com.micropythonos.nostr/assets/confetti.py new file mode 100644 index 00000000..ba781142 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.nostr/assets/confetti.py @@ -0,0 +1,182 @@ +import time +import random +import lvgl as lv +import mpos.ui + + +class Confetti: + """Manages confetti animation with physics simulation.""" + + def __init__(self, screen, icon_path, asset_path, duration=10000): + """ + Initialize the Confetti system. + + Args: + screen: The LVGL screen/display object + icon_path: Path to icon assets (e.g., "M:apps/com.lightningpiggy.displaywallet/res/mipmap-mdpi/") + asset_path: Path to confetti assets (e.g., "M:apps/com.lightningpiggy.displaywallet/res/drawable-mdpi/") + max_confetti: Maximum number of confetti pieces to display + """ + self.screen = screen + self.icon_path = icon_path + self.asset_path = asset_path + self.duration = duration + self.max_confetti = 21 + + # Physics constants + self.GRAVITY = 100 # pixels/sec² + + # Screen dimensions + self.screen_width = screen.get_display().get_horizontal_resolution() + self.screen_height = screen.get_display().get_vertical_resolution() + + # State + self.is_running = False + self.last_time = time.ticks_ms() + self.confetti_pieces = [] + self.confetti_images = [] + self.used_img_indices = set() + + # Spawn control + self.spawn_timer = 0 + self.spawn_interval = 0.15 # seconds + self.animation_start = 0 + + + # Pre-create LVGL image objects + self._init_images() + + def _init_images(self): + """Pre-create LVGL image objects for confetti.""" + iconimages = 2 + for _ in range(iconimages): + img = lv.image(lv.layer_top()) + img.set_src(f"{self.icon_path}icon_64x64.png") + img.add_flag(lv.obj.FLAG.HIDDEN) + self.confetti_images.append(img) + + for i in range(self.max_confetti - iconimages): + img = lv.image(lv.layer_top()) + img.set_src(f"{self.asset_path}confetti{random.randint(0, 4)}.png") + img.add_flag(lv.obj.FLAG.HIDDEN) + self.confetti_images.append(img) + + def start(self): + """Start the confetti animation.""" + if self.is_running: + return + + self.is_running = True + self.last_time = time.ticks_ms() + self._clear_confetti() + + # Staggered spawn control + self.spawn_timer = 0 + self.animation_start = time.ticks_ms() / 1000.0 + + # Initial burst + for _ in range(10): + self._spawn_one() + + # Register update callback + mpos.ui.task_handler.add_event_cb(self._update_frame, 1) + + # Stop spawning after 15 seconds + lv.timer_create(self.stop, self.duration, None).set_repeat_count(1) + + def stop(self, timer=None): + """Stop the confetti animation.""" + self.is_running = False + + def _clear_confetti(self): + """Clear all confetti pieces from the screen.""" + for img in self.confetti_images: + img.add_flag(lv.obj.FLAG.HIDDEN) + self.confetti_pieces = [] + self.used_img_indices.clear() + + def _update_frame(self, a, b): + """Update frame for confetti animation. Called by task handler.""" + current_time = time.ticks_ms() + delta_time = time.ticks_diff(current_time, self.last_time) / 1000.0 + self.last_time = current_time + + # === STAGGERED SPAWNING === + if self.is_running: + self.spawn_timer += delta_time + if self.spawn_timer >= self.spawn_interval: + self.spawn_timer = 0 + for _ in range(random.randint(1, 2)): + if len(self.confetti_pieces) < self.max_confetti: + self._spawn_one() + + # === UPDATE ALL PIECES === + new_pieces = [] + for piece in self.confetti_pieces: + # Physics + piece['age'] += delta_time + piece['x'] += piece['vx'] * delta_time + piece['y'] += piece['vy'] * delta_time + piece['vy'] += self.GRAVITY * delta_time + piece['rotation'] += piece['spin'] * delta_time + piece['scale'] = max(0.3, 1.0 - (piece['age'] / piece['lifetime']) * 0.7) + + # Render + img = self.confetti_images[piece['img_idx']] + img.remove_flag(lv.obj.FLAG.HIDDEN) + img.set_pos(int(piece['x']), int(piece['y'])) + img.set_rotation(int(piece['rotation'] * 10)) + orig = img.get_width() + if orig >= 64: + img.set_scale(int(256 * piece['scale'] / 1.5)) + elif orig < 32: + img.set_scale(int(256 * piece['scale'] * 1.5)) + else: + img.set_scale(int(256 * piece['scale'])) + + # Death check + dead = ( + piece['x'] < -60 or piece['x'] > self.screen_width + 60 or + piece['y'] > self.screen_height + 60 or + piece['age'] > piece['lifetime'] + ) + + if dead: + img.add_flag(lv.obj.FLAG.HIDDEN) + self.used_img_indices.discard(piece['img_idx']) + else: + new_pieces.append(piece) + + self.confetti_pieces = new_pieces + + # Full stop when empty and paused + if not self.confetti_pieces and not self.is_running: + print("Confetti finished") + mpos.ui.task_handler.remove_event_cb(self._update_frame) + + def _spawn_one(self): + """Spawn a single confetti piece.""" + if not self.is_running: + return + + # Find a free image slot + for idx, img in enumerate(self.confetti_images): + if img.has_flag(lv.obj.FLAG.HIDDEN) and idx not in self.used_img_indices: + break + else: + return # No free slot + + piece = { + 'img_idx': idx, + 'x': random.uniform(-50, self.screen_width + 50), + 'y': random.uniform(50, 100), # Start above screen + 'vx': random.uniform(-80, 80), + 'vy': random.uniform(-150, 0), + 'spin': random.uniform(-500, 500), + 'age': 0.0, + 'lifetime': random.uniform(5.0, 10.0), # Long enough to fill 10s + 'rotation': random.uniform(0, 360), + 'scale': 1.0 + } + self.confetti_pieces.append(piece) + self.used_img_indices.add(idx) diff --git a/internal_filesystem/apps/com.micropythonos.nostr/assets/fullscreen_qr.py b/internal_filesystem/apps/com.micropythonos.nostr/assets/fullscreen_qr.py new file mode 100644 index 00000000..0941c855 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.nostr/assets/fullscreen_qr.py @@ -0,0 +1,22 @@ +import lvgl as lv + +from mpos import Activity, min_resolution + +class FullscreenQR(Activity): + # No __init__() so super.__init__() will be called automatically + + def onCreate(self): + receive_qr_data = self.getIntent().extras.get("receive_qr_data") + qr_screen = lv.obj() + qr_screen.set_scrollbar_mode(lv.SCROLLBAR_MODE.OFF) + qr_screen.set_scroll_dir(lv.DIR.NONE) + qr_screen.add_event_cb(lambda e: self.finish(),lv.EVENT.CLICKED,None) + big_receive_qr = lv.qrcode(qr_screen) + big_receive_qr.set_size(min_resolution()) + big_receive_qr.set_dark_color(lv.color_black()) + big_receive_qr.set_light_color(lv.color_white()) + big_receive_qr.center() + big_receive_qr.set_style_border_color(lv.color_white(), 0) + big_receive_qr.set_style_border_width(0, 0); + big_receive_qr.update(receive_qr_data, len(receive_qr_data)) + self.setContentView(qr_screen) diff --git a/internal_filesystem/apps/com.micropythonos.nostr/assets/nostr.py b/internal_filesystem/apps/com.micropythonos.nostr/assets/nostr.py new file mode 100644 index 00000000..4e9063f7 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.nostr/assets/nostr.py @@ -0,0 +1,219 @@ +import lvgl as lv + +from mpos import Activity, Intent, ConnectivityManager, MposKeyboard, pct_of_display_width, pct_of_display_height, SharedPreferences, SettingsActivity +from mpos.ui.anim import WidgetAnimator + +from fullscreen_qr import FullscreenQR + +class Nostr(Activity): + + wallet = None + receive_qr_data = None + destination = None + balance_mode = 0 # 0=sats, 1=bits, 2=μBTC, 3=mBTC, 4=BTC + payments_label_current_font = 2 + payments_label_fonts = [ lv.font_montserrat_10, lv.font_unscii_8, lv.font_montserrat_16, lv.font_montserrat_24, lv.font_unscii_16, lv.font_montserrat_28_compressed, lv.font_montserrat_40] + + # screens: + main_screen = None + + # widgets + balance_label = None + receive_qr = None + payments_label = None + + # activities + fullscreenqr = FullscreenQR() # need a reference to be able to finish() it + + def onCreate(self): + self.prefs = SharedPreferences("com.micropythonos.nostr") + self.main_screen = lv.obj() + self.main_screen.set_style_pad_all(10, 0) + # This line needs to be drawn first, otherwise it's over the balance label and steals all the clicks! + balance_line = lv.line(self.main_screen) + balance_line.set_points([{'x':0,'y':35},{'x':200,'y':35}],2) + balance_line.add_flag(lv.obj.FLAG.CLICKABLE) + self.balance_label = lv.label(self.main_screen) + self.balance_label.set_text("") + self.balance_label.align(lv.ALIGN.TOP_LEFT, 0, 0) + self.balance_label.set_style_text_font(lv.font_montserrat_24, 0) + self.balance_label.add_flag(lv.obj.FLAG.CLICKABLE) + self.balance_label.set_width(pct_of_display_width(75)) # 100 - receive_qr + self.balance_label.add_event_cb(self.balance_label_clicked_cb,lv.EVENT.CLICKED,None) + self.receive_qr = lv.qrcode(self.main_screen) + self.receive_qr.set_size(pct_of_display_width(20)) # bigger QR results in simpler code (less error correction?) + self.receive_qr.set_dark_color(lv.color_black()) + self.receive_qr.set_light_color(lv.color_white()) + self.receive_qr.align(lv.ALIGN.TOP_RIGHT,0,0) + self.receive_qr.set_style_border_color(lv.color_white(), 0) + self.receive_qr.set_style_border_width(1, 0); + self.receive_qr.add_flag(lv.obj.FLAG.CLICKABLE) + self.receive_qr.add_event_cb(self.qr_clicked_cb,lv.EVENT.CLICKED,None) + self.payments_label = lv.label(self.main_screen) + self.payments_label.set_text("") + self.payments_label.align_to(balance_line,lv.ALIGN.OUT_BOTTOM_LEFT,0,10) + self.update_payments_label_font() + self.payments_label.set_width(pct_of_display_width(75)) # 100 - receive_qr + self.payments_label.add_flag(lv.obj.FLAG.CLICKABLE) + self.payments_label.add_event_cb(self.payments_label_clicked,lv.EVENT.CLICKED,None) + settings_button = lv.button(self.main_screen) + settings_button.set_size(lv.pct(20), lv.pct(25)) + settings_button.align(lv.ALIGN.BOTTOM_RIGHT, 0, 0) + settings_button.add_event_cb(self.settings_button_tap,lv.EVENT.CLICKED,None) + settings_label = lv.label(settings_button) + settings_label.set_text(lv.SYMBOL.SETTINGS) + settings_label.set_style_text_font(lv.font_montserrat_24, 0) + settings_label.center() + if False: # send button disabled for now, not implemented + send_button = lv.button(self.main_screen) + send_button.set_size(lv.pct(20), lv.pct(25)) + send_button.align_to(settings_button, lv.ALIGN.OUT_TOP_MID, 0, -pct_of_display_height(2)) + send_button.add_event_cb(self.send_button_tap,lv.EVENT.CLICKED,None) + send_label = lv.label(send_button) + send_label.set_text(lv.SYMBOL.UPLOAD) + send_label.set_style_text_font(lv.font_montserrat_24, 0) + send_label.center() + self.setContentView(self.main_screen) + + def onStart(self, main_screen): + self.main_ui_set_defaults() + + def onResume(self, main_screen): + super().onResume(main_screen) + cm = ConnectivityManager.get() + cm.register_callback(self.network_changed) + self.network_changed(cm.is_online()) + + def onPause(self, main_screen): + if self.wallet and self.destination != FullscreenQR: + self.wallet.stop() # don't stop the wallet for the fullscreen QR activity + self.destination = None + cm = ConnectivityManager.get() + cm.unregister_callback(self.network_changed) + + def network_changed(self, online): + print("displaywallet.py network_changed, now:", "ONLINE" if online else "OFFLINE") + if online: + self.went_online() + else: + self.went_offline() + + def went_online(self): + if self.wallet and self.wallet.is_running(): + print("wallet is already running, nothing to do") # might have come from the QR activity + return + try: + from nwc_wallet import NWCWallet + self.wallet = NWCWallet(self.prefs.get_string("nwc_url")) + self.wallet.static_receive_code = self.prefs.get_string("nwc_static_receive_code") + self.redraw_static_receive_code_cb() + except Exception as e: + self.error_cb(f"Couldn't initialize NWC Wallet because: {e}") + return + self.balance_label.set_text(lv.SYMBOL.REFRESH) + self.payments_label.set_text(f"\nConnecting to {wallet_type} backend.\n\nIf this takes too long, it might be down or something's wrong with the settings.") + # by now, self.wallet can be assumed + self.wallet.start(self.balance_updated_cb, self.redraw_payments_cb, self.redraw_static_receive_code_cb, self.error_cb) + + def went_offline(self): + if self.wallet: + self.wallet.stop() # don't stop the wallet for the fullscreen QR activity + self.payments_label.set_text(f"WiFi is not connected, can't talk to wallet...") + + def update_payments_label_font(self): + self.payments_label.set_style_text_font(self.payments_label_fonts[self.payments_label_current_font], 0) + + def payments_label_clicked(self, event): + self.payments_label_current_font = (self.payments_label_current_font + 1) % len(self.payments_label_fonts) + self.update_payments_label_font() + + def float_to_string(self, value): + # Format float to string with fixed-point notation, up to 6 decimal places + s = "{:.8f}".format(value) + # Remove trailing zeros and decimal point if no decimals remain + return s.rstrip("0").rstrip(".") + + def display_balance(self, balance): + #print(f"displaying balance {balance}") + if self.balance_mode == 0: # sats + #balance_text = "丰 " + str(balance) # font doesnt support it + balance_text = str(balance) + " sat" + if balance > 1: + balance_text += "s" + elif self.balance_mode == 1: # bits (1 bit = 100 sats) + balance_bits = balance / 100 + balance_text = self.float_to_string(balance_bits) + " bit" + if balance_bits != 1: + balance_text += "s" + elif self.balance_mode == 2: # micro-BTC (1 μBTC = 100 sats) + balance_ubtc = balance / 100 + balance_text = self.float_to_string(balance_ubtc) + " micro-BTC" + elif self.balance_mode == 3: # milli-BTC (1 mBTC = 100000 sats) + balance_mbtc = balance / 100000 + balance_text = self.float_to_string(balance_mbtc) + " milli-BTC" + elif self.balance_mode == 4: # BTC (1 BTC = 100000000 sats) + balance_btc = balance / 100000000 + #balance_text = "₿ " + str(balance) # font doesnt support it - although it should https://fonts.google.com/specimen/Montserrat + balance_text = self.float_to_string(balance_btc) + " BTC" + self.balance_label.set_text(balance_text) + #print("done displaying balance") + + def balance_updated_cb(self, sats_added=0): + print(f"balance_updated_cb(sats_added={sats_added})") + if self.fullscreenqr.has_foreground(): + self.fullscreenqr.finish() + balance = self.wallet.last_known_balance + print(f"balance: {balance}") + + def redraw_payments_cb(self): + # this gets called from another thread (the wallet) so make sure it happens in the LVGL thread using lv.async_call(): + self.payments_label.set_text(str(self.wallet.payment_list)) + + def redraw_static_receive_code_cb(self): + # this gets called from another thread (the wallet) so make sure it happens in the LVGL thread using lv.async_call(): + self.receive_qr_data = self.wallet.static_receive_code + if self.receive_qr_data: + self.receive_qr.update(self.receive_qr_data, len(self.receive_qr_data)) + else: + print("Warning: redraw_static_receive_code_cb() was called while self.wallet.static_receive_code is None...") + + def error_cb(self, error): + if self.wallet and self.wallet.is_running(): + self.payments_label.set_text(str(error)) + + def should_show_setting(self, setting): + wallet_type = self.prefs.get_string("wallet_type") + if wallet_type != "lnbits" and setting["key"].startswith("lnbits_"): + return False + if wallet_type != "nwc" and setting["key"].startswith("nwc_"): + return False + return True + + def settings_button_tap(self, event): + intent = Intent(activity_class=SettingsActivity) + intent.putExtra("prefs", self.prefs) + intent.putExtra("settings", [ + {"title": "Wallet Type", "key": "wallet_type", "ui": "radiobuttons", "ui_options": [("LNBits", "lnbits"), ("Nostr Wallet Connect", "nwc")]}, + {"title": "LNBits URL", "key": "lnbits_url", "placeholder": "https://demo.lnpiggy.com", "should_show": self.should_show_setting}, + {"title": "LNBits Read Key", "key": "lnbits_readkey", "placeholder": "fd92e3f8168ba314dc22e54182784045", "should_show": self.should_show_setting}, + {"title": "Optional LN Address", "key": "lnbits_static_receive_code", "placeholder": "Will be fetched if empty.", "should_show": self.should_show_setting}, + {"title": "Nost Wallet Connect", "key": "nwc_url", "placeholder": "nostr+walletconnect://69effe7b...", "should_show": self.should_show_setting}, + {"title": "Optional LN Address", "key": "nwc_static_receive_code", "placeholder": "Optional if present in NWC URL.", "should_show": self.should_show_setting}, + ]) + self.startActivity(intent) + + def main_ui_set_defaults(self): + self.balance_label.set_text("Welcome!") + self.payments_label.set_text(lv.SYMBOL.REFRESH) + + def balance_label_clicked_cb(self, event): + print("Balance clicked") + self.balance_mode = (self.balance_mode + 1) % 5 + self.display_balance(self.wallet.last_known_balance) + + def qr_clicked_cb(self, event): + print("QR clicked") + if not self.receive_qr_data: + return + self.destination = FullscreenQR + self.startActivity(Intent(activity_class=self.fullscreenqr).putExtra("receive_qr_data", self.receive_qr_data)) diff --git a/internal_filesystem/apps/com.micropythonos.nostr/assets/nostr_client.py b/internal_filesystem/apps/com.micropythonos.nostr/assets/nostr_client.py new file mode 100644 index 00000000..52c817e6 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.nostr/assets/nostr_client.py @@ -0,0 +1,281 @@ +import ssl +import json +import time + +from mpos.util import urldecode +from mpos import TaskManager + +from nostr.relay_manager import RelayManager +from nostr.message_type import ClientMessageType +from nostr.filter import Filter, Filters +from nostr.event import EncryptedDirectMessage +from nostr.key import PrivateKey + +from payment import Payment +from unique_sorted_list import UniqueSortedList + +class NWCWallet(): + + PAYMENTS_TO_SHOW = 6 + PERIODIC_FETCH_BALANCE_SECONDS = 60 # seconds + + relays = [] + secret = None + wallet_pubkey = None + + def __init__(self, nwc_url): + super().__init__() + self.nwc_url = nwc_url + if not nwc_url: + raise ValueError('NWC URL is not set.') + self.connected = False + self.relays, self.wallet_pubkey, self.secret, self.lud16 = self.parse_nwc_url(self.nwc_url) + if not self.relays: + raise ValueError('Missing relay in NWC URL.') + if not self.wallet_pubkey: + raise ValueError('Missing public key in NWC URL.') + if not self.secret: + raise ValueError('Missing "secret" in NWC URL.') + #if not self.lud16: + # raise ValueError('Missing lud16 (= lightning address) in NWC URL.') + + def getCommentFromTransaction(self, transaction): + comment = "" + try: + comment = transaction["description"] + if comment is None: + return comment + json_comment = json.loads(comment) + for field in json_comment: + if field[0] == "text/plain": + comment = field[1] + break + else: + print("text/plain field is missing from JSON description") + except Exception as e: + print(f"Info: comment {comment} is not JSON, this is fine, using as-is ({e})") + comment = super().try_parse_as_zap(comment) + return comment + + async def async_wallet_manager_task(self): + if self.lud16: + self.handle_new_static_receive_code(self.lud16) + + self.private_key = PrivateKey(bytes.fromhex(self.secret)) + self.relay_manager = RelayManager() + for relay in self.relays: + self.relay_manager.add_relay(relay) + + print(f"DEBUG: Opening relay connections") + await self.relay_manager.open_connections({"cert_reqs": ssl.CERT_NONE}) + self.connected = False + nrconnected = 0 + for _ in range(100): + await TaskManager.sleep(0.1) + nrconnected = self.relay_manager.connected_or_errored_relays() + #print(f"Waiting for relay connections, currently: {nrconnected}/{len(self.relays)}") + if nrconnected == len(self.relays) or not self.keep_running: + break + if nrconnected == 0: + self.handle_error("Could not connect to any Nostr Wallet Connect relays.") + return + if not self.keep_running: + print(f"async_wallet_manager_task does not have self.keep_running, returning...") + return + + print(f"{nrconnected} relays connected") + + # Set up subscription to receive response + self.subscription_id = "micropython_nwc_" + str(round(time.time())) + print(f"DEBUG: Setting up subscription with ID: {self.subscription_id}") + self.filters = Filters([Filter( + #event_ids=[self.subscription_id], would be nice to filter, but not like this + kinds=[23195, 23196], # NWC reponses and notifications + authors=[self.wallet_pubkey], + pubkey_refs=[self.private_key.public_key.hex()] + )]) + print(f"DEBUG: Subscription filters: {self.filters.to_json_array()}") + self.relay_manager.add_subscription(self.subscription_id, self.filters) + print(f"DEBUG: Creating subscription request") + request_message = [ClientMessageType.REQUEST, self.subscription_id] + request_message.extend(self.filters.to_json_array()) + print(f"DEBUG: Publishing subscription request") + self.relay_manager.publish_message(json.dumps(request_message)) + print(f"DEBUG: Published subscription request") + + last_fetch_balance = time.time() - self.PERIODIC_FETCH_BALANCE_SECONDS + while True: # handle incoming events and do periodic fetch_balance + #print(f"checking for incoming events...") + await TaskManager.sleep(0.1) + if not self.keep_running: + print("NWCWallet: not keep_running, closing connections...") + await self.relay_manager.close_connections() + break + + if time.time() - last_fetch_balance >= self.PERIODIC_FETCH_BALANCE_SECONDS: + last_fetch_balance = time.time() + try: + await self.fetch_balance() + except Exception as e: + print(f"fetch_balance got exception {e}") # fetch_balance got exception 'NoneType' object isn't iterable?! + + start_time = time.ticks_ms() + if self.relay_manager.message_pool.has_events(): + print(f"DEBUG: Event received from message pool after {time.ticks_ms()-start_time}ms") + event_msg = self.relay_manager.message_pool.get_event() + event_created_at = event_msg.event.created_at + print(f"Received at {time.localtime()} a message with timestamp {event_created_at} after {time.ticks_ms()-start_time}ms") + try: + # This takes a very long time, even for short messages: + decrypted_content = self.private_key.decrypt_message( + event_msg.event.content, + event_msg.event.public_key, + ) + print(f"DEBUG: Decrypted content: {decrypted_content} after {time.ticks_ms()-start_time}ms") + response = json.loads(decrypted_content) + print(f"DEBUG: Parsed response: {response}") + result = response.get("result") + if result: + if result.get("balance") is not None: + new_balance = round(int(result["balance"]) / 1000) + print(f"Got balance: {new_balance}") + self.handle_new_balance(new_balance) + elif result.get("transactions") is not None: + print("Response contains transactions!") + new_payment_list = UniqueSortedList() + for transaction in result["transactions"]: + amount = transaction["amount"] + amount = round(amount / 1000) + comment = self.getCommentFromTransaction(transaction) + epoch_time = transaction["created_at"] + paymentObj = Payment(epoch_time, amount, comment) + new_payment_list.add(paymentObj) + if len(new_payment_list) > 0: + # do them all in one shot instead of one-by-one because the lv_async() isn't always chronological, + # so when a long list of payments is added, it may be overwritten by a short list + self.handle_new_payments(new_payment_list) + else: + notification = response.get("notification") + if notification: + amount = notification["amount"] + amount = round(amount / 1000) + type = notification["type"] + if type == "outgoing": + amount = -amount + elif type == "incoming": + new_balance = self.last_known_balance + amount + self.handle_new_balance(new_balance, False) # don't trigger full fetch because payment info is in notification + epoch_time = notification["created_at"] + comment = self.getCommentFromTransaction(notification) + paymentObj = Payment(epoch_time, amount, comment) + self.handle_new_payment(paymentObj) + else: + print(f"WARNING: invalid notification type {type}, ignoring.") + else: + print("Unsupported response, ignoring.") + except Exception as e: + print(f"DEBUG: Error processing response: {e}") + import sys + sys.print_exception(e) # Full traceback on MicroPython + else: + #print(f"pool has no events after {time.ticks_ms()-start_time}ms") # completes in 0-1ms + pass + + def fetch_balance(self): + try: + if not self.keep_running: + return + # Create get_balance request + balance_request = { + "method": "get_balance", + "params": {} + } + print(f"DEBUG: Created balance request: {balance_request}") + print(f"DEBUG: Creating encrypted DM to wallet pubkey: {self.wallet_pubkey}") + dm = EncryptedDirectMessage( + recipient_pubkey=self.wallet_pubkey, + cleartext_content=json.dumps(balance_request), + kind=23194 + ) + print(f"DEBUG: Signing DM {json.dumps(dm)} with private key") + self.private_key.sign_event(dm) # sign also does encryption if it's a encrypted dm + print(f"DEBUG: Publishing encrypted DM") + self.relay_manager.publish_event(dm) + except Exception as e: + print(f"inside fetch_balance exception: {e}") + + def fetch_payments(self): + if not self.keep_running: + return + # Create get_balance request + list_transactions = { + "method": "list_transactions", + "params": { + "limit": self.PAYMENTS_TO_SHOW + } + } + dm = EncryptedDirectMessage( + recipient_pubkey=self.wallet_pubkey, + cleartext_content=json.dumps(list_transactions), + kind=23194 + ) + self.private_key.sign_event(dm) # sign also does encryption if it's a encrypted dm + print("\nPublishing DM to fetch payments...") + self.relay_manager.publish_event(dm) + + def parse_nwc_url(self, nwc_url): + """Parse Nostr Wallet Connect URL to extract pubkey, relays, secret, and lud16.""" + print(f"DEBUG: Starting to parse NWC URL: {nwc_url}") + try: + # Remove 'nostr+walletconnect://' or 'nwc:' prefix + if nwc_url.startswith('nostr+walletconnect://'): + print(f"DEBUG: Removing 'nostr+walletconnect://' prefix") + nwc_url = nwc_url[22:] + elif nwc_url.startswith('nwc:'): + print(f"DEBUG: Removing 'nwc:' prefix") + nwc_url = nwc_url[4:] + else: + print(f"DEBUG: No recognized prefix found in URL") + raise ValueError("Invalid NWC URL: missing 'nostr+walletconnect://' or 'nwc:' prefix") + print(f"DEBUG: URL after prefix removal: {nwc_url}") + # urldecode because the relay might have %3A%2F%2F etc + nwc_url = urldecode(nwc_url) + print(f"after urldecode: {nwc_url}") + # Split into pubkey and query params + parts = nwc_url.split('?') + pubkey = parts[0] + print(f"DEBUG: Extracted pubkey: {pubkey}") + # Validate pubkey (should be 64 hex characters) + if len(pubkey) != 64 or not all(c in '0123456789abcdef' for c in pubkey): + raise ValueError("Invalid NWC URL: pubkey must be 64 hex characters") + # Extract relay, secret, and lud16 from query params + relays = [] + lud16 = None + secret = None + if len(parts) > 1: + print(f"DEBUG: Query parameters found: {parts[1]}") + params = parts[1].split('&') + for param in params: + if param.startswith('relay='): + relay = param[6:] + print(f"DEBUG: Extracted relay: {relay}") + relays.append(relay) + elif param.startswith('secret='): + secret = param[7:] + print(f"DEBUG: Extracted secret: {secret}") + elif param.startswith('lud16='): + lud16 = param[6:] + print(f"DEBUG: Extracted lud16: {lud16}") + else: + print(f"DEBUG: No query parameters found") + if not pubkey or not len(relays) > 0 or not secret: + raise ValueError("Invalid NWC URL: missing required fields (pubkey, relay, or secret)") + # Validate secret (should be 64 hex characters) + if len(secret) != 64 or not all(c in '0123456789abcdef' for c in secret): + raise ValueError("Invalid NWC URL: secret must be 64 hex characters") + print(f"DEBUG: Parsed NWC data - Relay: {relays}, Pubkey: {pubkey}, Secret: {secret}, lud16: {lud16}") + return relays, pubkey, secret, lud16 + except Exception as e: + raise RuntimeError(f"Exception parsing NWC URL {nwc_url}: {e}") + + diff --git a/internal_filesystem/apps/com.micropythonos.nostr/assets/payment.py b/internal_filesystem/apps/com.micropythonos.nostr/assets/payment.py new file mode 100644 index 00000000..c331f1bb --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.nostr/assets/payment.py @@ -0,0 +1,43 @@ +# Payment class remains unchanged +class Payment: + def __init__(self, epoch_time, amount_sats, comment): + self.epoch_time = epoch_time + self.amount_sats = amount_sats + self.comment = comment + + def __str__(self): + sattext = "sats" + if self.amount_sats == 1: + sattext = "sat" + if not self.comment: + verb = "spent" + if self.amount_sats > 0: + verb = "received!" + return f"{self.amount_sats} {sattext} {verb}" + #return f"{self.amount_sats} {sattext} @ {self.epoch_time}: {self.comment}" + return f"{self.amount_sats} {sattext}: {self.comment}" + + def __eq__(self, other): + if not isinstance(other, Payment): + return False + return self.epoch_time == other.epoch_time and self.amount_sats == other.amount_sats and self.comment == other.comment + + def __lt__(self, other): + if not isinstance(other, Payment): + return NotImplemented + return (self.epoch_time, self.amount_sats, self.comment) < (other.epoch_time, other.amount_sats, other.comment) + + def __le__(self, other): + if not isinstance(other, Payment): + return NotImplemented + return (self.epoch_time, self.amount_sats, self.comment) <= (other.epoch_time, other.amount_sats, other.comment) + + def __gt__(self, other): + if not isinstance(other, Payment): + return NotImplemented + return (self.epoch_time, self.amount_sats, self.comment) > (other.epoch_time, other.amount_sats, other.comment) + + def __ge__(self, other): + if not isinstance(other, Payment): + return NotImplemented + return (self.epoch_time, self.amount_sats, self.comment) >= (other.epoch_time, other.amount_sats, other.comment) diff --git a/internal_filesystem/apps/com.micropythonos.nostr/assets/unique_sorted_list.py b/internal_filesystem/apps/com.micropythonos.nostr/assets/unique_sorted_list.py new file mode 100644 index 00000000..8f2dc4e5 --- /dev/null +++ b/internal_filesystem/apps/com.micropythonos.nostr/assets/unique_sorted_list.py @@ -0,0 +1,43 @@ +# keeps a list of items +# The .add() method ensures the list remains unique (via __eq__) +# and sorted (via __lt__) by inserting new items in the correct position. +class UniqueSortedList: + def __init__(self): + self._items = [] + + def add(self, item): + #print(f"before add: {str(self)}") + # Check if item already exists (using __eq__) + if item not in self._items: + # Insert item in sorted position for descending order (using __gt__) + for i, existing_item in enumerate(self._items): + if item > existing_item: + self._items.insert(i, item) + return + # If item is smaller than all existing items, append it + self._items.append(item) + #print(f"after add: {str(self)}") + + def __iter__(self): + # Return iterator for the internal list + return iter(self._items) + + def get(self, index_nr): + # Retrieve item at given index, raise IndexError if invalid + try: + return self._items[index_nr] + except IndexError: + raise IndexError("Index out of range") + + def __len__(self): + # Return the number of items for len() calls + return len(self._items) + + def __str__(self): + #print("UniqueSortedList tostring called") + return "\n".join(str(item) for item in self._items) + + def __eq__(self, other): + if len(self._items) != len(other): + return False + return all(p1 == p2 for p1, p2 in zip(self._items, other)) diff --git a/internal_filesystem/apps/com.micropythonos.nostr/res/drawable-mdpi/confetti0.png b/internal_filesystem/apps/com.micropythonos.nostr/res/drawable-mdpi/confetti0.png new file mode 100644 index 00000000..220c65cb Binary files /dev/null and b/internal_filesystem/apps/com.micropythonos.nostr/res/drawable-mdpi/confetti0.png differ diff --git a/internal_filesystem/apps/com.micropythonos.nostr/res/drawable-mdpi/confetti1.png b/internal_filesystem/apps/com.micropythonos.nostr/res/drawable-mdpi/confetti1.png new file mode 100644 index 00000000..0d7ddbff Binary files /dev/null and b/internal_filesystem/apps/com.micropythonos.nostr/res/drawable-mdpi/confetti1.png differ diff --git a/internal_filesystem/apps/com.micropythonos.nostr/res/drawable-mdpi/confetti2.png b/internal_filesystem/apps/com.micropythonos.nostr/res/drawable-mdpi/confetti2.png new file mode 100644 index 00000000..9159ff62 Binary files /dev/null and b/internal_filesystem/apps/com.micropythonos.nostr/res/drawable-mdpi/confetti2.png differ diff --git a/internal_filesystem/apps/com.micropythonos.nostr/res/drawable-mdpi/confetti3.png b/internal_filesystem/apps/com.micropythonos.nostr/res/drawable-mdpi/confetti3.png new file mode 100644 index 00000000..8ce98e5b Binary files /dev/null and b/internal_filesystem/apps/com.micropythonos.nostr/res/drawable-mdpi/confetti3.png differ diff --git a/internal_filesystem/apps/com.micropythonos.nostr/res/drawable-mdpi/confetti4.png b/internal_filesystem/apps/com.micropythonos.nostr/res/drawable-mdpi/confetti4.png new file mode 100644 index 00000000..bccb6d99 Binary files /dev/null and b/internal_filesystem/apps/com.micropythonos.nostr/res/drawable-mdpi/confetti4.png differ diff --git a/internal_filesystem/apps/com.micropythonos.nostr/res/mipmap-mdpi/icon_64x64.png b/internal_filesystem/apps/com.micropythonos.nostr/res/mipmap-mdpi/icon_64x64.png new file mode 100644 index 00000000..c0871732 Binary files /dev/null and b/internal_filesystem/apps/com.micropythonos.nostr/res/mipmap-mdpi/icon_64x64.png differ diff --git a/scripts/bundle_apps.sh b/scripts/bundle_apps.sh index 11cf5ea1..60f4671f 100755 --- a/scripts/bundle_apps.sh +++ b/scripts/bundle_apps.sh @@ -20,6 +20,7 @@ rm "$outputjson" # com.micropythonos.errortest is an intentional bad app for testing (caught by tests/test_graphical_launch_all_apps.py) # com.micropythonos.showbattery is just a test # com.micropythonos.doom_launcher isn't ready because the firmware doesn't have doom built-in yet +# com.micropythonos.nostr isn't ready for release yet blacklist="com.micropythonos.filemanager com.quasikili.quasidoodle com.micropythonos.errortest com.micropythonos.showbattery com.micropythonos.doom_launcher" echo "[" | tee -a "$outputjson"