first project like telegram

This commit is contained in:
MOJ1403 2026-03-23 20:28:22 +03:30
parent 2f128f9a1a
commit 609c627823
22 changed files with 426 additions and 945 deletions

View File

@ -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("<Return>", 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()

View File

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

View File

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

View File

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

View File

@ -258,7 +258,7 @@ def main():
return 1 return 1
try: try:
from secure_sms.controller import AppController from secure_sms.application.controller import AppController
from secure_sms.ui import SecureSmsApp from secure_sms.ui import SecureSmsApp
controller = AppController() controller = AppController()

70
refactor.py Normal file
View File

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

View File

View File

@ -1,18 +1,48 @@
from __future__ import annotations from __future__ import annotations
import queue
import threading
from typing import Optional from typing import Optional
from secure_sms.database import Database from secure_sms.infrastructure.database import Database
from secure_sms.gsm import GSMGateway from secure_sms.infrastructure.gsm import IMessageGateway, GSMGateway
from secure_sms.services import SecureMessagingService from secure_sms.application.services import SecureMessagingService
class AppController: class AppController:
def __init__(self): def __init__(self):
self.db = Database() self.db = Database()
self.service = SecureMessagingService(self.db) self.service = SecureMessagingService(self.db)
self.gsm: Optional[GSMGateway] = None self.gsm: Optional[IMessageGateway] = None
self.ui = 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): def bind_ui(self, ui):
self.ui = ui self.ui = ui
@ -74,40 +104,22 @@ class AppController:
except Exception as exc: except Exception as exc:
return False, str(exc) return False, str(exc)
if self.gsm and self.gsm.is_connected: msg_id = self.service.store_outgoing_message(phone, text, mode, "queued")
sent = self.gsm.send_frames(phone, frames) self._outbox.put({"phone": phone, "frames": frames, "msg_id": msg_id})
state = "sent" if sent else "failed" self._notify_ui(phone)
else: return True, "queued"
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"
def request_secure(self, phone: str) -> tuple[bool, str]: def request_secure(self, phone: str) -> tuple[bool, str]:
frames = self.service.request_secure_channel(phone) frames = self.service.request_secure_channel(phone)
if self.gsm and self.gsm.is_connected: self._outbox.put({"phone": phone, "frames": frames, "msg_id": None})
ok = self.gsm.send_frames(phone, frames)
status = "sent" if ok else "failed"
else:
ok = True
status = "simulated"
self._notify_ui(phone) self._notify_ui(phone)
return ok, status return True, "queued"
def switch_to_normal(self, phone: str) -> tuple[bool, str]: def switch_to_normal(self, phone: str) -> tuple[bool, str]:
frames = self.service.request_normal_mode(phone) frames = self.service.request_normal_mode(phone)
if self.gsm and self.gsm.is_connected: self._outbox.put({"phone": phone, "frames": frames, "msg_id": None})
ok = self.gsm.send_frames(phone, frames)
status = "sent" if ok else "failed"
else:
ok = True
status = "simulated"
self._notify_ui(phone) self._notify_ui(phone)
return ok, status return True, "queued"
def get_admin_snapshot(self) -> dict: def get_admin_snapshot(self) -> dict:
snapshot = self.service.get_admin_snapshot() snapshot = self.service.get_admin_snapshot()

View File

