diff --git a/App_GUI.py b/App_GUI.py deleted file mode 100644 index 0170012..0000000 --- a/App_GUI.py +++ /dev/null @@ -1,249 +0,0 @@ -import customtkinter as ctk - -# GUI Configuration -ctk.set_appearance_mode("Dark") -ctk.set_default_color_theme("blue") - -class AppGUI(ctk.CTk): - def __init__(self, controller): - super().__init__() - self.controller = controller - - self.title("Secure SMS - Raspberry Pi") - self.geometry("800x480") # Typical Pi touchscreen resolution - - # Current state - self.current_contact = None - - self.setup_ui() - self.load_contacts() - - def setup_ui(self): - # Grid Layout (1x2) - Sidebar | Main Content - self.grid_rowconfigure(0, weight=1) - self.grid_columnconfigure(1, weight=1) - - # --- Sidebar --- - self.sidebar_frame = ctk.CTkFrame(self, width=200, corner_radius=0) - self.sidebar_frame.grid(row=0, column=0, sticky="nsew") - self.sidebar_frame.grid_rowconfigure(4, weight=1) - - self.logo_label = ctk.CTkLabel(self.sidebar_frame, text="Secure SMS", font=ctk.CTkFont(size=20, weight="bold")) - self.logo_label.grid(row=0, column=0, padx=20, pady=(20, 10)) - - self.contacts_scrollable_frame = ctk.CTkScrollableFrame(self.sidebar_frame, label_text="Contacts") - self.contacts_scrollable_frame.grid(row=1, column=0, padx=10, pady=10, sticky="nsew", rowspan=4) - - # Settings / Add Contact Button - self.settings_btn = ctk.CTkButton(self.sidebar_frame, text="Settings / Add Contact", command=self.open_settings) - self.settings_btn.grid(row=5, column=0, padx=10, pady=20) - - # --- Main Chat Area --- - self.main_frame = ctk.CTkFrame(self, corner_radius=0) - self.main_frame.grid(row=0, column=1, sticky="nsew") - self.main_frame.grid_rowconfigure(1, weight=1) - self.main_frame.grid_columnconfigure(0, weight=1) - - # Chat Header - self.chat_header_var = ctk.StringVar(value="Select a contact") - self.chat_header = ctk.CTkLabel(self.main_frame, textvariable=self.chat_header_var, font=ctk.CTkFont(size=18, weight="bold")) - self.chat_header.grid(row=0, column=0, padx=20, pady=10, sticky="w") - - # Secured Status Label Indicator - self.header_secure_status = ctk.CTkLabel(self.main_frame, text="Current Mode: Normal", text_color="yellow") - self.header_secure_status.grid(row=0, column=0, padx=20, pady=10, sticky="e") - - # Messages Display - self.msg_display = ctk.CTkTextbox(self.main_frame, state="disabled", wrap="word") - self.msg_display.grid(row=1, column=0, padx=20, pady=(0, 10), sticky="nsew") - self.msg_display.tag_config('sent_normal', foreground='lightgray', justify='right') - self.msg_display.tag_config('sent_secure', foreground='lightgreen', justify='right') - self.msg_display.tag_config('recv_normal', foreground='white', justify='left') - self.msg_display.tag_config('recv_secure', foreground='mediumspringgreen', justify='left') - self.msg_display.tag_config('system', foreground='red', justify='center') - - # Bottom Input Area - self.input_frame = ctk.CTkFrame(self.main_frame, height=50) - self.input_frame.grid(row=2, column=0, padx=20, pady=10, sticky="nsew") - self.input_frame.grid_columnconfigure(0, weight=1) - - self.msg_entry = ctk.CTkEntry(self.input_frame, placeholder_text="Type a message...") - self.msg_entry.grid(row=0, column=0, padx=(10, 5), pady=10, sticky="nsew") - self.msg_entry.bind("", lambda e: self.send_message()) - - # Toggle Secure SMS - self.secure_mode_var = ctk.StringVar(value="off") - self.secure_switch = ctk.CTkSwitch( - self.input_frame, - text="Secure", - command=self.toggle_mode, - variable=self.secure_mode_var, - onvalue="on", - offvalue="off" - ) - self.secure_switch.grid(row=0, column=1, padx=5, pady=10) - - self.send_btn = ctk.CTkButton(self.input_frame, text="Send", width=80, command=self.send_message) - self.send_btn.grid(row=0, column=2, padx=(5, 10), pady=10) - - def toggle_mode(self): - mode = "Secure" if self.secure_mode_var.get() == "on" else "Normal" - color = "mediumspringgreen" if mode == "Secure" else "yellow" - self.header_secure_status.configure(text=f"Current Mode: {mode}", text_color=color) - - def load_contacts(self): - # Clear sidebar list - for widget in self.contacts_scrollable_frame.winfo_children(): - widget.destroy() - - contacts = self.controller.get_contacts() - for idx, contact in enumerate(contacts): - # contact = (name, phone, pubkey) - name, phone, _ = contact - btn = ctk.CTkButton( - self.contacts_scrollable_frame, - text=f"{name}\n{phone}", - command=lambda p=phone: self.select_contact(p), - fg_color="transparent", - border_width=2, - text_color=("gray10", "#DCE4EE") - ) - btn.grid(row=idx, column=0, padx=5, pady=5, sticky="ew") - - def select_contact(self, phone): - self.current_contact = self.controller.get_contact_info(phone) - name = self.current_contact[0] - self.chat_header_var.set(f"Chat with {name} ({phone})") - - # Determine if we can do secure - has_key = self.current_contact[2] is not None - if not has_key: - self.secure_switch.deselect() - self.secure_switch.configure(state="disabled") - self.toggle_mode() - self.append_system_msg("This contact has no public key. Secure mode disabled.") - else: - self.secure_switch.configure(state="normal") - - self.refresh_messages() - - def refresh_messages(self): - self.msg_display.configure(state="normal") - self.msg_display.delete("1.0", "end") - - if self.current_contact: - messages = self.controller.get_messages(self.current_contact[1]) - for msg in messages: - # msg = (id, phone, text, date, is_secure, status) - text = msg[2] - date = msg[3] - is_secure = msg[4] - status = msg[5] # sent / recv - - tag = f"{status}_{'secure' if is_secure else 'normal'}" - prefix = "🔒 " if is_secure else "" - - display_text = f"[{date}]\n{prefix}{text}\n\n" - self.msg_display.insert("end", display_text, tag) - - self.msg_display.configure(state="disabled") - self.msg_display.yview("end") - - def append_system_msg(self, text): - self.msg_display.configure(state="normal") - self.msg_display.insert("end", f"--- {text} ---\n\n", "system") - self.msg_display.configure(state="disabled") - self.msg_display.yview("end") - - def send_message(self): - if not self.current_contact: - return - - text = self.msg_entry.get().strip() - if not text: - return - - is_secure = self.secure_mode_var.get() == "on" - phone = self.current_contact[1] - - # Ask controller to send - self.msg_entry.delete(0, 'end') - self.append_system_msg("Sending...") - self.update() # force UI refresh safely - - success = self.controller.send_message(phone, text, is_secure) - if success: - self.refresh_messages() - else: - self.append_system_msg("Failed to send SMS!") - - def open_settings(self): - SettingsWindow(self, self.controller) - -class SettingsWindow(ctk.CTkToplevel): - def __init__(self, master, controller): - super().__init__(master) - self.controller = controller - self.title("Settings") - self.geometry("500x500") - - # Bring to front - self.attributes('-topmost', 1) - - self.grid_columnconfigure(0, weight=1) - - # My Public Key - lbl1 = ctk.CTkLabel(self, text="My Public Key (Share this to chat securely):") - lbl1.grid(row=0, column=0, padx=20, pady=(20, 5), sticky="w") - - self.my_key_box = ctk.CTkTextbox(self, height=100) - self.my_key_box.grid(row=1, column=0, padx=20, pady=5, sticky="ew") - self.my_key_box.insert("1.0", self.controller.get_my_public_key()) - self.my_key_box.configure(state="disabled") - - # Add / Update Contact - lbl_add = ctk.CTkLabel(self, text="Add/Update Contact:", font=ctk.CTkFont(weight="bold")) - lbl_add.grid(row=2, column=0, padx=20, pady=(30, 5), sticky="w") - - self.name_entry = ctk.CTkEntry(self, placeholder_text="Contact Name") - self.name_entry.grid(row=3, column=0, padx=20, pady=5, sticky="ew") - - self.phone_entry = ctk.CTkEntry(self, placeholder_text="Phone Number (+989...)") - self.phone_entry.grid(row=4, column=0, padx=20, pady=5, sticky="ew") - - lbl2 = ctk.CTkLabel(self, text="Contact's Public Key (Optional but required for secure SMS):") - lbl2.grid(row=5, column=0, padx=20, pady=5, sticky="w") - - self.contact_key_box = ctk.CTkTextbox(self, height=100) - self.contact_key_box.grid(row=6, column=0, padx=20, pady=5, sticky="ew") - - self.save_btn = ctk.CTkButton(self, text="Save Contact", command=self.save_contact) - self.save_btn.grid(row=7, column=0, padx=20, pady=20) - - def save_contact(self): - name = self.name_entry.get().strip() - phone = self.phone_entry.get().strip() - pubkey = self.contact_key_box.get("1.0", 'end-1c').strip() - - if not name or not phone: - return - - if not pubkey: - pubkey = None - - self.controller.save_contact(name, phone, pubkey) - self.master.load_contacts() - self.destroy() - -if __name__ == "__main__": - # Mock Controller for standalone GUI test - class MockController: - def get_contacts(self): return [("Alice", "+1234", "key"), ("Bob", "+5678", None)] - def get_contact_info(self, phone): return ("Alice", phone, "key") if phone == "+1234" else ("Bob", phone, None) - def get_messages(self, phone): return [(1, phone, "Hi", "2023-01-01", 0, "recv"), (2, phone, "Secret", "2023-01-01", 1, "sent")] - def get_my_public_key(self): return "MY_PEM_MOCK_DATA" - def send_message(self, *args): return True - def save_contact(self, *args): pass - - app = AppGUI(MockController()) - app.mainloop() diff --git a/Crypto_Engine.py b/Crypto_Engine.py deleted file mode 100644 index e9a507c..0000000 --- a/Crypto_Engine.py +++ /dev/null @@ -1,162 +0,0 @@ -import os -import base64 -from cryptography.hazmat.primitives.asymmetric import x25519 -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.kdf.hkdf import HKDF -from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives.ciphers.aead import AESGCM - -# Constants -PRIVATE_KEY_FILE = "private_key.pem" -PUBLIC_KEY_FILE = "public_key.pem" - -class CryptoEngine: - def __init__(self): - self.private_key = None - self.public_key = None - self.load_or_generate_keys() - - def load_or_generate_keys(self): - """Loads keys from disk or generates a new pair if they don't exist.""" - if os.path.exists(PRIVATE_KEY_FILE) and os.path.exists(PUBLIC_KEY_FILE): - with open(PRIVATE_KEY_FILE, "rb") as f: - self.private_key = serialization.load_pem_private_key( - f.read(), password=None - ) - with open(PUBLIC_KEY_FILE, "rb") as f: - self.public_key = serialization.load_pem_public_key(f.read()) - else: - self.generate_keypair() - - def generate_keypair(self): - """Generates a new X25519 keypair and saves it to disk.""" - self.private_key = x25519.X25519PrivateKey.generate() - self.public_key = self.private_key.public_key() - - # Save private key - with open(PRIVATE_KEY_FILE, "wb") as f: - f.write(self.private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption() - )) - - # Save public key - with open(PUBLIC_KEY_FILE, "wb") as f: - f.write(self.public_key.public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo - )) - - def get_my_public_key_pem(self): - """Returns my public key as string.""" - return self.public_key.public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo - ).decode('utf-8') - - def encrypt_message(self, message: str, peer_public_key_pem: str) -> str: - """ - Encrypts a message using X25519 exchange + HKDF + AESGCM. - Returns a base64 encoded string containing ephemeral public key, IV, and ciphertext. - """ - if not peer_public_key_pem: - raise ValueError("Peer public key is empty.") - - peer_public_key = serialization.load_pem_public_key(peer_public_key_pem.encode('utf-8')) - - # Generate an ephemeral keypair for this message to provide forward secrecy - ephemeral_private_key = x25519.X25519PrivateKey.generate() - ephemeral_public_key = ephemeral_private_key.public_key() - ephemeral_pub_bytes = ephemeral_public_key.public_bytes( - encoding=serialization.Encoding.Raw, - format=serialization.PublicFormat.Raw - ) - - # Perform key exchange - shared_key = ephemeral_private_key.exchange(peer_public_key) - - # Derive a symmetric key using HKDF - derived_key = HKDF( - algorithm=hashes.SHA256(), - length=32, - salt=None, - info=b'sms-secure-encryption' - ).derive(shared_key) - - # Encrypt with AES-GCM - aesgcm = AESGCM(derived_key) - nonce = os.urandom(12) - ciphertext = aesgcm.encrypt(nonce, message.encode('utf-8'), None) - - # Construct final payload: [Ephemeral Pub Key (32)] + [Nonce (12)] + [Ciphertext + Tag] - payload = ephemeral_pub_bytes + nonce + ciphertext - - # We prepend a marker to easily identify secure messages - marker = "SEC:" - b64_payload = base64.b64encode(payload).decode('ascii') - - return marker + b64_payload - - def decrypt_message(self, secure_payload: str) -> str: - """ - Decrypts a secure payload. - Assumes it starts with 'SEC:'. - """ - if not secure_payload.startswith("SEC:"): - raise ValueError("Not a secure message format.") - - b64_payload = secure_payload[4:] - try: - payload = base64.b64decode(b64_payload) - except Exception: - raise ValueError("Invalid Base64 payload.") - - if len(payload) < 32 + 12 + 16: # Length of pub key + nonce + tag - raise ValueError("Payload too short.") - - ephemeral_pub_bytes = payload[:32] - nonce = payload[32:44] - ciphertext = payload[44:] - - ephemeral_public_key = x25519.X25519PublicKey.from_public_bytes(ephemeral_pub_bytes) - - # Key exchange - shared_key = self.private_key.exchange(ephemeral_public_key) - - # Derive symmetric key - derived_key = HKDF( - algorithm=hashes.SHA256(), - length=32, - salt=None, - info=b'sms-secure-encryption' - ).derive(shared_key) - - # Decrypt - aesgcm = AESGCM(derived_key) - try: - plaintext = aesgcm.decrypt(nonce, ciphertext, None) - return plaintext.decode('utf-8') - except Exception as e: - raise ValueError("Decryption failed. Invalid key or message tampered.") from e - -if __name__ == "__main__": - # Test - alice = CryptoEngine() - alice_pub = alice.get_my_public_key_pem() - - bob = CryptoEngine() - bob_pub = bob.get_my_public_key_pem() - - msg = "This is a highly secret message!" - - # Alice sends to Bob - encrypted = alice.encrypt_message(msg, bob_pub) - print("Encrypted:", encrypted) - - # Bob decrypts - decrypted = bob.decrypt_message(encrypted) - print("Decrypted:", decrypted) - - assert msg == decrypted - print("Crypto Engine OK.") diff --git a/DB_Handler.py b/DB_Handler.py deleted file mode 100644 index 7f43392..0000000 --- a/DB_Handler.py +++ /dev/null @@ -1,134 +0,0 @@ -import sqlite3 -from datetime import datetime -import os - -DB_FILE = "sms_app.db" - -class DBHandler: - def __init__(self, db_path=DB_FILE): - self.db_path = db_path - self._initialize_db() - - def _get_connection(self): - return sqlite3.connect(self.db_path) - - def _initialize_db(self): - with self._get_connection() as conn: - cursor = conn.cursor() - # Create contacts table - cursor.execute(''' - CREATE TABLE IF NOT EXISTS contacts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - phone TEXT UNIQUE NOT NULL, - public_key TEXT - ) - ''') - # Create messages table - cursor.execute(''' - CREATE TABLE IF NOT EXISTS messages ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - phone TEXT NOT NULL, - text TEXT NOT NULL, - date TEXT NOT NULL, - is_secure INTEGER NOT NULL, - status TEXT NOT NULL, - FOREIGN KEY(phone) REFERENCES contacts(phone) - ) - ''') - # Create settings table for our own keypair if needed (though usually we save keys to files) - cursor.execute(''' - CREATE TABLE IF NOT EXISTS settings ( - key TEXT PRIMARY KEY, - value TEXT - ) - ''') - conn.commit() - - # --- Contacts Methods --- - def add_contact(self, name, phone, public_key=None): - with self._get_connection() as conn: - cursor = conn.cursor() - try: - cursor.execute('INSERT INTO contacts (name, phone, public_key) VALUES (?, ?, ?)', - (name, phone, public_key)) - conn.commit() - return True - except sqlite3.IntegrityError: - return False # Phone number already exists - - def update_contact_key(self, phone, public_key): - with self._get_connection() as conn: - cursor = conn.cursor() - cursor.execute('UPDATE contacts SET public_key = ? WHERE phone = ?', (public_key, phone)) - conn.commit() - - def get_contact(self, phone): - with self._get_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT name, phone, public_key FROM contacts WHERE phone = ?', (phone,)) - return cursor.fetchone() - - def get_all_contacts(self): - with self._get_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT name, phone, public_key FROM contacts') - return cursor.fetchall() - - def set_contact_name_if_not_exists(self, phone, name): - with self._get_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT phone FROM contacts WHERE phone = ?', (phone,)) - if not cursor.fetchone(): - cursor.execute('INSERT INTO contacts (name, phone, public_key) VALUES (?, ?, ?)', (name, phone, None)) - conn.commit() - - # --- Messages Methods --- - def add_message(self, phone, text, is_secure, status): - date_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - with self._get_connection() as conn: - cursor = conn.cursor() - cursor.execute(''' - INSERT INTO messages (phone, text, date, is_secure, status) - VALUES (?, ?, ?, ?, ?) - ''', (phone, text, date_str, int(is_secure), status)) - conn.commit() - return cursor.lastrowid - - def get_messages_for_contact(self, phone): - with self._get_connection() as conn: - cursor = conn.cursor() - cursor.execute(''' - SELECT id, phone, text, date, is_secure, status - FROM messages - WHERE phone = ? - ORDER BY date ASC - ''', (phone,)) - return cursor.fetchall() - - def delete_message(self, msg_id): - with self._get_connection() as conn: - cursor = conn.cursor() - cursor.execute('DELETE FROM messages WHERE id = ?', (msg_id,)) - conn.commit() - - # --- Settings Methods --- - def get_setting(self, key): - with self._get_connection() as conn: - cursor = conn.cursor() - cursor.execute('SELECT value FROM settings WHERE key = ?', (key,)) - row = cursor.fetchone() - return row[0] if row else None - - def set_setting(self, key, value): - with self._get_connection() as conn: - cursor = conn.cursor() - cursor.execute('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)', (key, value)) - conn.commit() - -if __name__ == "__main__": - db = DBHandler("test_sms.db") - db.add_contact("Alice", "+1234567890", "PUBLIC_KEY_CONTENT") - db.add_message("+1234567890", "Hello Alice!", is_secure=False, status="sent") - print("Database OK.") - os.remove("test_sms.db") diff --git a/GSM_Manager.py b/GSM_Manager.py deleted file mode 100644 index 5bed997..0000000 --- a/GSM_Manager.py +++ /dev/null @@ -1,218 +0,0 @@ -import serial -import time -import threading -import re - -class GSMManager: - def __init__(self, port='COM1', baudrate=115200, message_callback=None): - """ - message_callback: function(sender_number, message_text) - called when a new SMS is fully read. - """ - self.port = port - self.baudrate = baudrate - self.serial_conn = None - self.is_running = False - self.read_thread = None - self.message_callback = message_callback - - # Lock for thread-safe AT command execution - self.lock = threading.Lock() - - def connect(self): - try: - self.serial_conn = serial.Serial( - port=self.port, - baudrate=self.baudrate, - timeout=1 # 1 second timeout for readline - ) - self.is_running = True - - # Setup Modem - self.send_at_cmd('AT') - self.send_at_cmd('AT+CMGF=1') # Text Mode - self.send_at_cmd('AT+CNMI=2,1,0,0,0') # Route URC to TE for +CMTI - - # Start Read Thread - self.read_thread = threading.Thread(target=self._read_loop, daemon=True) - self.read_thread.start() - - return True - except Exception as e: - print(f"Failed to connect GSM: {e}") - return False - - def disconnect(self): - self.is_running = False - if self.serial_conn and self.serial_conn.is_open: - self.serial_conn.close() - if self.read_thread: - self.read_thread.join(timeout=2) - - def send_at_cmd(self, cmd, expected_resp='OK', timeout=2): - """Send an AT command safely and wait for a response.""" - with self.lock: - if not self.serial_conn or not self.serial_conn.is_open: - return False, "Not connected" - - # Flush input buffer to clear old data - self.serial_conn.reset_input_buffer() - - full_cmd = cmd + '\r\n' - self.serial_conn.write(full_cmd.encode('ascii')) - - start_time = time.time() - response_lines = [] - - while time.time() - start_time < timeout: - if self.serial_conn.in_waiting: - line = self.serial_conn.readline().decode('ascii', errors='ignore').strip() - if line: - response_lines.append(line) - if expected_resp in line or 'ERROR' in line: - break - else: - time.sleep(0.05) - - return expected_resp in "\n".join(response_lines), response_lines - - def send_sms(self, phone_number, message): - """ - Sends an SMS. For long messages, splits them up. - (Note: Quectel M66 supports up to 160 chars in 7-bit text mode natively. - For simplicity, we chunk it to 150 chars max per SMS if it exceeds). - """ - chunk_size = 140 # Safe limit for standard ASCII/Base64 - chunks = [message[i:i+chunk_size] for i in range(0, len(message), chunk_size)] - - for idx, chunk in enumerate(chunks): - # If multiple chunks, we can prepend (1/2) kind of indicator for normal texts - # But for base64 secure chunks, they might need stitching. - # For simplicity, we just send them sequentially. - # In a production resilient app, multipart PDU mode is preferred. - - success = self._send_single_sms(phone_number, chunk) - if not success: - return False - - if len(chunks) > 1: - time.sleep(2) # Add some delay between multi-part sends - - return True - - def _send_single_sms(self, phone_number, text): - with self.lock: - try: - self.serial_conn.reset_input_buffer() - - # Start SMS prompt - self.serial_conn.write(f'AT+CMGS="{phone_number}"\r\n'.encode('ascii')) - - # Wait for '>' - start_time = time.time() - prompt_ready = False - while time.time() - start_time < 2: - if self.serial_conn.in_waiting: - char = self.serial_conn.read().decode('ascii', errors='ignore') - if char == '>': - prompt_ready = True - break - time.sleep(0.05) - - if not prompt_ready: - print("SMS prompt '>' not received.") - # Send ESC just in case - self.serial_conn.write(chr(27).encode('ascii')) - return False - - # Write text and send Ctrl+Z (ASCII 26) - self.serial_conn.write(text.encode('ascii') + chr(26).encode('ascii')) - - # Wait for +CMGS and OK - start_time = time.time() - success = False - response_lines = [] - while time.time() - start_time < 10: # SMS sending can take time - if self.serial_conn.in_waiting: - line = self.serial_conn.readline().decode('ascii', errors='ignore').strip() - if line: - response_lines.append(line) - if 'OK' in line: - success = True - break - elif 'ERROR' in line: - break - else: - time.sleep(0.1) - - return success - except Exception as e: - print(f"Error sending SMS: {e}") - return False - - def _read_loop(self): - """Background thread continuously reading URCs like +CMTI""" - while self.is_running: - try: - if self.serial_conn and self.serial_conn.in_waiting: - # We only read if we can acquire the lock without waiting, - # otherwise it means AT command is in progress and it handles reading. - # Wait, AT command reading in `send_at_cmd` reads synchronously. URCs might mix. - # Standard practice for URCs: the AT command sender ignores unexpected URCs, - # OR we have a dedicated thread that reads everything and routes URCs vs command responses. - # For simplicity, we try to grab the lock. If locked, AT comm is happening. - if self.lock.acquire(blocking=False): - try: - line = self.serial_conn.readline().decode('ascii', errors='ignore').strip() - if line.startswith('+CMTI:'): - # Incoming message: +CMTI: "SM",1 - match = re.search(r'\+CMTI:\s*".*?",(\d+)', line) - if match: - msg_index = int(match.group(1)) - # Process it in a new thread or queue to avoid blocking this loop - threading.Thread(target=self.process_incoming_sms, args=(msg_index,), daemon=True).start() - finally: - self.lock.release() - except Exception as e: - pass - time.sleep(0.1) - - def process_incoming_sms(self, index): - """Reads the SMS payload from memory and deletes it.""" - # read_sms uses AT command, so it uses the lock - # wait a bit ensuring any ongoing AT comm finishes - time.sleep(1) - - success, response = self.send_at_cmd(f'AT+CMGR={index}', expected_resp='OK', timeout=3) - if success: - sender = "Unknown" - text_lines = [] - is_text_block = False - - for line in response: - if line.startswith('+CMGR:'): - # +CMGR: "REC UNREAD","+989123456789",,"23/05/26,10:30:00+14" - parts = line.split(',') - if len(parts) >= 2: - sender = parts[1].strip('"') - is_text_block = True - elif is_text_block and line not in ['OK', 'ERROR'] and not line.startswith('+CMGR:'): - text_lines.append(line) - - full_text = "\n".join(text_lines) - - if self.message_callback: - self.message_callback(sender, full_text) - - # Delete message to free space - self.send_at_cmd(f'AT+CMGD={index}') - -if __name__ == "__main__": - def on_sms(sender, text): - print(f"\n[NEW SMS] From: {sender}\nText:{text}\n") - - gsm = GSMManager(port='COM3') - print("Testing locally...") - # This will obviously fail without actual M66 module on COM3, but acts as boilerplate check. - # gsm.connect() - # gsm.disconnect() diff --git a/main.py b/main.py index 8ca2075..a40a617 100644 --- a/main.py +++ b/main.py @@ -258,7 +258,7 @@ def main(): return 1 try: - from secure_sms.controller import AppController + from secure_sms.application.controller import AppController from secure_sms.ui import SecureSmsApp controller = AppController() diff --git a/refactor.py b/refactor.py new file mode 100644 index 0000000..c1a4c14 --- /dev/null +++ b/refactor.py @@ -0,0 +1,70 @@ +import os +import shutil +from pathlib import Path + +def main(): + base = Path(r"c:\Users\Pars\Desktop\saba-python\secure_sms") + + print("[1/3] Creating Architectural Directories...") + for folder in ['core', 'application', 'infrastructure', 'ui']: + (base / folder).mkdir(exist_ok=True) + (base / folder / '__init__.py').touch(exist_ok=True) + + print("[2/3] Moving Domain Logic Files...") + moves = { + 'models.py': 'core', + 'protocol.py': 'core', + 'security.py': 'core', + 'services.py': 'application', + 'controller.py': 'application', + 'database.py': 'infrastructure', + 'gsm.py': 'infrastructure' + } + + for file_name, folder in moves.items(): + src = base / file_name + dst = base / folder / file_name + if src.exists(): + shutil.move(str(src), str(dst)) + print(f' -> Moved {file_name} to {folder}/') + + print("[3/3] Refactoring Import Statements...") + replacements = { + 'from secure_sms.infrastructure.database': 'from secure_sms.infrastructure.database', + 'from secure_sms.infrastructure.gsm': 'from secure_sms.infrastructure.gsm', + 'from secure_sms.application.services': 'from secure_sms.application.services', + 'from secure_sms.application.controller': 'from secure_sms.application.controller', + 'from secure_sms.core.models': 'from secure_sms.core.models', + 'from secure_sms.core.protocol': 'from secure_sms.core.protocol', + 'from secure_sms.core.security': 'from secure_sms.core.security', + 'import secure_sms.infrastructure.database': 'import secure_sms.infrastructure.database' + } + + project_root = Path(r"c:\Users\Pars\Desktop\saba-python") + for py_file in project_root.rglob('*.py'): + if py_file.name == '.venv' or '.runtime-venv' in str(py_file): + continue + try: + content = py_file.read_text(encoding='utf-8') + orig = content + for old, new in replacements.items(): + content = content.replace(old, new) + if content != orig: + py_file.write_text(content, encoding='utf-8') + print(f' -> Updated imports in {py_file.name}') + except Exception as e: + pass + + # Cleanup obsolete root files + print("[Cleanup] Removing Legacy Files...") + legacy = ["App_GUI.py", "Crypto_Engine.py", "DB_Handler.py", "GSM_Manager.py"] + for legacy_file in legacy: + f = project_root / legacy_file + if f.exists(): + f.unlink() + print(f' -> Deleted {legacy_file}') + + print("\n✅ Migration complete! Backend is now strictly adhering to Clean Architecture.") + +if __name__ == "__main__": + main() diff --git a/secure_sms/__pycache__/ui.cpython-313.pyc b/secure_sms/__pycache__/ui.cpython-313.pyc index ce9c9b9..1ba0a10 100644 Binary files a/secure_sms/__pycache__/ui.cpython-313.pyc and b/secure_sms/__pycache__/ui.cpython-313.pyc differ diff --git a/secure_sms/application/__init__.py b/secure_sms/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/secure_sms/application/__pycache__/controller.cpython-313.pyc b/secure_sms/application/__pycache__/controller.cpython-313.pyc new file mode 100644 index 0000000..2bfa4a1 Binary files /dev/null and b/secure_sms/application/__pycache__/controller.cpython-313.pyc differ diff --git a/secure_sms/application/__pycache__/services.cpython-313.pyc b/secure_sms/application/__pycache__/services.cpython-313.pyc new file mode 100644 index 0000000..b2e35e2 Binary files /dev/null and b/secure_sms/application/__pycache__/services.cpython-313.pyc differ diff --git a/secure_sms/controller.py b/secure_sms/application/controller.py similarity index 66% rename from secure_sms/controller.py rename to secure_sms/application/controller.py index 308611d..8af4180 100644 --- a/secure_sms/controller.py +++ b/secure_sms/application/controller.py @@ -1,18 +1,48 @@ from __future__ import annotations +import queue +import threading from typing import Optional -from secure_sms.database import Database -from secure_sms.gsm import GSMGateway -from secure_sms.services import SecureMessagingService +from secure_sms.infrastructure.database import Database +from secure_sms.infrastructure.gsm import IMessageGateway, GSMGateway +from secure_sms.application.services import SecureMessagingService class AppController: def __init__(self): self.db = Database() self.service = SecureMessagingService(self.db) - self.gsm: Optional[GSMGateway] = None + self.gsm: Optional[IMessageGateway] = None self.ui = None + self._outbox = queue.Queue() + self._worker_thread = threading.Thread(target=self._outbox_worker, daemon=True) + self._worker_thread.start() + + def _outbox_worker(self): + while True: + try: + task = self._outbox.get() + if task is None: + break + phone = task.get("phone") + frames = task.get("frames") + msg_id = task.get("msg_id") + + if self.gsm and self.gsm.is_connected: + sent = self.gsm.send_frames(phone, frames) + state = "sent" if sent else "failed" + else: + # In simulation mode (no modem attached), we instantly mark it simul-sent. + sent = True + state = "simulated" + + if msg_id: + self.db.update_message_transport_state(msg_id, state) + self._notify_ui(phone) + self._outbox.task_done() + except Exception: + pass def bind_ui(self, ui): self.ui = ui @@ -74,40 +104,22 @@ class AppController: except Exception as exc: return False, str(exc) - if self.gsm and self.gsm.is_connected: - sent = self.gsm.send_frames(phone, frames) - state = "sent" if sent else "failed" - else: - sent = True - state = "simulated" - - if sent: - self.service.store_outgoing_message(phone, text, mode, state) - self._notify_ui(phone) - return True, state - return False, "failed" + msg_id = self.service.store_outgoing_message(phone, text, mode, "queued") + self._outbox.put({"phone": phone, "frames": frames, "msg_id": msg_id}) + self._notify_ui(phone) + return True, "queued" def request_secure(self, phone: str) -> tuple[bool, str]: frames = self.service.request_secure_channel(phone) - if self.gsm and self.gsm.is_connected: - ok = self.gsm.send_frames(phone, frames) - status = "sent" if ok else "failed" - else: - ok = True - status = "simulated" + self._outbox.put({"phone": phone, "frames": frames, "msg_id": None}) self._notify_ui(phone) - return ok, status + return True, "queued" def switch_to_normal(self, phone: str) -> tuple[bool, str]: frames = self.service.request_normal_mode(phone) - if self.gsm and self.gsm.is_connected: - ok = self.gsm.send_frames(phone, frames) - status = "sent" if ok else "failed" - else: - ok = True - status = "simulated" + self._outbox.put({"phone": phone, "frames": frames, "msg_id": None}) self._notify_ui(phone) - return ok, status + return True, "queued" def get_admin_snapshot(self) -> dict: snapshot = self.service.get_admin_snapshot() diff --git a/secure_sms/services.py b/secure_sms/application/services.py similarity index 97% rename from secure_sms/services.py rename to secure_sms/application/services.py index 7abc8a0..738e655 100644 --- a/secure_sms/services.py +++ b/secure_sms/application/services.py @@ -2,9 +2,9 @@ import platform from pathlib import Path from typing import Optional -from secure_sms.database import Database, utc_now -from secure_sms.models import ContactDetails, ContactSummary, MessageView, PendingPacketView, SecureEventView -from secure_sms.protocol import ( +from secure_sms.infrastructure.database import Database, utc_now +from secure_sms.core.models import ContactDetails, ContactSummary, MessageView, PendingPacketView, SecureEventView +from secure_sms.core.protocol import ( build_control_frames, build_message_frames, decode_control_payload, @@ -12,7 +12,7 @@ from secure_sms.protocol import ( encode_plain_body, parse_frame, ) -from secure_sms.security import ECCCryptoService, PasswordManager, StorageCipher +from secure_sms.core.security import ECCCryptoService, PasswordManager, StorageCipher SYSTEM_CONTACT_LABEL = "مخاطب ناشناس" @@ -183,8 +183,8 @@ class SecureMessagingService: encoded_payload = encode_plain_body(text) return build_message_frames("N", encoded_payload), "normal" - def store_outgoing_message(self, phone: str, text: str, mode: str, transport_state: str): - self.db.add_message( + def store_outgoing_message(self, phone: str, text: str, mode: str, transport_state: str) -> int: + return self.db.add_message( phone=phone, direction="out", body_enc=self._enc(text), diff --git a/secure_sms/core/__init__.py b/secure_sms/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/secure_sms/models.py b/secure_sms/core/models.py similarity index 100% rename from secure_sms/models.py rename to secure_sms/core/models.py diff --git a/secure_sms/protocol.py b/secure_sms/core/protocol.py similarity index 97% rename from secure_sms/protocol.py rename to secure_sms/core/protocol.py index 1d37180..b401e5e 100644 --- a/secure_sms/protocol.py +++ b/secure_sms/core/protocol.py @@ -3,7 +3,7 @@ import uuid from dataclasses import dataclass from typing import Optional -from secure_sms.security import b64u_decode, b64u_encode +from secure_sms.core.security import b64u_decode, b64u_encode FRAME_PREFIX = "@SSM1" diff --git a/secure_sms/security.py b/secure_sms/core/security.py similarity index 100% rename from secure_sms/security.py rename to secure_sms/core/security.py diff --git a/secure_sms/infrastructure/__init__.py b/secure_sms/infrastructure/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/secure_sms/database.py b/secure_sms/infrastructure/database.py similarity index 97% rename from secure_sms/database.py rename to secure_sms/infrastructure/database.py index 468807a..25feb8f 100644 --- a/secure_sms/database.py +++ b/secure_sms/infrastructure/database.py @@ -3,7 +3,7 @@ from datetime import datetime from pathlib import Path from typing import Optional -from secure_sms.security import SecurityMetadata, StorageCipher +from secure_sms.core.security import SecurityMetadata, StorageCipher DB_FILE = "secure_sms_v2.db" @@ -279,6 +279,14 @@ class Database: conn.commit() return int(cursor.lastrowid) + def update_message_transport_state(self, message_id: int, transport_state: str): + with self._connect() as conn: + conn.execute( + "UPDATE messages SET transport_state = ? WHERE id = ?", + (transport_state, message_id), + ) + conn.commit() + def list_message_rows(self, phone: str) -> list[sqlite3.Row]: with self._connect() as conn: cursor = conn.cursor() diff --git a/secure_sms/gsm.py b/secure_sms/infrastructure/gsm.py similarity index 93% rename from secure_sms/gsm.py rename to secure_sms/infrastructure/gsm.py index 00045f9..f821f19 100644 --- a/secure_sms/gsm.py +++ b/secure_sms/infrastructure/gsm.py @@ -1,3 +1,4 @@ +import abc import re import threading import time @@ -6,7 +7,22 @@ from typing import Callable, Optional import serial -class GSMGateway: +class IMessageGateway(abc.ABC): + @property + @abc.abstractmethod + def is_connected(self) -> bool: pass + + @abc.abstractmethod + def connect(self) -> bool: pass + + @abc.abstractmethod + def disconnect(self) -> None: pass + + @abc.abstractmethod + def send_frames(self, phone: str, frames: list[str]) -> bool: pass + + +class GSMGateway(IMessageGateway): def __init__( self, port: str, diff --git a/secure_sms/ui.py b/secure_sms/ui.py index 52a016d..e264b2a 100644 --- a/secure_sms/ui.py +++ b/secure_sms/ui.py @@ -12,30 +12,32 @@ except ImportError: get_display = None -ctk.set_appearance_mode("light") +ctk.set_appearance_mode("dark") ctk.set_default_color_theme("blue") -PRIMARY = "#175B4B" -PRIMARY_DARK = "#0E4236" -PRIMARY_SOFT = "#DFF1E8" -ACCENT = "#E8A04D" -ACCENT_DARK = "#C97E2D" -BACKGROUND = "#F5EFE7" -CARD = "#FFFDFC" -SURFACE = "#FBF7F2" -INPUT_BG = "#FFFCF8" -TEXT = "#16312A" -MUTED = "#6B7A77" -DANGER = "#B6465F" -WARNING = "#9A6C3C" -BORDER = "#E5DCCE" -KEYBOARD_BG = "#D4DCE2" -KEY_FACE = "#FFFFFF" -KEY_MUTED = "#BCC1C9" -KEY_TEXT = "#000000" -SIDEBAR = "#1B5A4A" -SIDEBAR_SOFT = "#245E4E" +PRIMARY = "#2AABEE" +PRIMARY_DARK = "#229ED9" +PRIMARY_SOFT = "#1C3A4F" +ACCENT = "#2AABEE" +ACCENT_DARK = "#229ED9" +BACKGROUND = "#0E1621" +CARD = "#17212B" +SURFACE = "#17212B" +INPUT_BG = "#242F3D" +TEXT = "#FFFFFF" +MUTED = "#6C7883" +DANGER = "#E05D57" +WARNING = "#E0A356" +BORDER = "#232E3C" +KEYBOARD_BG = "#17212B" +KEY_FACE = "#242F3D" +KEY_MUTED = "#1C2733" +KEY_TEXT = "#FFFFFF" +SIDEBAR = "#17212B" +SIDEBAR_SOFT = "#242F3D" +BUBBLE_OUT = "#2B5278" +BUBBLE_IN = "#182533" FONT_BODY = "Tahoma" if os.name == "nt" else "DejaVu Sans" FONT_TITLE = "Tahoma" if os.name == "nt" else "DejaVu Sans" RTL_PATTERN = re.compile(r"[\u0600-\u06FF]") @@ -560,7 +562,7 @@ class SecureSmsApp(ctk.CTk): def _show_lock_screen(self): self._clear_root() - frame = ctk.CTkFrame(self.root_frame, fg_color=SURFACE, corner_radius=28, border_width=1, border_color=BORDER) + frame = ctk.CTkFrame(self.root_frame, fg_color=CARD, corner_radius=16, border_width=1, border_color=BORDER) frame.place( relx=0.5, rely=0.5, @@ -574,55 +576,69 @@ class SecureSmsApp(ctk.CTk): if not self.controller.is_bootstrapped() else 'برای ورود، رمز اصلی برنامه را وارد کن.' ) + RTLLabel( + frame, + text='📨 صبا', + font=ctk.CTkFont(family=FONT_TITLE, size=32, weight="bold"), + text_color=PRIMARY, + ).pack(pady=(24, 4)) RTLLabel( frame, text=title, - font=ctk.CTkFont(family=FONT_BODY, size=28, weight="bold"), + font=ctk.CTkFont(family=FONT_BODY, size=20, weight="bold"), text_color=TEXT, - ).pack(pady=(30, 10)) + ).pack(pady=(4, 6)) RTLLabel( frame, text=subtitle, wraplength=min(self.window_width - 110, 420), justify="right", - font=ctk.CTkFont(family=FONT_BODY, size=16), + font=ctk.CTkFont(family=FONT_BODY, size=14), text_color=MUTED, ).pack(padx=28) self.password_entry = RTLEntry( frame, placeholder_text='رمز اصلی', show="*", - height=48, - font=ctk.CTkFont(family=FONT_BODY, size=17), + height=44, + font=ctk.CTkFont(family=FONT_BODY, size=16), + fg_color=INPUT_BG, + border_color=BORDER, + text_color=TEXT, ) - self.password_entry.pack(fill="x", padx=42, pady=(24, 12)) + self.password_entry.pack(fill="x", padx=36, pady=(20, 10)) self.confirm_entry = None if not self.controller.is_bootstrapped(): self.confirm_entry = RTLEntry( frame, placeholder_text='تکرار رمز', show="*", - height=48, - font=ctk.CTkFont(family=FONT_BODY, size=17), + height=44, + font=ctk.CTkFont(family=FONT_BODY, size=16), + fg_color=INPUT_BG, + border_color=BORDER, + text_color=TEXT, ) - self.confirm_entry.pack(fill="x", padx=42, pady=8) + self.confirm_entry.pack(fill="x", padx=36, pady=6) self.lock_message = RTLLabel( frame, text="", text_color=DANGER, - font=ctk.CTkFont(family=FONT_BODY, size=14 if self.is_portrait else 15), + font=ctk.CTkFont(family=FONT_BODY, size=13), ) - self.lock_message.pack(pady=(6, 10)) + self.lock_message.pack(pady=(4, 8)) action_text = 'شروع برنامه' if not self.controller.is_bootstrapped() else 'ورود' RTLButton( frame, text=action_text, - height=48, + height=44, + corner_radius=8, fg_color=PRIMARY, hover_color=PRIMARY_DARK, + text_color="#FFFFFF", command=self._submit_lock_screen, - font=ctk.CTkFont(family=FONT_BODY, size=18, weight="bold"), - ).pack(fill="x", padx=42, pady=(8, 12)) + font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), + ).pack(fill="x", padx=36, pady=(4, 16)) self.password_entry.bind("", lambda _event: self._submit_lock_screen()) self._register_text_input(self.password_entry, title='رمز اصلی', layout="en", submit=self._submit_lock_screen) if self.confirm_entry: @@ -713,66 +729,71 @@ class SecureSmsApp(ctk.CTk): main_row = 1 if self.is_portrait else 0 main_column = 0 if self.is_portrait else 1 - self.sidebar = ctk.CTkFrame(self.root_frame, fg_color=SIDEBAR, corner_radius=0) + self.sidebar = ctk.CTkFrame(self.root_frame, fg_color=SIDEBAR, corner_radius=0, border_width=0) self.sidebar.grid(row=0, column=0, sticky="nsew") self.sidebar.grid_columnconfigure(0, weight=1) self.sidebar.grid_rowconfigure(5, weight=1) + sidebar_header = ctk.CTkFrame(self.sidebar, fg_color="transparent") + sidebar_header.grid(row=0, column=0, padx=14, pady=(12, 2), sticky="ew") + sidebar_header.grid_columnconfigure(0, weight=1) RTLLabel( - self.sidebar, + sidebar_header, text='صبا', - text_color="white", + text_color=TEXT, font=ctk.CTkFont(family=FONT_TITLE, size=title_size, weight="bold"), - ).grid(row=0, column=0, padx=20, pady=(16, 2), sticky="e") + ).grid(row=0, column=0, sticky="e") RTLLabel( - self.sidebar, - text='پیام\u200cرسان امن و ساده برای کاربر غیر فنی', - text_color="#D5E8E1", + sidebar_header, + text='پیام\u200cرسان امن', + text_color=MUTED, font=ctk.CTkFont(family=FONT_BODY, size=subtitle_size), - ).grid(row=1, column=0, padx=20, sticky="e") + ).grid(row=1, column=0, sticky="e") self.connection_badge = RTLLabel( self.sidebar, text="", - corner_radius=999, - fg_color="#2E7D62", - text_color="white", - font=ctk.CTkFont(family=FONT_BODY, size=15 if self.is_portrait else 14, weight="bold"), - padx=14, - pady=8, + corner_radius=6, + fg_color="#1C3A4F", + text_color=MUTED, + font=ctk.CTkFont(family=FONT_BODY, size=13, weight="bold"), + padx=10, + pady=6, ) - self.connection_badge.grid(row=2, column=0, padx=20, pady=(12, 10), sticky="e") + self.connection_badge.grid(row=2, column=0, padx=14, pady=(6, 8), sticky="ew") top_actions = ctk.CTkFrame(self.sidebar, fg_color="transparent") - top_actions.grid(row=3, column=0, padx=14, pady=(0, 8), sticky="ew") + top_actions.grid(row=3, column=0, padx=14, pady=(0, 6), sticky="ew") top_actions.grid_columnconfigure((0, 1), weight=1) RTLButton( top_actions, - text='مخاطب جدید', + text='گفتگوی جدید', command=self._open_contact_dialog, - fg_color=ACCENT, - text_color="#3A2514", - hover_color=ACCENT_DARK, + fg_color=PRIMARY, + text_color="#FFFFFF", + hover_color=PRIMARY_DARK, + corner_radius=8, height=action_height, - font=ctk.CTkFont(family=FONT_BODY, size=15 if self.is_portrait else 14, weight="bold"), - ).grid(row=0, column=0, padx=6, sticky="ew") + font=ctk.CTkFont(family=FONT_BODY, size=14, weight="bold"), + ).grid(row=0, column=0, padx=4, sticky="ew") RTLButton( top_actions, - text='تنظیمات', + text='⚙ تنظیمات', command=self._open_settings_panel, - fg_color="#F4EFE9", - text_color="#15302B", - hover_color="#ECE1D5", + fg_color=INPUT_BG, + text_color=TEXT, + hover_color="#2D3A49", + corner_radius=8, height=action_height, - font=ctk.CTkFont(family=FONT_BODY, size=15 if self.is_portrait else 14, weight="bold"), - ).grid(row=0, column=1, padx=6, sticky="ew") + font=ctk.CTkFont(family=FONT_BODY, size=14, weight="bold"), + ).grid(row=0, column=1, padx=4, sticky="ew") self.contact_form_card = ctk.CTkFrame( self.sidebar, fg_color=SIDEBAR_SOFT, - corner_radius=18, + corner_radius=10, border_width=1, - border_color="#3B7B66", + border_color=BORDER, ) self.contact_form_card.grid(row=4, column=0, padx=14, pady=(0, 10), sticky="ew") self.contact_form_card.grid_columnconfigure(0, weight=1) @@ -809,20 +830,22 @@ class SecureSmsApp(ctk.CTk): RTLButton( contact_actions, text='ذخیره', - fg_color=ACCENT, - text_color="#3A2514", - hover_color=ACCENT_DARK, + fg_color=PRIMARY, + text_color="#FFFFFF", + hover_color=PRIMARY_DARK, command=self._save_contact_inline, + corner_radius=8, font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), height=40, ).grid(row=0, column=0, padx=4, sticky="ew") RTLButton( contact_actions, text='بستن', - fg_color="#F4EFE9", + fg_color=INPUT_BG, text_color=TEXT, - hover_color="#ECE1D5", + hover_color="#2D3A49", command=self._hide_contact_form, + corner_radius=8, font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), height=40, ).grid(row=0, column=1, padx=4, sticky="ew") @@ -839,46 +862,46 @@ class SecureSmsApp(ctk.CTk): self.contacts_frame = RTLScrollableFrame( self.sidebar, height=160 if self.is_portrait else 320, - label_text='مخاطب\u200cها', - label_font=ctk.CTkFont(family=FONT_BODY, size=17 if self.is_portrait else 18, weight="bold"), + label_text='گفتگو\u200cها', + label_font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), fg_color="transparent", ) - self.contacts_frame.grid(row=5, column=0, padx=12, pady=8, sticky="nsew") + self.contacts_frame.grid(row=5, column=0, padx=8, pady=4, sticky="nsew") self.main_panel = ctk.CTkFrame(self.root_frame, fg_color=BACKGROUND, corner_radius=0) self.main_panel.grid(row=main_row, column=main_column, sticky="nsew") self.main_panel.grid_rowconfigure(1, weight=1) self.main_panel.grid_columnconfigure(0, weight=1) - self.header_card = ctk.CTkFrame(self.main_panel, fg_color=CARD, corner_radius=24, border_width=1, border_color=BORDER) - self.header_card.grid(row=0, column=0, padx=outer_pad, pady=(outer_pad, inner_pad), sticky="ew") + self.header_card = ctk.CTkFrame(self.main_panel, fg_color=CARD, corner_radius=0, border_width=0) + self.header_card.grid(row=0, column=0, sticky="ew") self.header_card.grid_columnconfigure(0, weight=1) self.header_card.grid_columnconfigure(1, weight=0) self.chat_title = RTLLabel( self.header_card, text='یک مخاطب را انتخاب کن', text_color=TEXT, - font=ctk.CTkFont(family=FONT_TITLE, size=18 if self.is_portrait else 20, weight="bold"), + font=ctk.CTkFont(family=FONT_TITLE, size=16 if self.is_portrait else 18, weight="bold"), ) - self.chat_title.grid(row=0, column=0, padx=22, pady=(18, 4), sticky="e") + self.chat_title.grid(row=0, column=0, padx=16, pady=(10, 2), sticky="e") self.chat_subtitle = RTLLabel( self.header_card, text='در اینجا فقط دو حالت داری: عادی یا امن', text_color=MUTED, - font=ctk.CTkFont(family=FONT_BODY, size=14), + font=ctk.CTkFont(family=FONT_BODY, size=12), ) - self.chat_subtitle.grid(row=1, column=0, padx=22, pady=(0, 18), sticky="e") + self.chat_subtitle.grid(row=1, column=0, padx=16, pady=(0, 10), sticky="e") self.mode_badge = RTLLabel( self.header_card, text='عادی', fg_color=PRIMARY_SOFT, text_color=PRIMARY, - corner_radius=999, - padx=20, - pady=10, - font=ctk.CTkFont(family=FONT_BODY, size=14, weight="bold"), + corner_radius=6, + padx=14, + pady=6, + font=ctk.CTkFont(family=FONT_BODY, size=12, weight="bold"), ) - self.mode_badge.grid(row=0, column=1, rowspan=2, padx=20, sticky="e") + self.mode_badge.grid(row=0, column=1, rowspan=2, padx=14, sticky="e") content = ctk.CTkFrame(self.main_panel, fg_color="transparent") content.grid(row=1, column=0, padx=outer_pad, pady=(0, inner_pad), sticky="nsew") @@ -900,7 +923,7 @@ class SecureSmsApp(ctk.CTk): else: self.chat_container.grid(row=0, column=0, sticky="nsew", padx=(0, inner_pad)) - self.profile_card = ctk.CTkFrame(content, fg_color=CARD, corner_radius=24, border_width=1, border_color=BORDER) + self.profile_card = ctk.CTkFrame(content, fg_color=CARD, corner_radius=12, border_width=1, border_color=BORDER) self.profile_card.grid(row=1, column=0, sticky="ew") if self.is_portrait else self.profile_card.grid(row=0, column=1, sticky="nsew") self.profile_card.grid_columnconfigure(0, weight=1) self.profile_title_label = RTLLabel( @@ -937,54 +960,60 @@ class SecureSmsApp(ctk.CTk): text='فعال\u200cسازی ارتباط امن', fg_color=PRIMARY, hover_color=PRIMARY_DARK, - font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), + text_color="#FFFFFF", + corner_radius=8, + font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), command=self._toggle_secure_mode, - height=48, + height=42, ) - self.secure_button.grid(row=4, column=0, padx=18, pady=(0, 10), sticky="ew") + self.secure_button.grid(row=4, column=0, padx=16, pady=(0, 8), sticky="ew") self.normal_button = RTLButton( self.profile_card, text='بازگشت به حالت عادی', - fg_color="#F4EFE9", + fg_color=INPUT_BG, text_color=TEXT, - hover_color="#ECE1D5", - font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), + hover_color="#2D3A49", + corner_radius=8, + font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), command=self._switch_to_normal, - height=44, + height=40, ) - self.normal_button.grid(row=5, column=0, padx=18, pady=(0, 16), sticky="ew") + self.normal_button.grid(row=5, column=0, padx=16, pady=(0, 14), sticky="ew") self._configure_profile_card_layout() - composer = ctk.CTkFrame(self.main_panel, fg_color=CARD, corner_radius=24, border_width=1, border_color=BORDER) - composer.grid(row=2, column=0, padx=outer_pad, pady=(0, outer_pad), sticky="ew") + composer = ctk.CTkFrame(self.main_panel, fg_color=CARD, corner_radius=0, border_width=0) + composer.grid(row=2, column=0, sticky="ew") composer.grid_columnconfigure(0, weight=1) self.message_entry = RTLTextbox( composer, - height=72 if self.is_portrait else 82, + height=52 if self.is_portrait else 62, fg_color=INPUT_BG, + text_color=TEXT, border_width=0, - font=ctk.CTkFont(family=FONT_BODY, size=16), + corner_radius=10, + font=ctk.CTkFont(family=FONT_BODY, size=15), wrap="word", ) - self.message_entry.grid(row=0, column=0, padx=(16, inner_pad), pady=14 if self.is_portrait else 16, sticky="ew") + self.message_entry.grid(row=0, column=0, padx=(10, 6), pady=8, sticky="ew") actions = ctk.CTkFrame(composer, fg_color="transparent") - actions.grid(row=0, column=1, padx=(0, 16), pady=14 if self.is_portrait else 16, sticky="ns") + actions.grid(row=0, column=1, padx=(0, 10), pady=8, sticky="ns") self.send_state_label = RTLLabel( actions, text="", text_color=MUTED, - font=ctk.CTkFont(family=FONT_BODY, size=14), + font=ctk.CTkFont(family=FONT_BODY, size=11), ) - self.send_state_label.pack(pady=(4, 8)) + self.send_state_label.pack(pady=(2, 4)) RTLButton( actions, - text='ارسال', - fg_color=ACCENT, - text_color="#3A2514", - hover_color=ACCENT_DARK, - font=ctk.CTkFont(family=FONT_BODY, size=18, weight="bold"), - width=110 if self.is_portrait else 126, + text='➤', + fg_color=PRIMARY, + text_color="#FFFFFF", + hover_color=PRIMARY_DARK, + font=ctk.CTkFont(family=FONT_BODY, size=20, weight="bold"), + width=48, height=48, + corner_radius=24, command=self._send_message, ).pack() self.message_entry.bind("", lambda _event: self._send_message()) @@ -993,7 +1022,7 @@ class SecureSmsApp(ctk.CTk): self.overlay_frame = ctk.CTkFrame( self.main_panel, fg_color=CARD, - corner_radius=28, + corner_radius=12, border_width=1, border_color=BORDER, ) @@ -1015,9 +1044,9 @@ class SecureSmsApp(ctk.CTk): def _refresh_connection_badge(self): modem = self.controller.modem_status() if modem["connected"]: - self.connection_badge.configure(text=f"مودم متصل | {modem['port']}", fg_color="#2E7D62") + self.connection_badge.configure(text=f"مودم متصل | {modem['port']}", fg_color="#1C3A4F", text_color="#2AABEE") else: - self.connection_badge.configure(text=f"مودم آفلاین | {modem['port']}", fg_color="#9A6C3C") + self.connection_badge.configure(text=f"مودم آفلاین | {modem['port']}", fg_color="#3A2020", text_color="#E05D57") def _refresh_contacts(self): for widget in self.contacts_frame.winfo_children(): @@ -1037,15 +1066,15 @@ class SecureSmsApp(ctk.CTk): self.contacts_frame, text=f"{contact.name}\n{contact.phone}\n{contact.last_message_preview or 'آماده گفتگو'}", anchor="e", - height=76 if self.is_portrait else 88, - corner_radius=20, + height=72 if self.is_portrait else 80, + corner_radius=8, command=lambda phone=contact.phone: self._select_contact(phone), - fg_color="#F7EFE5" if selected else "#2A6956", - hover_color="#F0E3D5" if selected else "#32745F", - text_color=TEXT if selected else "white", - font=ctk.CTkFont(family=FONT_BODY, size=15), + fg_color="#2B5278" if selected else "transparent", + hover_color="#2B5278", + text_color=TEXT, + font=ctk.CTkFont(family=FONT_BODY, size=14), ) - card.grid(row=index, column=0, padx=8, pady=6, sticky="ew") + card.grid(row=index, column=0, padx=4, pady=2, sticky="ew") def _select_contact(self, phone: str): self.current_contact_phone = phone @@ -1093,51 +1122,52 @@ class SecureSmsApp(ctk.CTk): self.chat_container, text='هنوز پیامی ثبت نشده است.\nاز نوار پایین برای نوشتن پیام استفاده کن.', text_color=MUTED, - font=ctk.CTkFont(family=FONT_BODY, size=15) + font=ctk.CTkFont(family=FONT_BODY, size=14) ).pack(pady=40) return for message in messages: if message.direction == "system": + sys_frame = ctk.CTkFrame(self.chat_container, fg_color="#1C2733", corner_radius=8) + sys_frame.pack(pady=6, anchor="center") RTLLabel( - self.chat_container, + sys_frame, text=message.body, - text_color="#8A5C2E", - font=ctk.CTkFont(family=FONT_BODY, size=13), - wraplength=int(self.window_width * 0.7) - ).pack(pady=12, anchor="center") + text_color=MUTED, + font=ctk.CTkFont(family=FONT_BODY, size=12), + wraplength=int(self.window_width * 0.6) + ).pack(padx=12, pady=6) continue is_out = message.direction == "out" - bubble_color = "#E1F2E9" if is_out else "#FFFFFF" + bubble_color = BUBBLE_OUT if is_out else BUBBLE_IN anchor = "e" if is_out else "w" bubble = ctk.CTkFrame( self.chat_container, fg_color=bubble_color, - corner_radius=16, - border_width=1 if not is_out else 0, - border_color=BORDER + corner_radius=12, + border_width=0, ) - bubble.pack(anchor=anchor, padx=12, pady=6, fill="none") + bubble.pack(anchor=anchor, padx=8, pady=3, fill="none") RTLLabel( bubble, text=message.body, - text_color="#16312A", - font=ctk.CTkFont(family=FONT_BODY, size=16), - wraplength=max(200, int(self.window_width * 0.55)), + text_color="#FFFFFF", + font=ctk.CTkFont(family=FONT_BODY, size=15), + wraplength=max(180, int(self.window_width * 0.5)), justify="right" - ).pack(padx=16, pady=(12, 4), anchor="e") + ).pack(padx=12, pady=(8, 2), anchor="e") badge_text = f"🛡️ {message.created_at}" if message.mode == "secure" else message.created_at RTLLabel( bubble, text=badge_text, - text_color=MUTED, - font=ctk.CTkFont(family=FONT_BODY, size=11), + text_color="#7A8E9C", + font=ctk.CTkFont(family=FONT_BODY, size=10), justify="right" - ).pack(padx=16, pady=(0, 8), anchor="w" if is_out else "e") + ).pack(padx=12, pady=(0, 6), anchor="w" if is_out else "e") try: self.after(50, lambda: self.chat_container._parent_canvas.yview_moveto(1.0)) @@ -1255,9 +1285,9 @@ class SecureSmsApp(ctk.CTk): RTLButton( body, text='بازگشت به گفتگو', - fg_color="#F1E7DB", + fg_color=INPUT_BG, text_color=TEXT, - hover_color="#E8D8C7", + hover_color="#2D3A49", font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), height=40, command=self._hide_overlay, @@ -1286,9 +1316,9 @@ class SecureSmsApp(ctk.CTk): text='بستن', width=86, height=36, - fg_color="#F1E7DB", + fg_color=INPUT_BG, text_color=TEXT, - hover_color="#E8D8C7", + hover_color="#2D3A49", font=ctk.CTkFont(family=FONT_BODY, size=14, weight="bold"), command=self._hide_overlay, ).grid(row=0, column=1, padx=(8, 0), sticky="e") @@ -1353,9 +1383,9 @@ class SecureSmsApp(ctk.CTk): RTLButton( body, text='بازگشت به تنظیمات', - fg_color="#F1E7DB", + fg_color=INPUT_BG, text_color=TEXT, - hover_color="#E8D8C7", + hover_color="#2D3A49", font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), height=40, command=self._open_settings_panel, diff --git a/secure_sms/ui/__init__.py b/secure_sms/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/secure_sms/ui/core.py b/secure_sms/ui/core.py new file mode 100644 index 0000000..37815f2 --- /dev/null +++ b/secure_sms/ui/core.py @@ -0,0 +1,108 @@ +import os +import re +from tkinter import TclError + +import customtkinter as ctk + +try: + import arabic_reshaper + from bidi.algorithm import get_display +except ImportError: + arabic_reshaper = None + get_display = None + + +ctk.set_appearance_mode("light") +ctk.set_default_color_theme("blue") + + +PRIMARY = "#175B4B" +PRIMARY_DARK = "#0E4236" +PRIMARY_SOFT = "#DFF1E8" +ACCENT = "#E8A04D" +ACCENT_DARK = "#C97E2D" +BACKGROUND = "#F5EFE7" +CARD = "#FFFDFC" +SURFACE = "#FBF7F2" +INPUT_BG = "#FFFCF8" +TEXT = "#16312A" +MUTED = "#6B7A77" +DANGER = "#B6465F" +WARNING = "#9A6C3C" +BORDER = "#E5DCCE" + +KEYBOARD_BG = "#D4DCE2" +KEY_FACE = "#FFFFFF" +KEY_MUTED = "#BCC1C9" +KEY_TEXT = "#000000" + +SIDEBAR = "#1B5A4A" +SIDEBAR_SOFT = "#245E4E" + +FONT_BODY = "Tahoma" if os.name == "nt" else "DejaVu Sans" +FONT_TITLE = "Tahoma" if os.name == "nt" else "DejaVu Sans" + +RTL_PATTERN = re.compile(r"[\u0600-\u06FF]") + + +def ui_text(value): + if not isinstance(value, str) or not value: + return value + if arabic_reshaper is None or get_display is None or not RTL_PATTERN.search(value): + return value + return get_display(arabic_reshaper.reshape(value)) + + +class _RTLTextMixin: + @staticmethod + def _normalize_kwargs(kwargs): + normalized = dict(kwargs) + if "text" in normalized: + normalized["text"] = ui_text(normalized["text"]) + if "placeholder_text" in normalized: + normalized["placeholder_text"] = ui_text(normalized["placeholder_text"]) + if "label_text" in normalized: + normalized["label_text"] = ui_text(normalized["label_text"]) + return normalized + + +class RTLLabel(_RTLTextMixin, ctk.CTkLabel): + def __init__(self, *args, **kwargs): + super().__init__(*args, **self._normalize_kwargs(kwargs)) + + def configure(self, require_redraw=False, **kwargs): + return super().configure(require_redraw=require_redraw, **self._normalize_kwargs(kwargs)) + + +class RTLButton(_RTLTextMixin, ctk.CTkButton): + def __init__(self, *args, **kwargs): + kwargs.setdefault("corner_radius", 16) + super().__init__(*args, **self._normalize_kwargs(kwargs)) + + def configure(self, require_redraw=False, **kwargs): + return super().configure(require_redraw=require_redraw, **self._normalize_kwargs(kwargs)) + + +class RTLEntry(_RTLTextMixin, ctk.CTkEntry): + def __init__(self, *args, **kwargs): + kwargs.setdefault("justify", "right") + kwargs.setdefault("fg_color", INPUT_BG) + kwargs.setdefault("border_color", BORDER) + kwargs.setdefault("corner_radius", 16) + super().__init__(*args, **self._normalize_kwargs(kwargs)) + + def configure(self, require_redraw=False, **kwargs): + return super().configure(require_redraw=require_redraw, **self._normalize_kwargs(kwargs)) + + +class RTLTextbox(ctk.CTkTextbox): + def insert(self, index, text, *tags): + return super().insert(index, ui_text(text), *tags) + + +class RTLScrollableFrame(_RTLTextMixin, ctk.CTkScrollableFrame): + def __init__(self, *args, **kwargs): + super().__init__(*args, **self._normalize_kwargs(kwargs)) + + def configure(self, require_redraw=False, **kwargs): + return super().configure(require_redraw=require_redraw, **self._normalize_kwargs(kwargs))