Skip to content

Commit 6153248

Browse files
committed
Pre-Release (Version 0.3) - UI Preperations and fixes
1 parent fcef3f9 commit 6153248

9 files changed

Lines changed: 199 additions & 29 deletions

File tree

README.md

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
# Android SMS API Gateway 0.2 *(Pre-Release)*
2-
![Version](https://img.shields.io/badge/Version-0.2_(Pre--Release)-orange)
1+
# Android SMS API Gateway 0.3 *(Pre-Release)*
2+
![Version](https://img.shields.io/badge/Version-0.3_(Pre--Release)-orange)
33
![Python](https://img.shields.io/badge/Python-3.13+-blue?logo=python&logoColor=white)
44
![FastAPI](https://img.shields.io/badge/FastAPI-Powered-009688?logo=fastapi&logoColor=white)
55
![Maintained](https://img.shields.io/badge/Maintained-Yes-brightgreen)
@@ -30,7 +30,7 @@ This application transforms any Android device into a dedicated, self-hosted **S
3030
- [Trigger the QR Code](#trigger-the-qr-code)
3131
- [Pairing Instructions](#pairing-instructions)
3232
- [Verify Connection](#verify-connection)
33-
- [Whats New In 0.2](#whats-new-in-02-pre-release)
33+
- [Whats New In 0.3](#whats-new-in-03-pre-release)
3434

3535
---
3636

@@ -280,19 +280,18 @@ After scanning, the pairing process completes automatically. You can confirm suc
280280
* Checking the terminal logs for a "Successfully Paired" message.
281281
* Calling the `GET /adb/list-devices` endpoint to verify your device appears with the status `authorized`.
282282

283-
# What's New in 0.2 (Pre-release)
283+
# What's New in 0.3 (Pre-release)
284284
#### Features & Improvements
285285

286-
- Added a new API route to support pairing devices via a 6-digit code, offering an alternative to QR code scanning.
286+
- Added a route to get and list all conversations on the device
287287

288-
- Replaced the embedded ADB binary with the system-level android-tools-adb package. This improves stability and compatibility across different container environments.
288+
- Added a route to list all users
289289

290-
#### Bug Fixes
291-
292-
- Fixed major bugs that made delete-account endpoint not to work
290+
- `GET /auth/@me` now returns how many messages are left for the current month
293291

294-
- Resolved connectivity issues preventing successful wireless device pairing in Dockerized environments.
292+
> **_P.S. Parts of this release are preperations for the UI Interface which is coming VERY soon!_**
295293
296-
- Fixed an issue where remember_me tokens were not persisting correctly; tokens now utilize a 10-year expiration for long-term sessions.
294+
#### Bug Fixes
297295

298-
- Corrected username validator logic and pattern. Usernames can now be 3–32 characters long, include numbers and hyphens (previously restricted to 10 characters maximum and no numbers were allowed).
296+
- Timeout handling for ADB `POST /adb/connect-device` - trace back to client
297+
- `device_id` was limited to 35 characters (raised to 99) - code pairing devices have longer names than 35 characters

build.cmd

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
:: local use
2-
docker build --no-cache -t android-sms-api:0.2 -t android-sms-api:latest .
2+
docker build --no-cache -t android-sms-api:0.3 -t android-sms-api:latest .

models/adb.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ class AdbPairDeviceWithCodeRequest(BaseModel):
8989

9090

9191
class AdbConnectDeviceRequest(BaseModel):
92-
device_id: str
92+
device_id: Optional[str] = Field(None, min_length=4, max_length=99, description="The unique identifier (serial) of the Android device. If omitted, a random available device will be selected.", examples=[None])
9393

9494

9595
class AdbConnectDeviceResponse(AdbConnectDeviceRequest, AdbDetailResponse):
@@ -100,8 +100,7 @@ class AdbConnectDeviceResponse(AdbConnectDeviceRequest, AdbDetailResponse):
100100
# description="Must start with 05x (10 digits) or 9725x (12 digits). Allowed providers: 0,2,3,4,5,8."
101101

102102

103-
class AdbSendTextMessageRequest(BaseModel):
104-
device_id: Optional[str] = Field(None, min_length=4, max_length=35, description="The unique identifier (serial) of the Android device. If omitted, a random available device will be selected.", examples=[None])
103+
class AdbSendTextMessageRequest(AdbConnectDeviceRequest):
105104
phone_number: str = Field(..., pattern=r"^\+[1-9]\d{1,14}$")
106105
message: str
107106

@@ -122,3 +121,16 @@ class AdbProcessResult(BaseModel):
122121
returncode: int = Field(..., description="Exit status of the process (0 usually means success)")
123122
stdout: str = Field(..., description="Standard output from the command")
124123
stderr: str = Field(..., description="Standard error from the command")
124+
125+
126+
class AdbMessage(BaseModel):
127+
id: str = Field(..., description="The unique message ID")
128+
address: Optional[str] = Field(None, description="The phone number or address associated with the message")
129+
body: str = Field(..., description="The content of the message")
130+
date: Optional[int] = Field(None, description="The timestamp of the message")
131+
type: Literal['sent', 'received', 'unknown'] = Field(..., description="The type of the message")
132+
133+
134+
class AdbConversation(BaseModel):
135+
phone_number: str = Field(..., description="The phone number associated with the messages")
136+
messages: list[AdbMessage] = Field(..., description="List of messages exchanged with this phone number")

models/authentication.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ def validate_password(cls, passwd: str) -> str:
5656
class AdditionalAccountData(BaseUser):
5757
messages_limit: int = 50
5858
administrator: bool = False
59+
messages_left: Optional[int] = None
5960

6061

6162
class Token(BaseModel):
@@ -118,3 +119,14 @@ class MessageLimitUpdateResponse(AccountConfirmationResponse):
118119

119120
class UpdateMessageLimitRequest(BaseUser):
120121
messages_limit: int = Field(50, ge=0, description="New monthly message limit for the user")
122+
123+
124+
class UserStats(BaseUser):
125+
messages_limit: int
126+
current_usage: int
127+
administrator: bool
128+
129+
130+
class UserListResponse(BaseModel):
131+
users: list[UserStats]
132+

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "Android-SMS-API"
3-
version = "0.2"
3+
version = "0.3"
44
description = "Turn your Android phone into a programmable SMS server. A lightweight HTTP API wrapper around ADB for sending text messages over cellular network"
55
readme = "README.md"
66
requires-python = ">=3.13"

routes/adb.py

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22
import time
3-
from typing import Annotated
3+
import subprocess
4+
from typing import Annotated, Optional
45
from dotenv import load_dotenv
56
from utils.database import SQLiteDb
67
from fastapi.responses import StreamingResponse
@@ -9,7 +10,7 @@
910
from fastapi import Depends, HTTPException, status, APIRouter
1011
from utils.adb import Adb, DeviceUnavailable, DeviceConnectionError
1112
from routes.authentication import authenticate_with_token, AdditionalAccountData, MUST_BE_ADMINISTRATOR_EXCEPTION
12-
from models.adb import AdbListDevices, AdbDetailResponse, AdbConnectDeviceRequest, AdbConnectDeviceResponse, AdbSendTextMessageRequest, AdbMessageSentResponse, AdbShellExecuteRequest, AdbProcessResult, AdbPairDeviceWithCodeRequest, execution_route_enabled, ADB_QR_PAIRING_INSTRUCTIONS, ADB_PAIRING_INSTRUCTIONS
13+
from models.adb import AdbListDevices, AdbDetailResponse, AdbConnectDeviceRequest, AdbConnectDeviceResponse, AdbSendTextMessageRequest, AdbMessageSentResponse, AdbShellExecuteRequest, AdbProcessResult, AdbPairDeviceWithCodeRequest, execution_route_enabled, ADB_QR_PAIRING_INSTRUCTIONS, ADB_PAIRING_INSTRUCTIONS, AdbMessage, AdbConversation
1314

1415
load_dotenv()
1516

@@ -45,6 +46,25 @@ async def adb_list_devices(
4546
return devices_list
4647

4748

49+
@router.get(
50+
"/list-messages",
51+
summary="List all SMS messages from a connected device",
52+
status_code=status.HTTP_200_OK,
53+
response_model=list[AdbConversation]
54+
)
55+
async def adb_list_messages(
56+
account: Annotated[AdditionalAccountData, Depends(authenticate_with_token)],
57+
device_id: Optional[str] = None
58+
):
59+
60+
if not account.administrator:
61+
raise MUST_BE_ADMINISTRATOR_EXCEPTION
62+
63+
messages = await adb.list_messages(device_id=device_id)
64+
65+
return messages
66+
67+
4868
@router.get(
4969
"/qr-pair-device",
5070
summary="Pairing a new Android device over the network via QR code",
@@ -134,17 +154,24 @@ async def adb_connect_device(
134154

135155
response_detail = "ADB Error while connecting to device!"
136156

137-
device = await adb.connect_device(body.device_id)
157+
try:
158+
device = await adb.connect_device(body.device_id)
138159

139-
if "connected" in device.stdout or "already" in device.stdout:
160+
if "connected" in device.stdout or "already" in device.stdout:
140161

141-
response_detail = "ADB is now connected to device"
162+
response_detail = "ADB is now connected to device"
142163

143-
return AdbConnectDeviceResponse(
144-
detail=response_detail,
145-
device_id=body.device_id,
146-
adb_output=device.stdout
147-
)
164+
return AdbConnectDeviceResponse(
165+
detail=response_detail,
166+
device_id=body.device_id,
167+
adb_output=device.stdout
168+
)
169+
170+
except subprocess.TimeoutExpired:
171+
raise HTTPException(
172+
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
173+
detail="The connection attempt to the device timed out."
174+
)
148175

149176

150177
@router.post(

routes/authentication.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from typing import Annotated
44
from fastapi import Depends, HTTPException, status, APIRouter
55
from fastapi.security import OAuth2PasswordBearer
6-
from models.authentication import CreateUser, Token, AdditionalAccountData, CreateUserParams, LoginObtainToken, login_obtain_token, AccountConfirmationResponse, BaseUser, MUST_BE_ADMINISTRATOR_EXCEPTION, ResetAccountPasswordRequest, UpdateMessageLimitRequest, MessageLimitUpdateResponse, generate_random_password
6+
from models.authentication import CreateUser, Token, AdditionalAccountData, CreateUserParams, LoginObtainToken, login_obtain_token, AccountConfirmationResponse, BaseUser, MUST_BE_ADMINISTRATOR_EXCEPTION, ResetAccountPasswordRequest, UpdateMessageLimitRequest, MessageLimitUpdateResponse, generate_random_password, UserListResponse, UserStats
77
from utils.models.database import User_Model
88
from utils.database import SQLiteDb
99
from utils.secure import JWToken, Hash
@@ -62,8 +62,13 @@ async def authenticate_with_token(
6262
if user is None:
6363
raise credentials_exception
6464

65+
messages_count = db_helper.count_messages(user['username'])
66+
67+
messages_left = user['messages_limit'] - messages_count
68+
6569
return AdditionalAccountData(
66-
**user
70+
**user,
71+
messages_left=messages_left
6772
)
6873

6974

@@ -80,6 +85,45 @@ async def get_current_user(
8085
return current_user
8186

8287

88+
@router.get(
89+
"/list-users",
90+
response_model=UserListResponse,
91+
status_code=status.HTTP_200_OK,
92+
tags=["Account Management"]
93+
)
94+
async def list_users(
95+
token: Annotated[AdditionalAccountData, Depends(authenticate_with_token)]
96+
):
97+
98+
if not token.administrator:
99+
raise MUST_BE_ADMINISTRATOR_EXCEPTION
100+
101+
users = db_helper.get_all_users()
102+
user_stats_list = []
103+
104+
user_stats_list.append(UserStats(
105+
username=ADMIN_USERNAME,
106+
messages_limit=0,
107+
current_usage=db_helper.count_messages(ADMIN_USERNAME),
108+
administrator=True
109+
))
110+
111+
for user in users:
112+
113+
usage = db_helper.count_messages(user['username'])
114+
115+
user_stats = UserStats(
116+
username=user['username'],
117+
messages_limit=user['messages_limit'],
118+
current_usage=usage,
119+
administrator=user['administrator']
120+
)
121+
122+
user_stats_list.append(user_stats)
123+
124+
return UserListResponse(users=user_stats_list)
125+
126+
83127
@router.post(
84128
"/login",
85129
response_model=Token,

utils/adb.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import shutil
55
import subprocess
66
from typing import Optional
7+
from collections import defaultdict
78
from pydantic import IPvAnyAddress
89
from utils.logger import create_logger
910

@@ -243,3 +244,70 @@ async def send_text_message(self, phone_number: str, message: str, device_name:
243244

244245
log.error(f"SMS command failed. Device: {device_name}, Output: {parcel.stdout.strip()}")
245246
return False, str(device_name)
247+
248+
async def list_messages(self, device_id: Optional[str] = None) -> list[dict]:
249+
250+
log.debug(f"Listing messages for device: {device_id}")
251+
252+
command = []
253+
if device_id:
254+
command.extend(["-s", device_id])
255+
256+
command.extend(["shell", "content", "query", "--uri", "content://sms/", "--projection", "_id:address:body:date:type"])
257+
258+
process = await self.adb_execute(command)
259+
260+
if process.returncode != 0:
261+
log.error(f"Failed to list messages. Output: {process.stderr}")
262+
return []
263+
264+
output = process.stdout
265+
grouped_messages = defaultdict(list)
266+
row_pattern = re.compile(r"Row: \d+ (.*)")
267+
268+
for line in output.strip().split('\n'):
269+
match = row_pattern.search(line)
270+
if not match: continue
271+
272+
fields_str = match.group(1)
273+
parts = re.split(r', (?=\w+=)', fields_str)
274+
275+
row_data = {}
276+
for part in parts:
277+
if '=' in part:
278+
key, val = part.split('=', 1)
279+
row_data[key.strip()] = val.strip()
280+
281+
msg_type = row_data.get('type', '1')
282+
if msg_type == '1':
283+
msg_type_str = 'received'
284+
elif msg_type == '2':
285+
msg_type_str = 'sent'
286+
else:
287+
msg_type_str = 'unknown'
288+
289+
try:
290+
date_val = int(row_data.get('date', 0))
291+
except ValueError:
292+
date_val = None
293+
294+
msg_entry = {
295+
"type": msg_type_str,
296+
"body": row_data.get('body', ''),
297+
"date": date_val,
298+
"id": row_data.get('_id'),
299+
"address": row_data.get('address')
300+
}
301+
address = row_data.get('address', 'Unknown')
302+
grouped_messages[address].append(msg_entry)
303+
304+
conversations = []
305+
for address, msgs in grouped_messages.items():
306+
307+
msgs.sort(key=lambda x: x['date'] or 0)
308+
conversations.append({
309+
"phone_number": address,
310+
"messages": msgs
311+
})
312+
313+
return conversations

utils/database.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,14 @@ def get_user(self, username: str):
103103

104104
return query
105105

106+
def get_all_users(self):
107+
108+
log.debug("Fetching all users")
109+
cursor = self.conn.cursor()
110+
cursor.execute(f"SELECT * FROM {self.users_table_name}")
111+
112+
return cursor.fetchall()
113+
106114
def insert_user(self, user_model: User_Model):
107115

108116
data = user_model.model_dump(mode="json")

0 commit comments

Comments
 (0)