@ -2,9 +2,9 @@ import platform
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from secure_sms.database import Database, utc_now from secure_sms.infrastructure.database import Database, utc_now
from secure_sms.models import ContactDetails, ContactSummary, MessageView, PendingPacketView, SecureEventView from secure_sms.core.models import ContactDetails, ContactSummary, MessageView, PendingPacketView, SecureEventView
from secure_sms.protocol import ( from secure_sms.core.protocol import (
build_control_frames, build_control_frames,
build_message_frames, build_message_frames,
decode_control_payload, decode_control_payload,
@ -12,7 +12,7 @@ from secure_sms.protocol import (
encode_plain_body, encode_plain_body,
parse_frame, parse_frame,
) )
from secure_sms.security import ECCCryptoService, PasswordManager, StorageCipher from secure_sms.core.security import ECCCryptoService, PasswordManager, StorageCipher
SYSTEM_CONTACT_LABEL = "مخاطب ناشناس" SYSTEM_CONTACT_LABEL = "مخاطب ناشناس"
@ -183,8 +183,8 @@ class SecureMessagingService:
encoded_payload = encode_plain_body(text) encoded_payload = encode_plain_body(text)
return build_message_frames("N", encoded_payload), "normal" return build_message_frames("N", encoded_payload), "normal"
def store_outgoing_message(self, phone: str, text: str, mode: str, transport_state: str): def store_outgoing_message(self, phone: str, text: str, mode: str, transport_state: str) -> int:
self.db.add_message( return self.db.add_message(
phone=phone, phone=phone,
direction="out", direction="out",
body_enc=self._enc(text), body_enc=self._enc(text),

View File

View File

@ -3,7 +3,7 @@ import uuid
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional 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" FRAME_PREFIX = "@SSM1"

View File

View File

@ -3,7 +3,7 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional 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" DB_FILE = "secure_sms_v2.db"
@ -279,6 +279,14 @@ class Database:
conn.commit() conn.commit()
return int(cursor.lastrowid) 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]: def list_message_rows(self, phone: str) -> list[sqlite3.Row]:
with self._connect() as conn: with self._connect() as conn:
cursor = conn.cursor() cursor = conn.cursor()

View File

@ -1,3 +1,4 @@
import abc
import re import re
import threading import threading
import time import time
@ -6,7 +7,22 @@ from typing import Callable, Optional
import serial 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__( def __init__(
self, self,
port: str, port: str,

View File

@ -12,30 +12,32 @@ except ImportError:
get_display = None get_display = None
ctk.set_appearance_mode("light") ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue") ctk.set_default_color_theme("blue")
PRIMARY = "#175B4B" PRIMARY = "#2AABEE"
PRIMARY_DARK = "#0E4236" PRIMARY_DARK = "#229ED9"
PRIMARY_SOFT = "#DFF1E8" PRIMARY_SOFT = "#1C3A4F"
ACCENT = "#E8A04D" ACCENT = "#2AABEE"
ACCENT_DARK = "#C97E2D" ACCENT_DARK = "#229ED9"
BACKGROUND = "#F5EFE7" BACKGROUND = "#0E1621"
CARD = "#FFFDFC" CARD = "#17212B"
SURFACE = "#FBF7F2" SURFACE = "#17212B"
INPUT_BG = "#FFFCF8" INPUT_BG = "#242F3D"
TEXT = "#16312A" TEXT = "#FFFFFF"
MUTED = "#6B7A77" MUTED = "#6C7883"
DANGER = "#B6465F" DANGER = "#E05D57"
WARNING = "#9A6C3C" WARNING = "#E0A356"
BORDER = "#E5DCCE" BORDER = "#232E3C"
KEYBOARD_BG = "#D4DCE2" KEYBOARD_BG = "#17212B"
KEY_FACE = "#FFFFFF" KEY_FACE = "#242F3D"
KEY_MUTED = "#BCC1C9" KEY_MUTED = "#1C2733"
KEY_TEXT = "#000000" KEY_TEXT = "#FFFFFF"
SIDEBAR = "#1B5A4A" SIDEBAR = "#17212B"
SIDEBAR_SOFT = "#245E4E" SIDEBAR_SOFT = "#242F3D"
BUBBLE_OUT = "#2B5278"
BUBBLE_IN = "#182533"
FONT_BODY = "Tahoma" if os.name == "nt" else "DejaVu Sans" FONT_BODY = "Tahoma" if os.name == "nt" else "DejaVu Sans"
FONT_TITLE = "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]") RTL_PATTERN = re.compile(r"[\u0600-\u06FF]")
@ -560,7 +562,7 @@ class SecureSmsApp(ctk.CTk):
def _show_lock_screen(self): def _show_lock_screen(self):
self._clear_root() 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( frame.place(
relx=0.5, relx=0.5,
rely=0.5, rely=0.5,
@ -574,55 +576,69 @@ class SecureSmsApp(ctk.CTk):
if not self.controller.is_bootstrapped() if not self.controller.is_bootstrapped()
else 'برای ورود، رمز اصلی برنامه را وارد کن.' else 'برای ورود، رمز اصلی برنامه را وارد کن.'
) )
RTLLabel(
frame,
text='📨 صبا',
font=ctk.CTkFont(family=FONT_TITLE, size=32, weight="bold"),
text_color=PRIMARY,
).pack(pady=(24, 4))
RTLLabel( RTLLabel(
frame, frame,
text=title, 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, text_color=TEXT,
).pack(pady=(30, 10)) ).pack(pady=(4, 6))
RTLLabel( RTLLabel(
frame, frame,
text=subtitle, text=subtitle,
wraplength=min(self.window_width - 110, 420), wraplength=min(self.window_width - 110, 420),
justify="right", justify="right",
font=ctk.CTkFont(family=FONT_BODY, size=16), font=ctk.CTkFont(family=FONT_BODY, size=14),
text_color=MUTED, text_color=MUTED,
).pack(padx=28) ).pack(padx=28)
self.password_entry = RTLEntry( self.password_entry = RTLEntry(
frame, frame,
placeholder_text='رمز اصلی', placeholder_text='رمز اصلی',
show="*", show="*",
height=48, height=44,
font=ctk.CTkFont(family=FONT_BODY, size=17), 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 self.confirm_entry = None
if not self.controller.is_bootstrapped(): if not self.controller.is_bootstrapped():
self.confirm_entry = RTLEntry( self.confirm_entry = RTLEntry(
frame, frame,
placeholder_text='تکرار رمز', placeholder_text='تکرار رمز',
show="*", show="*",
height=48, height=44,
font=ctk.CTkFont(family=FONT_BODY, size=17), 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( self.lock_message = RTLLabel(
frame, frame,
text="", text="",
text_color=DANGER, 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 'ورود' action_text = 'شروع برنامه' if not self.controller.is_bootstrapped() else 'ورود'
RTLButton( RTLButton(
frame, frame,
text=action_text, text=action_text,
height=48, height=44,
corner_radius=8,
fg_color=PRIMARY, fg_color=PRIMARY,
hover_color=PRIMARY_DARK, hover_color=PRIMARY_DARK,
text_color="#FFFFFF",
command=self._submit_lock_screen, command=self._submit_lock_screen,
font=ctk.CTkFont(family=FONT_BODY, size=18, weight="bold"), font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"),
).pack(fill="x", padx=42, pady=(8, 12)) ).pack(fill="x", padx=36, pady=(4, 16))
self.password_entry.bind("<Return>", lambda _event: self._submit_lock_screen()) self.password_entry.bind("<Return>", lambda _event: self._submit_lock_screen())
self._register_text_input(self.password_entry, title='رمز اصلی', layout="en", submit=self._submit_lock_screen) self._register_text_input(self.password_entry, title='رمز اصلی', layout="en", submit=self._submit_lock_screen)
if self.confirm_entry: if self.confirm_entry:
@ -713,66 +729,71 @@ class SecureSmsApp(ctk.CTk):
main_row = 1 if self.is_portrait else 0 main_row = 1 if self.is_portrait else 0
main_column = 0 if self.is_portrait else 1 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(row=0, column=0, sticky="nsew")
self.sidebar.grid_columnconfigure(0, weight=1) self.sidebar.grid_columnconfigure(0, weight=1)
self.sidebar.grid_rowconfigure(5, 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( RTLLabel(
self.sidebar, sidebar_header,
text='صبا', text='صبا',
text_color="white", text_color=TEXT,
font=ctk.CTkFont(family=FONT_TITLE, size=title_size, weight="bold"), 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( RTLLabel(
self.sidebar, sidebar_header,
text='پیام\u200cرسان امن و ساده برای کاربر غیر فنی', text='پیام\u200cرسان امن',
text_color="#D5E8E1", text_color=MUTED,
font=ctk.CTkFont(family=FONT_BODY, size=subtitle_size), 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.connection_badge = RTLLabel(
self.sidebar, self.sidebar,
text="", text="",
corner_radius=999, corner_radius=6,
fg_color="#2E7D62", fg_color="#1C3A4F",
text_color="white", text_color=MUTED,
font=ctk.CTkFont(family=FONT_BODY, size=15 if self.is_portrait else 14, weight="bold"), font=ctk.CTkFont(family=FONT_BODY, size=13, weight="bold"),
padx=14, padx=10,
pady=8, 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 = 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) top_actions.grid_columnconfigure((0, 1), weight=1)
RTLButton( RTLButton(
top_actions, top_actions,
text='مخاطب جدید', text='گفتگوی جدید',
command=self._open_contact_dialog, command=self._open_contact_dialog,
fg_color=ACCENT, fg_color=PRIMARY,
text_color="#3A2514", text_color="#FFFFFF",
hover_color=ACCENT_DARK, hover_color=PRIMARY_DARK,
corner_radius=8,
height=action_height, height=action_height,
font=ctk.CTkFont(family=FONT_BODY, size=15 if self.is_portrait else 14, weight="bold"), font=ctk.CTkFont(family=FONT_BODY, size=14, weight="bold"),
).grid(row=0, column=0, padx=6, sticky="ew") ).grid(row=0, column=0, padx=4, sticky="ew")
RTLButton( RTLButton(
top_actions, top_actions,
text='تنظیمات', text='تنظیمات',
command=self._open_settings_panel, command=self._open_settings_panel,
fg_color="#F4EFE9", fg_color=INPUT_BG,
text_color="#15302B", text_color=TEXT,
hover_color="#ECE1D5", hover_color="#2D3A49",
corner_radius=8,
height=action_height, height=action_height,
font=ctk.CTkFont(family=FONT_BODY, size=15 if self.is_portrait else 14, weight="bold"), font=ctk.CTkFont(family=FONT_BODY, size=14, weight="bold"),
).grid(row=0, column=1, padx=6, sticky="ew") ).grid(row=0, column=1, padx=4, sticky="ew")
self.contact_form_card = ctk.CTkFrame( self.contact_form_card = ctk.CTkFrame(
self.sidebar, self.sidebar,
fg_color=SIDEBAR_SOFT, fg_color=SIDEBAR_SOFT,
corner_radius=18, corner_radius=10,
border_width=1, 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(row=4, column=0, padx=14, pady=(0, 10), sticky="ew")
self.contact_form_card.grid_columnconfigure(0, weight=1) self.contact_form_card.grid_columnconfigure(0, weight=1)
@ -809,20 +830,22 @@ class SecureSmsApp(ctk.CTk):
RTLButton( RTLButton(
contact_actions, contact_actions,
text='ذخیره', text='ذخیره',
fg_color=ACCENT, fg_color=PRIMARY,
text_color="#3A2514", text_color="#FFFFFF",
hover_color=ACCENT_DARK, hover_color=PRIMARY_DARK,
command=self._save_contact_inline, command=self._save_contact_inline,
corner_radius=8,
font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"),
height=40, height=40,
).grid(row=0, column=0, padx=4, sticky="ew") ).grid(row=0, column=0, padx=4, sticky="ew")
RTLButton( RTLButton(
contact_actions, contact_actions,
text='بستن', text='بستن',
fg_color="#F4EFE9", fg_color=INPUT_BG,
text_color=TEXT, text_color=TEXT,
hover_color="#ECE1D5", hover_color="#2D3A49",
command=self._hide_contact_form, command=self._hide_contact_form,
corner_radius=8,
font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"),
height=40, height=40,
).grid(row=0, column=1, padx=4, sticky="ew") ).grid(row=0, column=1, padx=4, sticky="ew")
@ -839,46 +862,46 @@ class SecureSmsApp(ctk.CTk):
self.contacts_frame = RTLScrollableFrame( self.contacts_frame = RTLScrollableFrame(
self.sidebar, self.sidebar,
height=160 if self.is_portrait else 320, height=160 if self.is_portrait else 320,
label_text='مخاطب\u200cها', label_text='گفتگو\u200cها',
label_font=ctk.CTkFont(family=FONT_BODY, size=17 if self.is_portrait else 18, weight="bold"), label_font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"),
fg_color="transparent", 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 = 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(row=main_row, column=main_column, sticky="nsew")
self.main_panel.grid_rowconfigure(1, weight=1) self.main_panel.grid_rowconfigure(1, weight=1)
self.main_panel.grid_columnconfigure(0, 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 = ctk.CTkFrame(self.main_panel, fg_color=CARD, corner_radius=0, border_width=0)
self.header_card.grid(row=0, column=0, padx=outer_pad, pady=(outer_pad, inner_pad), sticky="ew") self.header_card.grid(row=0, column=0, sticky="ew")
self.header_card.grid_columnconfigure(0, weight=1) self.header_card.grid_columnconfigure(0, weight=1)
self.header_card.grid_columnconfigure(1, weight=0) self.header_card.grid_columnconfigure(1, weight=0)
self.chat_title = RTLLabel( self.chat_title = RTLLabel(
self.header_card, self.header_card,
text='یک مخاطب را انتخاب کن', text='یک مخاطب را انتخاب کن',
text_color=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.chat_subtitle = RTLLabel(
self.header_card, self.header_card,
text='در اینجا فقط دو حالت داری: عادی یا امن', text='در اینجا فقط دو حالت داری: عادی یا امن',
text_color=MUTED, 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.mode_badge = RTLLabel(
self.header_card, self.header_card,
text='عادی', text='عادی',
fg_color=PRIMARY_SOFT, fg_color=PRIMARY_SOFT,
text_color=PRIMARY, text_color=PRIMARY,
corner_radius=999, corner_radius=6,
padx=20, padx=14,
pady=10, pady=6,
font=ctk.CTkFont(family=FONT_BODY, size=14, weight="bold"), 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 = ctk.CTkFrame(self.main_panel, fg_color="transparent")
content.grid(row=1, column=0, padx=outer_pad, pady=(0, inner_pad), sticky="nsew") content.grid(row=1, column=0, padx=outer_pad, pady=(0, inner_pad), sticky="nsew")
@ -900,7 +923,7 @@ class SecureSmsApp(ctk.CTk):
else: else:
self.chat_container.grid(row=0, column=0, sticky="nsew", padx=(0, inner_pad)) 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(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_card.grid_columnconfigure(0, weight=1)
self.profile_title_label = RTLLabel( self.profile_title_label = RTLLabel(
@ -937,54 +960,60 @@ class SecureSmsApp(ctk.CTk):
text='فعال\u200cسازی ارتباط امن', text='فعال\u200cسازی ارتباط امن',
fg_color=PRIMARY, fg_color=PRIMARY,
hover_color=PRIMARY_DARK, 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, 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.normal_button = RTLButton(
self.profile_card, self.profile_card,
text='بازگشت به حالت عادی', text='بازگشت به حالت عادی',
fg_color="#F4EFE9", fg_color=INPUT_BG,
text_color=TEXT, text_color=TEXT,
hover_color="#ECE1D5", hover_color="#2D3A49",
font=ctk.CTkFont(family=FONT_BODY, size=16, weight="bold"), corner_radius=8,
font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"),
command=self._switch_to_normal, 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() self._configure_profile_card_layout()
composer = ctk.CTkFrame(self.main_panel, fg_color=CARD, corner_radius=24, border_width=1, border_color=BORDER) composer = ctk.CTkFrame(self.main_panel, fg_color=CARD, corner_radius=0, border_width=0)
composer.grid(row=2, column=0, padx=outer_pad, pady=(0, outer_pad), sticky="ew") composer.grid(row=2, column=0, sticky="ew")
composer.grid_columnconfigure(0, weight=1) composer.grid_columnconfigure(0, weight=1)
self.message_entry = RTLTextbox( self.message_entry = RTLTextbox(
composer, composer,
height=72 if self.is_portrait else 82, height=52 if self.is_portrait else 62,
fg_color=INPUT_BG, fg_color=INPUT_BG,
text_color=TEXT,
border_width=0, border_width=0,
font=ctk.CTkFont(family=FONT_BODY, size=16), corner_radius=10,
font=ctk.CTkFont(family=FONT_BODY, size=15),
wrap="word", 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 = 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( self.send_state_label = RTLLabel(
actions, actions,
text="", text="",
text_color=MUTED, 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( RTLButton(
actions, actions,
text='ارسال', text='',
fg_color=ACCENT, fg_color=PRIMARY,
text_color="#3A2514", text_color="#FFFFFF",
hover_color=ACCENT_DARK, hover_color=PRIMARY_DARK,
font=ctk.CTkFont(family=FONT_BODY, size=18, weight="bold"), font=ctk.CTkFont(family=FONT_BODY, size=20, weight="bold"),
width=110 if self.is_portrait else 126, width=48,
height=48, height=48,
corner_radius=24,
command=self._send_message, command=self._send_message,
).pack() ).pack()
self.message_entry.bind("<Control-Return>", lambda _event: self._send_message()) self.message_entry.bind("<Control-Return>", lambda _event: self._send_message())
@ -993,7 +1022,7 @@ class SecureSmsApp(ctk.CTk):
self.overlay_frame = ctk.CTkFrame( self.overlay_frame = ctk.CTkFrame(
self.main_panel, self.main_panel,
fg_color=CARD, fg_color=CARD,
corner_radius=28, corner_radius=12,
border_width=1, border_width=1,
border_color=BORDER, border_color=BORDER,
) )
@ -1015,9 +1044,9 @@ class SecureSmsApp(ctk.CTk):
def _refresh_connection_badge(self): def _refresh_connection_badge(self):
modem = self.controller.modem_status() modem = self.controller.modem_status()
if modem["connected"]: 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: 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): def _refresh_contacts(self):
for widget in self.contacts_frame.winfo_children(): for widget in self.contacts_frame.winfo_children():
@ -1037,15 +1066,15 @@ class SecureSmsApp(ctk.CTk):
self.contacts_frame, self.contacts_frame,
text=f"{contact.name}\n{contact.phone}\n{contact.last_message_preview or 'آماده گفتگو'}", text=f"{contact.name}\n{contact.phone}\n{contact.last_message_preview or 'آماده گفتگو'}",
anchor="e", anchor="e",
height=76 if self.is_portrait else 88, height=72 if self.is_portrait else 80,
corner_radius=20, corner_radius=8,
command=lambda phone=contact.phone: self._select_contact(phone), command=lambda phone=contact.phone: self._select_contact(phone),
fg_color="#F7EFE5" if selected else "#2A6956", fg_color="#2B5278" if selected else "transparent",
hover_color="#F0E3D5" if selected else "#32745F", hover_color="#2B5278",
text_color=TEXT if selected else "white", text_color=TEXT,
font=ctk.CTkFont(family=FONT_BODY, size=15), 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): def _select_contact(self, phone: str):
self.current_contact_phone = phone self.current_contact_phone = phone
@ -1093,51 +1122,52 @@ class SecureSmsApp(ctk.CTk):
self.chat_container, self.chat_container,
text='هنوز پیامی ثبت نشده است.\nاز نوار پایین برای نوشتن پیام استفاده کن.', text='هنوز پیامی ثبت نشده است.\nاز نوار پایین برای نوشتن پیام استفاده کن.',
text_color=MUTED, text_color=MUTED,
font=ctk.CTkFont(family=FONT_BODY, size=15) font=ctk.CTkFont(family=FONT_BODY, size=14)
).pack(pady=40) ).pack(pady=40)
return return
for message in messages: for message in messages:
if message.direction == "system": 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( RTLLabel(
self.chat_container, sys_frame,
text=message.body, text=message.body,
text_color="#8A5C2E", text_color=MUTED,
font=ctk.CTkFont(family=FONT_BODY, size=13), font=ctk.CTkFont(family=FONT_BODY, size=12),
wraplength=int(self.window_width * 0.7) wraplength=int(self.window_width * 0.6)
).pack(pady=12, anchor="center") ).pack(padx=12, pady=6)
continue continue
is_out = message.direction == "out" 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" anchor = "e" if is_out else "w"
bubble = ctk.CTkFrame( bubble = ctk.CTkFrame(
self.chat_container, self.chat_container,
fg_color=bubble_color, fg_color=bubble_color,
corner_radius=16, corner_radius=12,
border_width=1 if not is_out else 0, border_width=0,
border_color=BORDER
) )
bubble.pack(anchor=anchor, padx=12, pady=6, fill="none") bubble.pack(anchor=anchor, padx=8, pady=3, fill="none")
RTLLabel( RTLLabel(
bubble, bubble,
text=message.body, text=message.body,
text_color="#16312A", text_color="#FFFFFF",
font=ctk.CTkFont(family=FONT_BODY, size=16), font=ctk.CTkFont(family=FONT_BODY, size=15),
wraplength=max(200, int(self.window_width * 0.55)), wraplength=max(180, int(self.window_width * 0.5)),
justify="right" 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 badge_text = f"🛡️ {message.created_at}" if message.mode == "secure" else message.created_at
RTLLabel( RTLLabel(
bubble, bubble,
text=badge_text, text=badge_text,
text_color=MUTED, text_color="#7A8E9C",
font=ctk.CTkFont(family=FONT_BODY, size=11), font=ctk.CTkFont(family=FONT_BODY, size=10),
justify="right" 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: try:
self.after(50, lambda: self.chat_container._parent_canvas.yview_moveto(1.0)) self.after(50, lambda: self.chat_container._parent_canvas.yview_moveto(1.0))
@ -1255,9 +1285,9 @@ class SecureSmsApp(ctk.CTk):
RTLButton( RTLButton(
body, body,
text='بازگشت به گفتگو', text='بازگشت به گفتگو',
fg_color="#F1E7DB", fg_color=INPUT_BG,
text_color=TEXT, text_color=TEXT,
hover_color="#E8D8C7", hover_color="#2D3A49",
font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"),
height=40, height=40,
command=self._hide_overlay, command=self._hide_overlay,
@ -1286,9 +1316,9 @@ class SecureSmsApp(ctk.CTk):
text='بستن', text='بستن',
width=86, width=86,
height=36, height=36,
fg_color="#F1E7DB", fg_color=INPUT_BG,
text_color=TEXT, text_color=TEXT,
hover_color="#E8D8C7", hover_color="#2D3A49",
font=ctk.CTkFont(family=FONT_BODY, size=14, weight="bold"), font=ctk.CTkFont(family=FONT_BODY, size=14, weight="bold"),
command=self._hide_overlay, command=self._hide_overlay,
).grid(row=0, column=1, padx=(8, 0), sticky="e") ).grid(row=0, column=1, padx=(8, 0), sticky="e")
@ -1353,9 +1383,9 @@ class SecureSmsApp(ctk.CTk):
RTLButton( RTLButton(
body, body,
text='بازگشت به تنظیمات', text='بازگشت به تنظیمات',
fg_color="#F1E7DB", fg_color=INPUT_BG,
text_color=TEXT, text_color=TEXT,
hover_color="#E8D8C7", hover_color="#2D3A49",
font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"), font=ctk.CTkFont(family=FONT_BODY, size=15, weight="bold"),
height=40, height=40,
command=self._open_settings_panel, command=self._open_settings_panel,

View File

108
secure_sms/ui/core.py Normal file
View File

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