feat(mobile): add iOS/Android mobile app with SSO login, camera upload, and push notifications

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-10 09:52:02 +00:00
parent a50c3aadf5
commit d538c0879d
26 changed files with 3239 additions and 0 deletions
+9
View File
@@ -510,3 +510,12 @@ EMBEDDING_MAX_TOKENS=8000
# Attach PII (IP addresses, user agents) to Sentry events.
# Disable (default) to stay GDPR/CCPA compliant.
# SENTRY_SEND_DEFAULT_PII=false
# **Mobile App Push Notifications**
# Push notifications are delivered via Expo's push notification service
# (https://expo.dev/notifications) which routes to APNs (iOS) and FCM (Android).
# No additional credentials are required on the server side.
# The mobile app registers its Expo push token via POST /api/mobile/register-device.
#
# To use native FCM/APNs directly (without Expo relay), replace the
# send_expo_push_notification function in app/utils/push_notification.py.
+2
View File
@@ -20,6 +20,7 @@ from app.api.google_drive import router as google_drive_router
from app.api.imap_accounts import router as imap_accounts_router
from app.api.integrations import router as integrations_router
from app.api.logs import router as logs_router
from app.api.mobile import router as mobile_router
from app.api.notifications import router as notifications_router
from app.api.onboarding import router as onboarding_router
from app.api.onedrive import router as onedrive_router
@@ -82,3 +83,4 @@ router.include_router(imap_accounts_router)
router.include_router(integrations_router)
router.include_router(notifications_router)
router.include_router(scheduled_jobs_router)
router.include_router(mobile_router)
+347
View File
@@ -0,0 +1,347 @@
"""Mobile app API endpoints.
Provides endpoints specifically designed for the DocuElevate native mobile
app (iOS / Android via React Native / Expo):
* ``POST /mobile/generate-token`` exchange an active session for a
long-lived API token that the mobile app stores securely. The token is
auto-named "Mobile App <device_name>" and is identical to regular API
tokens (Bearer auth works everywhere).
* ``POST /mobile/register-device`` register a push-notification device
token (Expo push token) so the user receives push notifications when
documents finish processing.
* ``GET /mobile/devices`` list registered devices for the current user.
* ``DELETE /mobile/devices/{device_id}`` deactivate a device.
* ``GET /mobile/whoami`` lightweight profile endpoint for the mobile app
to verify authentication state.
"""
import logging
from datetime import datetime, timezone
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.api.api_tokens import generate_api_token, hash_token
from app.auth import require_login
from app.database import get_db
from app.models import ApiToken, MobileDevice
from app.utils.user_scope import get_current_owner_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/mobile", tags=["mobile"])
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Auth helper
# ---------------------------------------------------------------------------
def _get_owner_id(request: Request) -> str:
"""Return the current user's owner ID, raising 401 if unauthenticated."""
owner_id = get_current_owner_id(request)
if not owner_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return owner_id
CurrentOwner = Annotated[str, Depends(_get_owner_id)]
# ---------------------------------------------------------------------------
# Request / Response schemas
# ---------------------------------------------------------------------------
class GenerateTokenRequest(BaseModel):
"""Request body for auto-generating a mobile app token."""
device_name: str = Field(
default="Mobile App",
min_length=1,
max_length=120,
description="Human-readable device name used to label the token.",
)
class GenerateTokenResponse(BaseModel):
"""Response containing the one-time-visible API token."""
token: str
token_id: int
name: str
created_at: datetime
class RegisterDeviceRequest(BaseModel):
"""Request body for registering a push-notification device token."""
push_token: str = Field(
min_length=1,
max_length=512,
description="Expo push token (ExponentPushToken[…]) obtained from the mobile app.",
)
device_name: str | None = Field(
default=None,
max_length=255,
description="Optional human-readable device name (e.g. 'John's iPhone').",
)
platform: str = Field(
default="ios",
description="Device platform: 'ios', 'android', or 'web'.",
)
class DeviceResponse(BaseModel):
"""Serialised MobileDevice record."""
id: int
device_name: str | None
platform: str
push_token_preview: str
is_active: bool
created_at: datetime
last_seen_at: datetime | None
class WhoAmIResponse(BaseModel):
"""Lightweight profile response for the mobile app."""
owner_id: str
display_name: str | None
email: str | None
avatar_url: str | None
is_admin: bool
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _device_to_response(device: MobileDevice) -> dict[str, Any]:
"""Convert a MobileDevice ORM object to a serialisable dict."""
# Show only first 20 chars of the push token for security.
token_preview = device.push_token[:20] + "" if len(device.push_token) > 20 else device.push_token
return {
"id": device.id,
"device_name": device.device_name,
"platform": device.platform,
"push_token_preview": token_preview,
"is_active": device.is_active,
"created_at": device.created_at,
"last_seen_at": device.last_seen_at,
}
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.post("/generate-token", status_code=status.HTTP_201_CREATED, response_model=GenerateTokenResponse)
@require_login
async def generate_mobile_token(
request: Request,
body: GenerateTokenRequest,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, Any]:
"""Generate a long-lived API token for the mobile app.
The mobile app calls this endpoint immediately after SSO login to obtain
a Bearer token it can store in the secure keychain. The returned token
is functionally identical to manually-created API tokens and works with
every authenticated endpoint.
The token is shown **exactly once** in the response; subsequent requests
show only the prefix for identification.
"""
token_name = f"Mobile App {body.device_name}"
plaintext = generate_api_token()
token_hash_value = hash_token(plaintext)
prefix = plaintext[:12]
db_token = ApiToken(
owner_id=owner_id,
name=token_name,
token_hash=token_hash_value,
token_prefix=prefix,
)
try:
db.add(db_token)
db.commit()
db.refresh(db_token)
except Exception:
db.rollback()
logger.exception("Failed to create mobile API token for owner_id=%s", owner_id)
raise
logger.info("Mobile API token created: id=%s owner=%s device=%r", db_token.id, owner_id, body.device_name)
return {
"token": plaintext,
"token_id": db_token.id,
"name": token_name,
"created_at": db_token.created_at,
}
@router.post("/register-device", status_code=status.HTTP_201_CREATED, response_model=DeviceResponse)
@require_login
async def register_device(
request: Request,
body: RegisterDeviceRequest,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, Any]:
"""Register or refresh a push-notification device token.
If the same ``push_token`` is already registered for this user the
record is reactivated and ``last_seen_at`` is updated rather than
creating a duplicate.
"""
platform = body.platform.lower()
if platform not in {"ios", "android", "web"}:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="platform must be one of: ios, android, web",
)
now = datetime.now(timezone.utc)
# Upsert: reuse existing record if the token is already known.
existing = (
db.query(MobileDevice)
.filter(MobileDevice.owner_id == owner_id, MobileDevice.push_token == body.push_token)
.first()
)
if existing:
existing.is_active = True
existing.last_seen_at = now
if body.device_name:
existing.device_name = body.device_name
try:
db.commit()
db.refresh(existing)
except Exception:
db.rollback()
raise
logger.info("Mobile device refreshed: id=%s owner=%s", existing.id, owner_id)
return _device_to_response(existing)
device = MobileDevice(
owner_id=owner_id,
device_name=body.device_name,
platform=platform,
push_token=body.push_token,
is_active=True,
last_seen_at=now,
)
try:
db.add(device)
db.commit()
db.refresh(device)
except Exception:
db.rollback()
logger.exception("Failed to register mobile device for owner_id=%s", owner_id)
raise
logger.info("Mobile device registered: id=%s owner=%s platform=%s", device.id, owner_id, platform)
return _device_to_response(device)
@router.get("/devices", response_model=list[DeviceResponse])
@require_login
async def list_devices(
request: Request,
owner_id: CurrentOwner,
db: DbSession,
) -> list[dict[str, Any]]:
"""List all registered push-notification devices for the current user."""
devices = (
db.query(MobileDevice).filter(MobileDevice.owner_id == owner_id).order_by(MobileDevice.created_at.desc()).all()
)
return [_device_to_response(d) for d in devices]
@router.delete("/devices/{device_id}", status_code=status.HTTP_204_NO_CONTENT)
@require_login
async def deactivate_device(
request: Request,
device_id: int,
owner_id: CurrentOwner,
db: DbSession,
) -> None:
"""Deactivate a push-notification device registration.
The device record is kept for audit purposes but will no longer receive
push notifications.
"""
device = db.get(MobileDevice, device_id)
if not device or device.owner_id != owner_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Device not found")
device.is_active = False
try:
db.commit()
except Exception:
db.rollback()
raise
logger.info("Mobile device deactivated: id=%s owner=%s", device_id, owner_id)
@router.get("/whoami", response_model=WhoAmIResponse)
@require_login
async def whoami(
request: Request,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, Any]:
"""Return basic profile information for the authenticated user.
The mobile app calls this after token exchange to populate the user
profile screen and verify that the stored token is still valid.
"""
from app.auth import get_gravatar_url
from app.models import LocalUser, UserProfile
profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first()
local_user = db.query(LocalUser).filter(LocalUser.email == owner_id).first()
display_name: str | None = None
email: str | None = None
avatar_url: str | None = None
is_admin = False
if profile:
display_name = profile.display_name
if local_user:
email = local_user.email
is_admin = bool(local_user.is_admin)
if not display_name and local_user.display_name:
display_name = local_user.display_name
elif "@" in owner_id:
# SSO users commonly have their email as owner_id
email = owner_id
if email:
avatar_url = get_gravatar_url(email)
return {
"owner_id": owner_id,
"display_name": display_name,
"email": email,
"avatar_url": avatar_url,
"is_admin": is_admin,
}
+34
View File
@@ -833,3 +833,37 @@ class ScheduledJob(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class MobileDevice(Base):
"""Registered mobile device for push notifications.
Stores the push token (Expo push token, FCM token, or APNs token) for a
specific user device so that document-processing events can be forwarded
as push notifications to the native mobile app.
"""
__tablename__ = "mobile_devices"
id = Column(Integer, primary_key=True, index=True)
# User that owns this device registration.
owner_id = Column(String, nullable=False, index=True)
# Human-readable name the user gave this device (e.g. "John's iPhone").
device_name = Column(String(255), nullable=True)
# Platform: "ios", "android", or "web".
platform = Column(String(20), nullable=False, default="ios")
# Expo push token (ExponentPushToken[…]) or raw FCM/APNs token.
push_token = Column(String(512), nullable=False)
# Whether push notifications are enabled for this device.
is_active = Column(Boolean, nullable=False, default=True)
# Timestamps.
created_at = Column(DateTime(timezone=True), server_default=func.now())
last_seen_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (UniqueConstraint("owner_id", "push_token", name="uq_mobile_device_owner_token"),)
+130
View File
@@ -0,0 +1,130 @@
"""Push notification sender for the DocuElevate mobile app.
Uses the **Expo Push Notification** service to deliver notifications to both
iOS (via APNs) and Android (via FCM) without requiring server-side APNs keys
or FCM credentials. The mobile app obtains an ``ExponentPushToken[…]`` at
startup and registers it with the backend via the mobile API.
Reference: https://docs.expo.dev/push-notifications/sending-notifications/
"""
import logging
from typing import Any
import httpx
from app.database import SessionLocal
from app.models import MobileDevice
logger = logging.getLogger(__name__)
EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send"
# Maximum tokens per batch request (Expo limit).
_EXPO_BATCH_LIMIT = 100
def send_expo_push_notification(
tokens: list[str],
title: str,
body: str,
data: dict[str, Any] | None = None,
sound: str = "default",
badge: int | None = None,
) -> list[dict[str, Any]]:
"""Send a push notification to one or more Expo push tokens.
Args:
tokens: List of Expo push tokens (``ExponentPushToken[…]``).
title: Notification title shown in the system tray.
body: Notification body text.
data: Optional JSON-serialisable dict attached to the notification
(available in the app via ``notification.request.content.data``).
sound: Notification sound. Use ``"default"`` or ``None`` for silent.
badge: iOS badge count. Pass ``0`` to clear.
Returns:
List of Expo push receipt dicts (one per token).
"""
if not tokens:
return []
results: list[dict[str, Any]] = []
# Send in batches to stay within Expo's per-request limit.
for i in range(0, len(tokens), _EXPO_BATCH_LIMIT):
batch = tokens[i : i + _EXPO_BATCH_LIMIT]
messages = []
for token in batch:
msg: dict[str, Any] = {
"to": token,
"title": title,
"body": body,
"sound": sound,
}
if data:
msg["data"] = data
if badge is not None:
msg["badge"] = badge
messages.append(msg)
try:
resp = httpx.post(
EXPO_PUSH_URL,
json=messages,
headers={
"Accept": "application/json",
"Accept-Encoding": "gzip, deflate",
"Content-Type": "application/json",
},
timeout=15,
)
resp.raise_for_status()
payload = resp.json()
batch_results = payload.get("data", [])
results.extend(batch_results)
logger.debug("Expo push batch sent: %d tokens, %d results", len(batch), len(batch_results))
except httpx.HTTPStatusError as exc:
logger.error("Expo push HTTP error: %s %s", exc.response.status_code, exc.response.text)
except Exception:
logger.exception("Expo push notification failed for batch starting at index %d", i)
return results
def send_push_to_owner(
owner_id: str,
title: str,
body: str,
data: dict[str, Any] | None = None,
) -> None:
"""Look up all active push tokens for *owner_id* and send them a notification.
This function is safe to call from Celery task workers. Database errors
and push failures are logged but never raised so that the caller task is
not retried due to a notification failure.
"""
db = SessionLocal()
try:
devices = (
db.query(MobileDevice)
.filter(
MobileDevice.owner_id == owner_id,
MobileDevice.is_active.is_(True),
MobileDevice.push_token.isnot(None),
)
.all()
)
tokens = [d.push_token for d in devices if d.push_token]
except Exception:
logger.exception("Failed to query mobile devices for owner_id=%s", owner_id)
return
finally:
db.close()
if not tokens:
logger.debug("No active push tokens for owner_id=%s", owner_id)
return
logger.info("Sending push notification to %d device(s) for owner_id=%s", len(tokens), owner_id)
send_expo_push_notification(tokens=tokens, title=title, body=body, data=data)
+13
View File
@@ -210,6 +210,19 @@ def dispatch_user_notification(
finally:
db.close()
# 3. Send push notifications to registered mobile devices
try:
from app.utils.push_notification import send_push_to_owner
send_push_to_owner(
owner_id=owner_id,
title=title,
body=message,
data={"event_type": event_type, "file_id": file_id},
)
except Exception:
logger.exception("Error sending push notification for owner_id=%s event=%s", owner_id, event_type)
def notify_user_document_processed(owner_id: str, filename: str, file_id: int | None = None) -> None:
"""Notify a user that their document was successfully processed."""
+69
View File
@@ -2063,3 +2063,72 @@ print(response.json())
## Further Assistance
For additional help with the API, please contact our support team or refer to the [Development Guide](../CONTRIBUTING.md).
## Mobile App API
The mobile API provides endpoints used by the native iOS and Android app. All endpoints require authentication (Bearer token or active session cookie).
For full mobile app documentation see [MobileApp.md](./MobileApp.md).
### POST /api/mobile/generate-token
Exchange an active web session for a long-lived API token scoped to the mobile app.
**Request:**
```json
{ "device_name": "John's iPhone" }
```
**Response (201 Created):**
```json
{
"token": "de_AbCdEfGhIjKl...",
"token_id": 42,
"name": "Mobile App John's iPhone",
"created_at": "2026-03-10T09:30:00Z"
}
```
> The `token` is shown **once only**.
### POST /api/mobile/register-device
Register an Expo push token to receive push notifications.
**Request:**
```json
{
"push_token": "ExponentPushToken[xxxxxx]",
"device_name": "John's iPhone",
"platform": "ios"
}
```
**Response (201 Created):** Device record with `id`, `platform`, `is_active`, `created_at`.
### GET /api/mobile/devices
List all registered push-notification devices for the current user.
**Response (200 OK):** Array of device records.
### DELETE /api/mobile/devices/{device_id}
Deactivate a push-notification device. The device will no longer receive push notifications.
**Response (204 No Content)**
### GET /api/mobile/whoami
Return basic profile information for the authenticated user.
**Response (200 OK):**
```json
{
"owner_id": "john@example.com",
"display_name": "John Doe",
"email": "john@example.com",
"avatar_url": "https://www.gravatar.com/avatar/...",
"is_admin": false
}
```
+252
View File
@@ -0,0 +1,252 @@
# Mobile App
DocuElevate includes a native mobile application for iOS and Android built with **React Native** and **Expo**. The app allows users to capture documents with the device camera, pick files from the device storage, and receive push notifications when documents finish processing.
## Features
| Feature | iOS | Android |
|---------|-----|---------|
| SSO login (OAuth2) | ✅ | ✅ |
| Local / basic auth login | ✅ | ✅ |
| Auto-generated API token | ✅ | ✅ |
| Camera capture → upload | ✅ | ✅ |
| File picker upload | ✅ | ✅ |
| Share Sheet / Share Intent | ✅ | ✅ |
| Push notifications | ✅ | ✅ |
| Document list | ✅ | ✅ |
| Dark mode | ✅ | ✅ |
## Getting Started (Development)
### Prerequisites
- Node.js 18 or later
- [Expo CLI](https://docs.expo.dev/get-started/installation/): `npm install -g @expo/cli`
- [Expo Go](https://expo.dev/client) app on your iOS or Android device (for development)
- A running DocuElevate server reachable from your device
### Run in development mode
```bash
cd mobile
npm install
npx expo start
```
Scan the QR code with **Expo Go** on your device. On iOS you can also use the Camera app.
## Building for Production
DocuElevate uses **Expo Application Services (EAS)** to produce App Store / Play Store binaries.
```bash
# Install EAS CLI globally
npm install -g eas-cli
# Authenticate with Expo
eas login
# Build for iOS (requires Apple Developer account)
eas build --platform ios
# Build for Android
eas build --platform android
```
See the [EAS Build documentation](https://docs.expo.dev/build/introduction/) for full setup instructions.
## Authentication
### SSO Login Flow
The mobile app uses the server's existing OAuth2/SSO setup:
1. User enters the DocuElevate server URL on the login screen.
2. The app opens `<server>/login?mobile=1&redirect_uri=docuelevate://callback` in the **system browser** (Safari / Chrome).
3. The user authenticates via SSO or local credentials.
4. The server redirects back to `docuelevate://callback`.
5. The app calls `POST /api/mobile/generate-token` to exchange the session for a **long-lived API token**.
6. The token is stored securely in the device's keychain (`expo-secure-store`).
### Auto-generated Mobile Token
When the mobile app completes login it automatically creates a named API token (`"Mobile App <device name>"`) via `POST /api/mobile/generate-token`. This token:
- Works identically to tokens created manually in the web UI.
- Is shown in the **API Tokens** page (`/api-tokens`) and can be revoked there.
- Is stored in the device's secure keychain, never in plain storage.
## Push Notifications
Push notifications are delivered via the **Expo Push Notification** service, which routes through Apple Push Notification service (APNs) for iOS and Firebase Cloud Messaging (FCM) for Android.
**No server-side APNs/FCM credentials are required** Expo's servers handle the provider integration.
### How it works
1. After login, the app requests notification permission from the operating system.
2. If granted, the app obtains an **Expo Push Token** (`ExponentPushToken[…]`).
3. The token is registered with the backend via `POST /api/mobile/register-device`.
4. When a document finishes processing, the server sends a push notification to all registered devices for that user.
### Managing registered devices
Users can see and remove their registered devices from the **Profile** tab in the app, or via the API:
```bash
# List registered devices
curl -H "Authorization: Bearer <token>" https://your-server/api/mobile/devices
# Remove a device
curl -X DELETE -H "Authorization: Bearer <token>" https://your-server/api/mobile/devices/<id>
```
## Uploading Documents
### Camera Capture
1. Open the **Upload** tab.
2. Tap **Camera**.
3. Point the camera at the document and take a photo.
4. The image is immediately uploaded and queued for processing.
### File Picker
1. Open the **Upload** tab.
2. Tap **File Picker**.
3. Browse to and select one or more files (PDF, DOCX, images, etc.).
4. Files are uploaded and queued for processing.
### Share Sheet (iOS) / Share Intent (Android)
The app registers itself as a share target so any file can be sent directly to DocuElevate from another app:
1. Open a file in Files, Mail, Safari, or any other app.
2. Tap the **Share** button (iOS) or **Share** (Android).
3. Find and tap **DocuElevate** in the share sheet.
4. The file is immediately uploaded.
> **Note:** The app must be installed on the device for it to appear in the share sheet.
## Mobile API Endpoints
The backend exposes a dedicated `/api/mobile/` namespace:
| Method | Endpoint | Auth | Description |
|--------|----------|------|-------------|
| `POST` | `/api/mobile/generate-token` | Session | Exchange SSO session for API token |
| `POST` | `/api/mobile/register-device` | Bearer | Register Expo push token |
| `GET` | `/api/mobile/devices` | Bearer | List registered devices |
| `DELETE` | `/api/mobile/devices/{id}` | Bearer | Deactivate a device |
| `GET` | `/api/mobile/whoami` | Bearer | Get current user profile |
All other API endpoints (file upload, file listing, etc.) work with Bearer token authentication.
### POST /api/mobile/generate-token
Exchanges an active web session (cookie) for a permanent API token suitable for use in the mobile app.
**Request:**
```json
{ "device_name": "John's iPhone" }
```
**Response (201):**
```json
{
"token": "de_AbCdEfGhIjKl...",
"token_id": 42,
"name": "Mobile App John's iPhone",
"created_at": "2026-03-10T09:30:00Z"
}
```
> ⚠️ The `token` value is returned **once only**. Store it in the device's secure keychain immediately.
### POST /api/mobile/register-device
Registers an Expo push token for the authenticated user.
**Request:**
```json
{
"push_token": "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]",
"device_name": "John's iPhone",
"platform": "ios"
}
```
Supported platforms: `ios`, `android`, `web`.
Re-registering the same token is safe (idempotent).
### GET /api/mobile/whoami
Returns the current user's profile.
**Response (200):**
```json
{
"owner_id": "john@example.com",
"display_name": "John Doe",
"email": "john@example.com",
"avatar_url": "https://www.gravatar.com/avatar/...",
"is_admin": false
}
```
## Configuration
No server-side configuration is required to enable the mobile app. The Expo push notification routing does not need FCM or APNs credentials on the server.
If you wish to use **direct FCM/APNs** without Expo's relay, replace the `send_expo_push_notification` function in `app/utils/push_notification.py` with your own implementation.
## Project Structure (mobile/)
```
mobile/
├── App.tsx # Root component
├── app.json # Expo/EAS configuration
├── eas.json # EAS Build profiles
├── package.json
├── tsconfig.json
└── src/
├── context/
│ └── AuthContext.tsx # Auth state + SSO login flow
├── hooks/
│ └── usePushNotifications.ts # Push token registration
├── screens/
│ ├── LoginScreen.tsx # Server URL + SSO button
│ ├── UploadScreen.tsx # Camera capture + file picker
│ ├── FilesScreen.tsx # Processed document list
│ └── ProfileScreen.tsx # User profile + sign out
└── services/
└── api.ts # DocuElevate REST API client
```
## Troubleshooting
### "Authentication was cancelled or failed"
- Ensure the server URL is correct (including `https://`).
- Verify the server is reachable from your device's network.
- Confirm that `AUTH_ENABLED=True` on the server.
### Push notifications not arriving
1. Check that the app has notification permission (Settings → DocuElevate → Notifications).
2. Verify the device is registered: `GET /api/mobile/devices`.
3. Ensure the server can reach `https://exp.host` (outbound HTTPS on port 443).
4. On Android, add `google-services.json` to the `mobile/` directory if you are building your own binary.
### "Connection refused" or timeout
- Verify that the DocuElevate server is running and accessible.
- Ensure the server's `EXTERNAL_HOSTNAME` or reverse proxy is configured correctly.
- Check that the server accepts CORS requests from `docuelevate://`.
## Related Documentation
- [API Documentation](./API.md)
- [Configuration Guide](./ConfigurationGuide.md)
- [Deployment Guide](./DeploymentGuide.md)
+1
View File
@@ -24,6 +24,7 @@ from app.models import ( # noqa: F401
DocumentMetadata,
FileProcessingStep,
FileRecord,
MobileDevice,
ProcessingLog,
SavedSearch,
SettingsAuditLog,
@@ -0,0 +1,41 @@
"""Add mobile_devices table for push notification device registration.
Revision ID: 027_add_mobile_devices
Revises: 026_add_scheduled_jobs
Create Date: 2026-03-10
"""
from typing import Union
import sqlalchemy as sa
from alembic import op
revision: str = "027_add_mobile_devices"
down_revision: Union[str, None] = "026_add_scheduled_jobs"
depends_on: Union[str, None] = None
def upgrade() -> None:
"""Create mobile_devices table."""
op.create_table(
"mobile_devices",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("owner_id", sa.String(), nullable=False),
sa.Column("device_name", sa.String(255), nullable=True),
sa.Column("platform", sa.String(20), nullable=False, server_default="ios"),
sa.Column("push_token", sa.String(512), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=True),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("owner_id", "push_token", name="uq_mobile_device_owner_token"),
)
op.create_index("ix_mobile_devices_id", "mobile_devices", ["id"])
op.create_index("ix_mobile_devices_owner_id", "mobile_devices", ["owner_id"])
def downgrade() -> None:
"""Drop mobile_devices table."""
op.drop_index("ix_mobile_devices_owner_id", table_name="mobile_devices")
op.drop_index("ix_mobile_devices_id", table_name="mobile_devices")
op.drop_table("mobile_devices")
+21
View File
@@ -0,0 +1,21 @@
node_modules/
.expo/
dist/
web-build/
ios/
android/
.env
google-services.json
GoogleService-Info.plist
*.jks
*.p8
*.p12
*.key
*.mobileprovision
*.orig.*
npm-debug.*
yarn-debug.*
yarn-error.*
.idea/
.DS_Store
Thumbs.db
+126
View File
@@ -0,0 +1,126 @@
/**
* App.tsx root component for the DocuElevate mobile app.
*
* Wraps the entire app in the AuthProvider and renders either the login
* screen (unauthenticated) or the main tab navigator (authenticated).
* Push notification registration is handled by the usePushNotifications hook.
*/
import { NavigationContainer } from "@react-navigation/native";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import React from "react";
import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { AuthProvider, useAuth } from "./src/context/AuthContext";
import { usePushNotifications } from "./src/hooks/usePushNotifications";
import FilesScreen from "./src/screens/FilesScreen";
import LoginScreen from "./src/screens/LoginScreen";
import ProfileScreen from "./src/screens/ProfileScreen";
import UploadScreen from "./src/screens/UploadScreen";
const Tab = createBottomTabNavigator();
function TabNavigator() {
const { isAuthenticated } = useAuth();
usePushNotifications(isAuthenticated);
return (
<Tab.Navigator
screenOptions={{
tabBarActiveTintColor: "#1e40af",
tabBarInactiveTintColor: "#9ca3af",
tabBarStyle: {
borderTopColor: "#e5e7eb",
backgroundColor: "#ffffff",
},
headerStyle: {
backgroundColor: "#1e40af",
},
headerTintColor: "#ffffff",
headerTitleStyle: {
fontWeight: "700",
},
}}
>
<Tab.Screen
name="Upload"
component={UploadScreen}
options={{
title: "Upload",
tabBarLabel: "Upload",
tabBarIcon: ({ color }) => (
<Text style={{ fontSize: 20, color }}></Text>
),
headerTitle: "DocuElevate",
}}
/>
<Tab.Screen
name="Files"
component={FilesScreen}
options={{
title: "Files",
tabBarLabel: "Files",
tabBarIcon: ({ color }) => (
<Text style={{ fontSize: 20, color }}>📄</Text>
),
headerTitle: "My Documents",
}}
/>
<Tab.Screen
name="Profile"
component={ProfileScreen}
options={{
title: "Profile",
tabBarLabel: "Profile",
tabBarIcon: ({ color }) => (
<Text style={{ fontSize: 20, color }}>👤</Text>
),
headerTitle: "Profile",
}}
/>
</Tab.Navigator>
);
}
function AppContent() {
const { isLoading, isAuthenticated } = useAuth();
if (isLoading) {
return (
<View style={styles.loading}>
<ActivityIndicator size="large" color="#1e40af" />
<Text style={styles.loadingText}>Loading</Text>
</View>
);
}
return (
<NavigationContainer>
{isAuthenticated ? <TabNavigator /> : <LoginScreen />}
</NavigationContainer>
);
}
export default function App() {
return (
<SafeAreaProvider>
<AuthProvider>
<AppContent />
</AuthProvider>
</SafeAreaProvider>
);
}
const styles = StyleSheet.create({
loading: {
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f9fafb",
gap: 12,
},
loadingText: {
color: "#6b7280",
fontSize: 15,
},
});
+137
View File
@@ -0,0 +1,137 @@
# DocuElevate Mobile App
Native mobile application for DocuElevate, built with **React Native** and **Expo** for both iOS (primary) and Android.
## Features
- 🔐 **SSO Login** authenticate via your DocuElevate server's OAuth2/SSO provider; an API token is auto-generated and stored securely in the device keychain
- 📷 **Camera Capture** scan documents directly with the device camera
- 📄 **File Picker** upload PDFs, images, and Office documents from the device's Files app
- 🔗 **Share Extension** send files from any app directly to DocuElevate via the iOS/Android share sheet
- 🔔 **Push Notifications** receive real-time push notifications when documents finish processing (via Expo push notifications)
- 📂 **Document List** browse and search your processed documents
- 👤 **Profile** view account details and sign out
## Requirements
- Node.js 18+
- Expo CLI (`npm install -g @expo/cli`)
- Expo Go app on device (for development) **or** Expo Application Services (EAS) for production builds
- An Expo account: <https://expo.dev/>
## Setup
```bash
# 1. Install dependencies
cd mobile
npm install
# 2. Start the development server
npx expo start
```
Scan the QR code with **Expo Go** on your iOS or Android device.
## Building
DocuElevate uses **EAS Build** for production binaries.
```bash
# Install EAS CLI
npm install -g eas-cli
# Log in to Expo
eas login
# Configure your project (one-time)
eas init
# Build for iOS
eas build --platform ios
# Build for Android
eas build --platform android
# Build for both
eas build --platform all
```
### iOS-specific
- An Apple Developer account is required for TestFlight and App Store distribution
- Update `eas.json` with your `appleId`, `ascAppId`, and `appleTeamId`
- Camera, photo library, and push notification usage descriptions are configured in `app.json`
### Android-specific
- Add a `google-services.json` file (from Firebase Console) to the `mobile/` directory for push notification support
- Update `eas.json` with the path to your Google Play service account key
## Configuration
No code changes are needed to point the app at a different server. The server URL is entered by the user on the login screen and stored in the device's secure store.
## Authentication Flow
1. User enters the DocuElevate server URL on the login screen
2. The app opens the server's `/login?mobile=1&redirect_uri=docuelevate://callback` URL in the system browser
3. The user authenticates (SSO / local login)
4. The server redirects back to `docuelevate://callback`
5. The app exchanges the browser session for a permanent API token via `POST /api/mobile/generate-token`
6. The token is stored in the device's secure keychain (`expo-secure-store`)
## Push Notifications
The app uses **Expo Push Notifications** which route through Expo's servers to APNs (iOS) and FCM (Android) no server-side APNs/FCM credentials are needed.
The Expo push token is sent to the backend after login via `POST /api/mobile/register-device` and the server uses it to deliver notifications when documents are processed.
## Project Structure
```
mobile/
├── App.tsx # Root component
├── app.json # Expo configuration
├── eas.json # EAS Build configuration
├── package.json
├── tsconfig.json
└── src/
├── context/
│ └── AuthContext.tsx # Authentication state management
├── hooks/
│ └── usePushNotifications.ts # Push notification registration
├── screens/
│ ├── LoginScreen.tsx # SSO login
│ ├── UploadScreen.tsx # Camera capture + file picker
│ ├── FilesScreen.tsx # Document list
│ └── ProfileScreen.tsx # User profile + sign out
└── services/
└── api.ts # DocuElevate API client
```
## Share Extension (iOS)
The app registers the `docuelevate://` URL scheme and the `com.docuelevate.app` bundle identifier. To enable the share sheet:
1. Ensure the app is installed on the device
2. Open any file in Files, Mail, Safari, etc.
3. Tap the share icon → find **DocuElevate** in the share sheet
4. The file is uploaded immediately
Android uses a similar intent filter configured in `app.json`.
## Backend API
The mobile app uses the following backend endpoints:
| Method | Endpoint | Description |
|----------|-------------------------------------|---------------------------------------|
| `POST` | `/api/mobile/generate-token` | Exchange SSO session for API token |
| `POST` | `/api/mobile/register-device` | Register Expo push token |
| `GET` | `/api/mobile/devices` | List registered devices |
| `DELETE` | `/api/mobile/devices/{id}` | Deactivate device registration |
| `GET` | `/api/mobile/whoami` | Get current user profile |
| `POST` | `/api/ui-upload` | Upload file for processing |
| `GET` | `/api/files` | List processed documents |
Authentication uses `Authorization: Bearer <api_token>` on all requests.
+70
View File
@@ -0,0 +1,70 @@
{
"name": "DocuElevate",
"slug": "docuelevate",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"splash": {
"image": "./assets/splash.png",
"resizeMode": "contain",
"backgroundColor": "#1e40af"
},
"assetBundlePatterns": ["**/*"],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.docuelevate.app",
"infoPlist": {
"NSCameraUsageDescription": "DocuElevate uses the camera to capture documents for upload.",
"NSPhotoLibraryUsageDescription": "DocuElevate accesses your photo library to select documents for upload.",
"NSPhotoLibraryAddUsageDescription": "DocuElevate saves scanned documents to your photo library.",
"UIBackgroundModes": ["fetch", "remote-notification"]
},
"buildNumber": "1"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#1e40af"
},
"package": "com.docuelevate.app",
"permissions": [
"CAMERA",
"READ_EXTERNAL_STORAGE",
"WRITE_EXTERNAL_STORAGE",
"RECEIVE_BOOT_COMPLETED",
"VIBRATE"
],
"versionCode": 1,
"googleServicesFile": "./google-services.json"
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-notifications",
{
"icon": "./assets/notification-icon.png",
"color": "#1e40af",
"sounds": ["./assets/notification-sound.wav"]
}
],
[
"expo-camera",
{
"cameraPermission": "DocuElevate uses the camera to capture documents for upload."
}
],
"expo-document-picker",
"expo-secure-store",
"expo-sharing"
],
"scheme": "docuelevate",
"extra": {
"eas": {
"projectId": "YOUR_EAS_PROJECT_ID"
}
}
}
+18
View File
@@ -0,0 +1,18 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ["babel-preset-expo"],
plugins: [
[
"module-resolver",
{
root: ["./"],
alias: {
"@": "./src",
},
},
],
"react-native-reanimated/plugin",
],
};
};
+33
View File
@@ -0,0 +1,33 @@
{
"cli": {
"version": ">= 5.9.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal"
},
"preview": {
"distribution": "internal",
"ios": {
"simulator": false
}
},
"production": {
"autoIncrement": true
}
},
"submit": {
"production": {
"ios": {
"appleId": "YOUR_APPLE_ID",
"ascAppId": "YOUR_APP_STORE_CONNECT_APP_ID",
"appleTeamId": "YOUR_APPLE_TEAM_ID"
},
"android": {
"serviceAccountKeyPath": "./google-play-service-account.json",
"track": "production"
}
}
}
}
+63
View File
@@ -0,0 +1,63 @@
{
"name": "docuelevate-mobile",
"version": "1.0.0",
"description": "DocuElevate native mobile app (iOS and Android)",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web",
"lint": "eslint src --ext .ts,.tsx",
"type-check": "tsc --noEmit",
"build:ios": "eas build --platform ios",
"build:android": "eas build --platform android",
"build:all": "eas build --platform all",
"submit:ios": "eas submit --platform ios",
"submit:android": "eas submit --platform android"
},
"dependencies": {
"@expo/vector-icons": "^14.0.0",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-navigation/bottom-tabs": "^6.6.1",
"@react-navigation/native": "^6.1.18",
"@react-navigation/native-stack": "^6.11.0",
"expo": "~51.0.0",
"expo-auth-session": "~5.5.2",
"expo-camera": "~15.0.16",
"expo-constants": "~16.0.2",
"expo-crypto": "~13.0.2",
"expo-document-picker": "~12.0.2",
"expo-file-system": "~17.0.1",
"expo-image-manipulator": "~12.0.5",
"expo-image-picker": "~15.0.7",
"expo-linking": "~6.3.1",
"expo-notifications": "~0.28.15",
"expo-router": "~3.5.23",
"expo-secure-store": "~13.0.2",
"expo-sharing": "~12.0.1",
"expo-splash-screen": "~0.27.5",
"expo-status-bar": "~1.12.1",
"expo-web-browser": "~13.0.3",
"react": "18.2.0",
"react-native": "0.74.5",
"react-native-safe-area-context": "4.10.5",
"react-native-screens": "3.31.1"
},
"devDependencies": {
"@babel/core": "^7.24.0",
"@types/react": "~18.2.79",
"@types/react-native": "^0.73.0",
"eslint": "^8.57.0",
"eslint-config-expo": "~7.0.0",
"typescript": "^5.3.0"
},
"private": true,
"expo": {
"doctor": {
"reactNativeDirectoryCheck": {
"exclude": ["@react-navigation/bottom-tabs"]
}
}
}
}
+186
View File
@@ -0,0 +1,186 @@
/**
* Authentication context for the DocuElevate mobile app.
*
* Manages the lifecycle of the stored API token and user profile. The SSO
* login flow uses expo-auth-session to open the server's OAuth page in the
* system browser; on return the redirect URL carries a one-time code that is
* exchanged for a session cookie, which is then traded for a permanent API
* token via POST /api/mobile/generate-token.
*/
import * as SecureStore from "expo-secure-store";
import * as WebBrowser from "expo-web-browser";
import React, {
createContext,
useCallback,
useContext,
useEffect,
useState,
} from "react";
import {
SECURE_STORE_API_TOKEN_KEY,
SECURE_STORE_BASE_URL_KEY,
SECURE_STORE_OWNER_ID_KEY,
api,
type WhoAmIResponse,
} from "../services/api";
WebBrowser.maybeCompleteAuthSession();
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface AuthState {
isLoading: boolean;
isAuthenticated: boolean;
user: WhoAmIResponse | null;
baseUrl: string;
signIn: (serverUrl: string) => Promise<void>;
signOut: () => Promise<void>;
setToken: (token: string) => Promise<void>;
}
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
const AuthContext = createContext<AuthState>({
isLoading: true,
isAuthenticated: false,
user: null,
baseUrl: "",
signIn: async () => {},
signOut: async () => {},
setToken: async () => {},
});
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [isLoading, setIsLoading] = useState(true);
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [user, setUser] = useState<WhoAmIResponse | null>(null);
const [baseUrl, setBaseUrl] = useState("");
// On mount: restore persisted session
useEffect(() => {
(async () => {
try {
const storedUrl = await SecureStore.getItemAsync(SECURE_STORE_BASE_URL_KEY);
const storedToken = await SecureStore.getItemAsync(SECURE_STORE_API_TOKEN_KEY);
if (storedUrl && storedToken) {
await api.init(storedUrl);
setBaseUrl(storedUrl);
// Verify token is still valid
const profile = await api.whoAmI();
setUser(profile);
setIsAuthenticated(true);
}
} catch {
// Token expired or server unavailable clear stored credentials
await SecureStore.deleteItemAsync(SECURE_STORE_API_TOKEN_KEY);
await SecureStore.deleteItemAsync(SECURE_STORE_OWNER_ID_KEY);
} finally {
setIsLoading(false);
}
})();
}, []);
const setToken = useCallback(async (token: string) => {
await SecureStore.setItemAsync(SECURE_STORE_API_TOKEN_KEY, token);
const profile = await api.whoAmI();
setUser(profile);
await SecureStore.setItemAsync(SECURE_STORE_OWNER_ID_KEY, profile.owner_id);
setIsAuthenticated(true);
}, []);
const signIn = useCallback(
async (serverUrl: string) => {
const cleanUrl = serverUrl.replace(/\/$/, "");
await api.init(cleanUrl);
setBaseUrl(cleanUrl);
// Open the web login page in the system browser. The user authenticates
// via SSO or local credentials, then the app deep-link (docuelevate://callback)
// is triggered. The WebBrowser.openAuthSessionAsync handles the redirect
// back to the app.
const result = await WebBrowser.openAuthSessionAsync(
`${cleanUrl}/login?mobile=1&redirect_uri=docuelevate://callback`,
"docuelevate://callback"
);
if (result.type !== "success") {
throw new Error("Authentication was cancelled or failed");
}
// Parse the token from the redirect URL if the server appended it,
// otherwise hit the generate-token endpoint (session cookie is carried
// by the WebBrowser).
const url = new URL(result.url);
const inlineToken = url.searchParams.get("token");
if (inlineToken) {
await setToken(inlineToken);
} else {
// The server set a session cookie during the browser session; exchange
// it for a persistent API token.
const deviceInfo = await _getDeviceName();
const tokenResp = await api.generateMobileToken(deviceInfo);
await setToken(tokenResp.token);
}
},
[setToken]
);
const signOut = useCallback(async () => {
await SecureStore.deleteItemAsync(SECURE_STORE_API_TOKEN_KEY);
await SecureStore.deleteItemAsync(SECURE_STORE_OWNER_ID_KEY);
setUser(null);
setIsAuthenticated(false);
}, []);
return (
<AuthContext.Provider
value={{
isLoading,
isAuthenticated,
user,
baseUrl,
signIn,
signOut,
setToken,
}}
>
{children}
</AuthContext.Provider>
);
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useAuth(): AuthState {
return useContext(AuthContext);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function _getDeviceName(): Promise<string> {
try {
const Constants = await import("expo-constants");
return (
Constants.default.deviceName ||
Constants.default.expoConfig?.name ||
"Mobile App"
);
} catch {
return "Mobile App";
}
}
+114
View File
@@ -0,0 +1,114 @@
/**
* usePushNotifications register the device for push notifications.
*
* Requests the user's permission for notifications, obtains an Expo push
* token, and registers it with the DocuElevate backend via
* POST /api/mobile/register-device.
*
* This hook should be called once from the root component after the user has
* successfully authenticated.
*/
import Constants from "expo-constants";
import * as Device from "expo-device";
import * as Notifications from "expo-notifications";
import { useCallback, useEffect, useRef } from "react";
import { Platform } from "react-native";
import api from "../services/api";
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
export function usePushNotifications(isAuthenticated: boolean) {
const notificationListener = useRef<Notifications.Subscription | null>(null);
const responseListener = useRef<Notifications.Subscription | null>(null);
const registerForPushNotifications = useCallback(async () => {
if (!Device.isDevice) {
// Push tokens are not available in simulators.
return;
}
if (Platform.OS === "android") {
await Notifications.setNotificationChannelAsync("default", {
name: "DocuElevate",
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: "#1e40af",
});
}
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== "granted") {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== "granted") {
// User declined no push notifications
return;
}
let projectId: string | undefined;
try {
projectId =
Constants.expoConfig?.extra?.eas?.projectId ??
Constants.easConfig?.projectId;
} catch {
// ignore
}
const tokenData = await Notifications.getExpoPushTokenAsync(
projectId ? { projectId } : undefined
);
const pushToken = tokenData.data;
const platform = Platform.OS as "ios" | "android" | "web";
let deviceName = "Mobile App";
try {
deviceName = Device.modelName ?? Device.deviceName ?? "Mobile App";
} catch {
// ignore
}
try {
await api.registerDevice({ push_token: pushToken, device_name: deviceName, platform });
} catch {
// Registration failure is non-fatal the app still works without push.
}
}, []);
useEffect(() => {
if (!isAuthenticated) return;
registerForPushNotifications();
// Listen for incoming notifications while app is foregrounded
notificationListener.current = Notifications.addNotificationReceivedListener((notification) => {
console.log("Notification received:", notification.request.content.title);
});
// Listen for user taps on notifications
responseListener.current = Notifications.addNotificationResponseReceivedListener((response) => {
const data = response.notification.request.content.data as Record<string, unknown>;
// Navigate to file detail if file_id is present
if (data?.file_id) {
console.log("User tapped notification for file:", data.file_id);
// Navigation would be wired up by the caller via a callback prop
}
});
return () => {
notificationListener.current?.remove();
responseListener.current?.remove();
};
}, [isAuthenticated, registerForPushNotifications]);
}
+220
View File
@@ -0,0 +1,220 @@
/**
* FilesScreen list of documents processed by DocuElevate.
*/
import React, { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
FlatList,
Pressable,
RefreshControl,
StyleSheet,
Text,
View,
} from "react-native";
import type { FileRecord } from "../services/api";
import api from "../services/api";
function formatBytes(bytes: number | null): string {
if (bytes === null || bytes === undefined) return "";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
} catch {
return iso;
}
}
function statusEmoji(status: string): string {
const map: Record<string, string> = {
processed: "✅",
processing: "⚙️",
queued: "⏳",
failed: "❌",
uploaded: "⬆️",
};
return map[status?.toLowerCase()] ?? "📄";
}
export default function FilesScreen() {
const [files, setFiles] = useState<FileRecord[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchFiles = useCallback(
async (pageNum: number, replace: boolean) => {
try {
const data = await api.listFiles(pageNum, 20);
if (replace) {
setFiles(data);
} else {
setFiles((prev) => [...prev, ...data]);
}
setHasMore(data.length === 20);
setError(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to load files");
}
},
[]
);
useEffect(() => {
(async () => {
setLoading(true);
await fetchFiles(1, true);
setLoading(false);
})();
}, [fetchFiles]);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
setPage(1);
await fetchFiles(1, true);
setRefreshing(false);
}, [fetchFiles]);
const handleLoadMore = useCallback(async () => {
if (!hasMore || loading || refreshing) return;
const next = page + 1;
setPage(next);
await fetchFiles(next, false);
}, [fetchFiles, hasMore, loading, page, refreshing]);
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" color="#1e40af" />
</View>
);
}
if (error) {
return (
<View style={styles.center}>
<Text style={styles.errorText}>{error}</Text>
<Pressable style={styles.retryButton} onPress={handleRefresh}>
<Text style={styles.retryText}>Retry</Text>
</Pressable>
</View>
);
}
return (
<FlatList
style={styles.list}
data={files}
keyExtractor={(item) => String(item.id)}
contentContainerStyle={styles.listContent}
renderItem={({ item }) => <FileRow file={item} />}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={handleRefresh} />
}
onEndReached={handleLoadMore}
onEndReachedThreshold={0.4}
ListEmptyComponent={
<View style={styles.emptyState}>
<Text style={styles.emptyEmoji}>📂</Text>
<Text style={styles.emptyText}>No documents yet.</Text>
<Text style={styles.emptyHint}>
Upload a document from the Upload tab to get started.
</Text>
</View>
}
ListFooterComponent={
hasMore && files.length > 0 ? (
<ActivityIndicator color="#1e40af" style={{ marginVertical: 16 }} />
) : null
}
/>
);
}
function FileRow({ file }: { file: FileRecord }) {
return (
<View style={rowStyles.row}>
<Text style={rowStyles.icon}>{statusEmoji(file.status)}</Text>
<View style={rowStyles.info}>
<Text style={rowStyles.filename} numberOfLines={1}>
{file.filename}
</Text>
<Text style={rowStyles.meta}>
{formatDate(file.created_at)} · {formatBytes(file.file_size)}
</Text>
</View>
<Text style={rowStyles.status}>{file.status}</Text>
</View>
);
}
const styles = StyleSheet.create({
list: { flex: 1, backgroundColor: "#f9fafb" },
listContent: { padding: 16 },
center: {
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f9fafb",
padding: 24,
},
errorText: { color: "#dc2626", fontSize: 15, textAlign: "center", marginBottom: 16 },
retryButton: {
backgroundColor: "#1e40af",
borderRadius: 8,
paddingHorizontal: 24,
paddingVertical: 10,
},
retryText: { color: "#fff", fontWeight: "600" },
emptyState: { alignItems: "center", paddingTop: 60 },
emptyEmoji: { fontSize: 48, marginBottom: 12 },
emptyText: { fontSize: 16, color: "#374151", marginBottom: 8 },
emptyHint: {
fontSize: 13,
color: "#6b7280",
textAlign: "center",
paddingHorizontal: 32,
},
});
const rowStyles = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#fff",
borderRadius: 10,
padding: 14,
marginBottom: 10,
shadowColor: "#000",
shadowOpacity: 0.04,
shadowOffset: { width: 0, height: 2 },
shadowRadius: 4,
elevation: 2,
},
icon: { fontSize: 22, marginRight: 12 },
info: { flex: 1 },
filename: {
fontSize: 14,
fontWeight: "600",
color: "#111827",
marginBottom: 4,
},
meta: { fontSize: 12, color: "#6b7280" },
status: {
fontSize: 11,
color: "#6b7280",
fontWeight: "500",
textTransform: "capitalize",
},
});
+165
View File
@@ -0,0 +1,165 @@
/**
* LoginScreen entry point for unauthenticated users.
*
* Renders a server URL input and a "Sign in with SSO" button that opens the
* DocuElevate web login page in the system browser. On success the
* AuthContext stores the API token and navigates to the main app.
*/
import React, { useState } from "react";
import {
ActivityIndicator,
Alert,
KeyboardAvoidingView,
Platform,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { useAuth } from "../context/AuthContext";
export default function LoginScreen() {
const { signIn } = useAuth();
const [serverUrl, setServerUrl] = useState("");
const [loading, setLoading] = useState(false);
async function handleSignIn() {
const url = serverUrl.trim();
if (!url) {
Alert.alert("Server URL required", "Please enter the URL of your DocuElevate server.");
return;
}
if (!url.startsWith("http://") && !url.startsWith("https://")) {
Alert.alert("Invalid URL", "The server URL must start with http:// or https://");
return;
}
setLoading(true);
try {
await signIn(url);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Sign-in failed";
Alert.alert("Sign-in failed", message);
} finally {
setLoading(false);
}
}
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<View style={styles.card}>
<Text style={styles.logo}>DocuElevate</Text>
<Text style={styles.tagline}>Intelligent Document Processing</Text>
<Text style={styles.label}>Server URL</Text>
<TextInput
style={styles.input}
placeholder="https://your-docuelevate-server.com"
placeholderTextColor="#9ca3af"
value={serverUrl}
onChangeText={setServerUrl}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
returnKeyType="go"
onSubmitEditing={handleSignIn}
accessibilityLabel="Server URL"
/>
<Pressable
style={[styles.button, loading && styles.buttonDisabled]}
onPress={handleSignIn}
disabled={loading}
accessibilityRole="button"
accessibilityLabel="Sign in with SSO"
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Sign in with SSO</Text>
)}
</Pressable>
<Text style={styles.hint}>
You will be redirected to your organisation's sign-in page.
</Text>
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#f3f4f6",
justifyContent: "center",
padding: 24,
},
card: {
backgroundColor: "#ffffff",
borderRadius: 16,
padding: 28,
shadowColor: "#000",
shadowOpacity: 0.08,
shadowOffset: { width: 0, height: 4 },
shadowRadius: 12,
elevation: 4,
},
logo: {
fontSize: 28,
fontWeight: "700",
color: "#1e40af",
textAlign: "center",
marginBottom: 4,
},
tagline: {
fontSize: 14,
color: "#6b7280",
textAlign: "center",
marginBottom: 32,
},
label: {
fontSize: 14,
fontWeight: "600",
color: "#374151",
marginBottom: 6,
},
input: {
borderWidth: 1,
borderColor: "#d1d5db",
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 12,
fontSize: 15,
color: "#111827",
marginBottom: 20,
backgroundColor: "#f9fafb",
},
button: {
backgroundColor: "#1e40af",
borderRadius: 8,
paddingVertical: 14,
alignItems: "center",
justifyContent: "center",
minHeight: 48,
},
buttonDisabled: {
opacity: 0.6,
},
buttonText: {
color: "#ffffff",
fontSize: 16,
fontWeight: "600",
},
hint: {
marginTop: 16,
fontSize: 12,
color: "#9ca3af",
textAlign: "center",
},
});
+195
View File
@@ -0,0 +1,195 @@
/**
* ProfileScreen authenticated user profile and settings.
*/
import React from "react";
import {
Alert,
Image,
Pressable,
ScrollView,
StyleSheet,
Switch,
Text,
View,
} from "react-native";
import { useAuth } from "../context/AuthContext";
export default function ProfileScreen() {
const { user, signOut, baseUrl } = useAuth();
function handleSignOut() {
Alert.alert("Sign out", "Are you sure you want to sign out?", [
{ text: "Cancel", style: "cancel" },
{
text: "Sign out",
style: "destructive",
onPress: signOut,
},
]);
}
if (!user) {
return (
<View style={styles.center}>
<Text style={styles.emptyText}>Not signed in</Text>
</View>
);
}
return (
<ScrollView style={styles.scroll} contentContainerStyle={styles.content}>
{/* Avatar + name */}
<View style={styles.profileCard}>
{user.avatar_url ? (
<Image
source={{ uri: user.avatar_url }}
style={styles.avatar}
accessibilityLabel={`Avatar for ${user.display_name ?? user.owner_id}`}
/>
) : (
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<Text style={styles.avatarInitial}>
{(user.display_name ?? user.owner_id).charAt(0).toUpperCase()}
</Text>
</View>
)}
<Text style={styles.displayName}>{user.display_name ?? user.owner_id}</Text>
{user.email && <Text style={styles.email}>{user.email}</Text>}
{user.is_admin && <Text style={styles.adminBadge}>Admin</Text>}
</View>
{/* Server info */}
<View style={styles.section}>
<Text style={styles.sectionTitle}>Connection</Text>
<View style={styles.row}>
<Text style={styles.rowLabel}>Server</Text>
<Text style={styles.rowValue} numberOfLines={1}>
{baseUrl || ""}
</Text>
</View>
<View style={styles.row}>
<Text style={styles.rowLabel}>User ID</Text>
<Text style={styles.rowValue} numberOfLines={1}>
{user.owner_id}
</Text>
</View>
</View>
{/* Danger zone */}
<View style={styles.section}>
<Pressable
style={styles.signOutButton}
onPress={handleSignOut}
accessibilityRole="button"
accessibilityLabel="Sign out"
>
<Text style={styles.signOutText}>Sign out</Text>
</Pressable>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
scroll: { flex: 1, backgroundColor: "#f9fafb" },
content: { padding: 20 },
center: {
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f9fafb",
},
emptyText: { color: "#6b7280", fontSize: 16 },
profileCard: {
alignItems: "center",
backgroundColor: "#fff",
borderRadius: 16,
padding: 24,
marginBottom: 20,
shadowColor: "#000",
shadowOpacity: 0.06,
shadowOffset: { width: 0, height: 4 },
shadowRadius: 12,
elevation: 3,
},
avatar: {
width: 80,
height: 80,
borderRadius: 40,
marginBottom: 14,
},
avatarPlaceholder: {
backgroundColor: "#1e40af",
alignItems: "center",
justifyContent: "center",
},
avatarInitial: {
color: "#fff",
fontSize: 32,
fontWeight: "700",
},
displayName: {
fontSize: 20,
fontWeight: "700",
color: "#111827",
marginBottom: 4,
},
email: { fontSize: 14, color: "#6b7280", marginBottom: 6 },
adminBadge: {
backgroundColor: "#dbeafe",
color: "#1e40af",
fontSize: 11,
fontWeight: "700",
paddingHorizontal: 10,
paddingVertical: 3,
borderRadius: 12,
overflow: "hidden",
},
section: {
backgroundColor: "#fff",
borderRadius: 12,
padding: 16,
marginBottom: 16,
shadowColor: "#000",
shadowOpacity: 0.04,
shadowOffset: { width: 0, height: 2 },
shadowRadius: 6,
elevation: 2,
},
sectionTitle: {
fontSize: 13,
fontWeight: "700",
color: "#6b7280",
textTransform: "uppercase",
letterSpacing: 0.5,
marginBottom: 12,
},
row: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingVertical: 8,
borderBottomWidth: 1,
borderBottomColor: "#f3f4f6",
},
rowLabel: { fontSize: 14, color: "#374151" },
rowValue: {
fontSize: 14,
color: "#6b7280",
maxWidth: "60%",
textAlign: "right",
},
signOutButton: {
backgroundColor: "#fee2e2",
borderRadius: 10,
paddingVertical: 14,
alignItems: "center",
minHeight: 48,
},
signOutText: {
color: "#dc2626",
fontWeight: "700",
fontSize: 15,
},
});
+258
View File
@@ -0,0 +1,258 @@
/**
* UploadScreen document upload via camera or file picker.
*
* Users can:
* 1. Take a photo of a document with the device camera.
* 2. Pick an existing file (PDF, image, Office document) from the Files app.
* 3. Receive files shared from other apps via the iOS Share Sheet / Android
* Share Intent (handled by the expo-sharing + deep-link integration).
*/
import * as DocumentPicker from "expo-document-picker";
import * as ImagePicker from "expo-image-picker";
import React, { useState } from "react";
import {
ActivityIndicator,
Alert,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { useAuth } from "../context/AuthContext";
import api from "../services/api";
interface UploadItem {
id: string;
filename: string;
status: "pending" | "uploading" | "done" | "error";
error?: string;
taskId?: string;
}
export default function UploadScreen() {
const { isAuthenticated } = useAuth();
const [uploads, setUploads] = useState<UploadItem[]>([]);
function updateItem(id: string, patch: Partial<UploadItem>) {
setUploads((prev) =>
prev.map((item) => (item.id === id ? { ...item, ...patch } : item))
);
}
async function uploadFile(uri: string, filename: string, mimeType?: string) {
const id = `${Date.now()}-${filename}`;
setUploads((prev) => [
{ id, filename, status: "uploading" },
...prev,
]);
try {
const resp = await api.uploadFile(uri, filename, mimeType);
updateItem(id, { status: "done", taskId: resp.task_id });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "Upload failed";
updateItem(id, { status: "error", error: msg });
}
}
async function handleCamera() {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== "granted") {
Alert.alert(
"Camera access required",
"Please grant camera access in Settings to capture documents."
);
return;
}
const result = await ImagePicker.launchCameraAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 0.9,
allowsEditing: false,
});
if (!result.canceled && result.assets.length > 0) {
const asset = result.assets[0];
const filename = `scan_${Date.now()}.jpg`;
await uploadFile(asset.uri, filename, "image/jpeg");
}
}
async function handleFilePicker() {
try {
const result = await DocumentPicker.getDocumentAsync({
type: "*/*",
multiple: true,
copyToCacheDirectory: true,
});
if (!result.canceled) {
for (const asset of result.assets) {
await uploadFile(asset.uri, asset.name, asset.mimeType ?? undefined);
}
}
} catch (err: unknown) {
Alert.alert("File picker error", err instanceof Error ? err.message : "Could not open file picker");
}
}
if (!isAuthenticated) {
return (
<View style={styles.center}>
<Text style={styles.emptyText}>Please sign in to upload documents.</Text>
</View>
);
}
return (
<View style={styles.container}>
{/* Action buttons */}
<View style={styles.actions}>
<Pressable
style={[styles.actionButton, styles.cameraButton]}
onPress={handleCamera}
accessibilityRole="button"
accessibilityLabel="Capture document with camera"
>
<Text style={styles.actionIcon}>📷</Text>
<Text style={styles.actionLabel}>Camera</Text>
</Pressable>
<Pressable
style={[styles.actionButton, styles.fileButton]}
onPress={handleFilePicker}
accessibilityRole="button"
accessibilityLabel="Pick file from device"
>
<Text style={styles.actionIcon}>📄</Text>
<Text style={styles.actionLabel}>File Picker</Text>
</Pressable>
</View>
{/* Upload list */}
<ScrollView style={styles.list} contentContainerStyle={styles.listContent}>
{uploads.length === 0 ? (
<View style={styles.emptyState}>
<Text style={styles.emptyEmoji}></Text>
<Text style={styles.emptyText}>
Tap Camera or File Picker to upload a document.
</Text>
<Text style={styles.emptyHint}>
You can also share files from other apps directly to DocuElevate.
</Text>
</View>
) : (
uploads.map((item) => (
<UploadRow key={item.id} item={item} />
))
)}
</ScrollView>
</View>
);
}
function UploadRow({ item }: { item: UploadItem }) {
const icons: Record<UploadItem["status"], string> = {
pending: "⏳",
uploading: "⬆️",
done: "✅",
error: "❌",
};
return (
<View style={rowStyles.row}>
<Text style={rowStyles.icon}>{icons[item.status]}</Text>
<View style={rowStyles.info}>
<Text style={rowStyles.filename} numberOfLines={1}>
{item.filename}
</Text>
{item.status === "uploading" && (
<ActivityIndicator size="small" color="#1e40af" />
)}
{item.status === "done" && (
<Text style={rowStyles.statusDone}>Queued for processing</Text>
)}
{item.status === "error" && (
<Text style={rowStyles.statusError}>{item.error}</Text>
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#f9fafb" },
actions: {
flexDirection: "row",
padding: 16,
gap: 12,
},
actionButton: {
flex: 1,
borderRadius: 12,
paddingVertical: 20,
alignItems: "center",
justifyContent: "center",
minHeight: 80,
},
cameraButton: { backgroundColor: "#1e40af" },
fileButton: { backgroundColor: "#059669" },
actionIcon: { fontSize: 28, marginBottom: 6 },
actionLabel: {
color: "#fff",
fontSize: 14,
fontWeight: "600",
},
list: { flex: 1 },
listContent: { padding: 16 },
emptyState: {
alignItems: "center",
paddingTop: 60,
},
emptyEmoji: { fontSize: 48, marginBottom: 12 },
emptyText: {
fontSize: 16,
color: "#374151",
textAlign: "center",
marginBottom: 8,
},
emptyHint: {
fontSize: 13,
color: "#6b7280",
textAlign: "center",
paddingHorizontal: 32,
},
center: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
});
const rowStyles = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#fff",
borderRadius: 10,
padding: 14,
marginBottom: 10,
shadowColor: "#000",
shadowOpacity: 0.04,
shadowOffset: { width: 0, height: 2 },
shadowRadius: 4,
elevation: 2,
},
icon: { fontSize: 22, marginRight: 12 },
info: { flex: 1 },
filename: {
fontSize: 14,
fontWeight: "600",
color: "#111827",
marginBottom: 4,
},
statusDone: { fontSize: 12, color: "#059669" },
statusError: { fontSize: 12, color: "#dc2626" },
});
+199
View File
@@ -0,0 +1,199 @@
/**
* DocuElevate API client for the mobile app.
*
* All requests authenticate via a Bearer token stored in the device's secure
* keychain (via expo-secure-store). The token is obtained once through the
* SSO flow and cached until the user explicitly logs out.
*/
import * as SecureStore from "expo-secure-store";
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
export const SECURE_STORE_API_TOKEN_KEY = "de_api_token";
export const SECURE_STORE_BASE_URL_KEY = "de_base_url";
export const SECURE_STORE_OWNER_ID_KEY = "de_owner_id";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface WhoAmIResponse {
owner_id: string;
display_name: string | null;
email: string | null;
avatar_url: string | null;
is_admin: boolean;
}
export interface GenerateTokenResponse {
token: string;
token_id: number;
name: string;
created_at: string;
}
export interface DeviceRegistration {
push_token: string;
device_name?: string;
platform: "ios" | "android" | "web";
}
export interface FileRecord {
id: number;
filename: string;
status: string;
created_at: string;
file_size: number | null;
content_type: string | null;
owner_id: string | null;
}
export interface UploadResponse {
task_id: string;
status: string;
message: string;
filename: string;
}
// ---------------------------------------------------------------------------
// Base API client
// ---------------------------------------------------------------------------
class DocuElevateAPI {
private baseUrl: string = "";
async init(baseUrl: string): Promise<void> {
this.baseUrl = baseUrl.replace(/\/$/, "");
await SecureStore.setItemAsync(SECURE_STORE_BASE_URL_KEY, this.baseUrl);
}
async loadFromStorage(): Promise<boolean> {
try {
const url = await SecureStore.getItemAsync(SECURE_STORE_BASE_URL_KEY);
if (url) {
this.baseUrl = url;
return true;
}
} catch {
// ignore
}
return false;
}
getBaseUrl(): string {
return this.baseUrl;
}
private async getToken(): Promise<string | null> {
try {
return await SecureStore.getItemAsync(SECURE_STORE_API_TOKEN_KEY);
} catch {
return null;
}
}
private async request<T>(
method: string,
path: string,
options?: { body?: unknown; formData?: FormData }
): Promise<T> {
const token = await this.getToken();
const headers: Record<string, string> = {};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
let body: BodyInit | undefined;
if (options?.formData) {
body = options.formData;
// Let fetch set multipart content-type with boundary automatically
} else if (options?.body !== undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(options.body);
}
const response = await fetch(`${this.baseUrl}${path}`, {
method,
headers,
body,
});
if (!response.ok) {
let detail = `HTTP ${response.status}`;
try {
const err = await response.json();
detail = err.detail || JSON.stringify(err);
} catch {
// ignore
}
throw new Error(detail);
}
if (response.status === 204) {
return undefined as unknown as T;
}
return response.json();
}
// -------------------------------------------------------------------------
// Auth
// -------------------------------------------------------------------------
/** Exchange the current session (cookie) for a long-lived API token. */
async generateMobileToken(deviceName: string): Promise<GenerateTokenResponse> {
return this.request<GenerateTokenResponse>("POST", "/api/mobile/generate-token", {
body: { device_name: deviceName },
});
}
/** Return profile information for the authenticated user. */
async whoAmI(): Promise<WhoAmIResponse> {
return this.request<WhoAmIResponse>("GET", "/api/mobile/whoami");
}
// -------------------------------------------------------------------------
// Push notifications
// -------------------------------------------------------------------------
/** Register a push notification device token. */
async registerDevice(data: DeviceRegistration): Promise<void> {
await this.request("POST", "/api/mobile/register-device", { body: data });
}
/** Deactivate a device registration. */
async deactivateDevice(deviceId: number): Promise<void> {
await this.request("DELETE", `/api/mobile/devices/${deviceId}`);
}
// -------------------------------------------------------------------------
// Files
// -------------------------------------------------------------------------
/** Upload a file for processing. */
async uploadFile(uri: string, filename: string, mimeType?: string): Promise<UploadResponse> {
const formData = new FormData();
formData.append("file", {
uri,
name: filename,
type: mimeType || "application/octet-stream",
} as unknown as Blob);
return this.request<UploadResponse>("POST", "/api/ui-upload", { formData });
}
/** List recently processed files. */
async listFiles(page = 1, pageSize = 20): Promise<FileRecord[]> {
return this.request<FileRecord[]>(
"GET",
`/api/files?page=${page}&page_size=${pageSize}`
);
}
}
export const api = new DocuElevateAPI();
export default api;
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["ESNext", "dom"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"jsx": "react-native",
"paths": {
"@/*": ["./src/*"]
},
"baseUrl": "."
},
"include": ["**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
}
+517
View File
@@ -0,0 +1,517 @@
"""Tests for the mobile API endpoints (app/api/mobile.py)."""
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base, get_db
from app.models import ApiToken, MobileDevice
# ---------------------------------------------------------------------------
# Test data
# ---------------------------------------------------------------------------
_OWNER = "mobile_user@example.com"
_OTHER_OWNER = "other@example.com"
_EXPO_TOKEN = "ExponentPushToken[test-token-abc123]"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture()
def mob_engine():
"""In-memory SQLite engine for mobile tests."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
yield engine
Base.metadata.drop_all(bind=engine)
@pytest.fixture()
def mob_session(mob_engine):
"""DB session scoped to one test."""
Session = sessionmaker(bind=mob_engine)
session = Session()
yield session
session.close()
def _make_client(mob_engine, owner_id: str = _OWNER) -> TestClient:
"""Return a TestClient with *owner_id* injected as the authenticated user."""
from app.api.mobile import _get_owner_id
from app.main import app
Session = sessionmaker(bind=mob_engine)
def _override_get_db():
session = Session()
try:
yield session
finally:
session.close()
def _override_owner():
return owner_id
app.dependency_overrides[get_db] = _override_get_db
app.dependency_overrides[_get_owner_id] = _override_owner
client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
return client
def _cleanup(app):
"""Remove dependency overrides after test."""
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Tests /mobile/generate-token
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGenerateMobileToken:
"""Tests for POST /api/mobile/generate-token."""
def test_generate_token_success(self, mob_engine):
"""Generating a mobile token returns a token string and metadata."""
from app.main import app
client = _make_client(mob_engine)
try:
resp = client.post(
"/api/mobile/generate-token",
json={"device_name": "John's iPhone"},
)
assert resp.status_code == 201
data = resp.json()
assert data["token"].startswith("de_")
assert data["token_id"] > 0
assert "Mobile App" in data["name"]
assert "John's iPhone" in data["name"]
assert "created_at" in data
finally:
_cleanup(app)
def test_generate_token_default_device_name(self, mob_engine):
"""A default device name is used if none is provided."""
from app.main import app
client = _make_client(mob_engine)
try:
resp = client.post("/api/mobile/generate-token", json={})
assert resp.status_code == 201
data = resp.json()
assert "Mobile App" in data["name"]
finally:
_cleanup(app)
def test_generate_token_persisted_in_db(self, mob_engine, mob_session):
"""The generated token is stored in the api_tokens table."""
from app.main import app
client = _make_client(mob_engine)
try:
resp = client.post(
"/api/mobile/generate-token",
json={"device_name": "Test Device"},
)
assert resp.status_code == 201
token_id = resp.json()["token_id"]
db_token = mob_session.get(ApiToken, token_id)
assert db_token is not None
assert db_token.owner_id == _OWNER
assert "Mobile App" in db_token.name
finally:
_cleanup(app)
def test_generate_token_unauthenticated(self, mob_engine):
"""Unauthenticated requests are rejected with 401."""
from app.api.mobile import _get_owner_id
from app.main import app
Session = sessionmaker(bind=mob_engine)
def _override_get_db():
session = Session()
try:
yield session
finally:
session.close()
def _raise_401():
from fastapi import HTTPException, status
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
app.dependency_overrides[get_db] = _override_get_db
app.dependency_overrides[_get_owner_id] = _raise_401
client = TestClient(app, base_url="http://localhost", raise_server_exceptions=False)
try:
resp = client.post("/api/mobile/generate-token", json={"device_name": "Test"})
assert resp.status_code == 401
finally:
_cleanup(app)
# ---------------------------------------------------------------------------
# Tests /mobile/register-device
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestRegisterDevice:
"""Tests for POST /api/mobile/register-device."""
def test_register_new_device(self, mob_engine, mob_session):
"""Registering a new device persists it in mobile_devices."""
from app.main import app
client = _make_client(mob_engine)
try:
resp = client.post(
"/api/mobile/register-device",
json={
"push_token": _EXPO_TOKEN,
"device_name": "Test iPhone",
"platform": "ios",
},
)
assert resp.status_code == 201
data = resp.json()
assert data["id"] > 0
assert data["platform"] == "ios"
assert data["is_active"] is True
assert "ExponentPushToken" in data["push_token_preview"]
device = mob_session.get(MobileDevice, data["id"])
assert device is not None
assert device.push_token == _EXPO_TOKEN
assert device.owner_id == _OWNER
finally:
_cleanup(app)
def test_register_same_token_is_idempotent(self, mob_engine, mob_session):
"""Re-registering the same token reactivates the existing record."""
from app.main import app
client = _make_client(mob_engine)
try:
resp1 = client.post(
"/api/mobile/register-device",
json={"push_token": _EXPO_TOKEN, "platform": "ios"},
)
assert resp1.status_code == 201
id1 = resp1.json()["id"]
resp2 = client.post(
"/api/mobile/register-device",
json={"push_token": _EXPO_TOKEN, "device_name": "Updated Name", "platform": "ios"},
)
assert resp2.status_code == 201
id2 = resp2.json()["id"]
assert id1 == id2 # Same record reused
devices = mob_session.query(MobileDevice).filter(MobileDevice.owner_id == _OWNER).all()
assert len(devices) == 1
finally:
_cleanup(app)
def test_register_invalid_platform(self, mob_engine):
"""An invalid platform value is rejected with 422."""
from app.main import app
client = _make_client(mob_engine)
try:
resp = client.post(
"/api/mobile/register-device",
json={"push_token": _EXPO_TOKEN, "platform": "windows"},
)
assert resp.status_code == 422
finally:
_cleanup(app)
def test_register_android_device(self, mob_engine):
"""Android devices can be registered."""
from app.main import app
client = _make_client(mob_engine)
try:
resp = client.post(
"/api/mobile/register-device",
json={
"push_token": "ExponentPushToken[android-token-xyz]",
"device_name": "Pixel 8",
"platform": "android",
},
)
assert resp.status_code == 201
assert resp.json()["platform"] == "android"
finally:
_cleanup(app)
# ---------------------------------------------------------------------------
# Tests /mobile/devices
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestListDevices:
"""Tests for GET /api/mobile/devices."""
def test_list_devices_empty(self, mob_engine):
"""An empty list is returned when no devices are registered."""
from app.main import app
client = _make_client(mob_engine)
try:
resp = client.get("/api/mobile/devices")
assert resp.status_code == 200
assert resp.json() == []
finally:
_cleanup(app)
def test_list_devices_returns_own_devices_only(self, mob_engine, mob_session):
"""Only the current user's devices are returned."""
from app.main import app
# Add devices for two different owners directly
mob_session.add(
MobileDevice(
owner_id=_OWNER,
push_token="ExponentPushToken[owner-token-12345]",
platform="ios",
)
)
mob_session.add(
MobileDevice(
owner_id=_OTHER_OWNER,
push_token="ExponentPushToken[other-token-67890]",
platform="android",
)
)
mob_session.commit()
client = _make_client(mob_engine)
try:
resp = client.get("/api/mobile/devices")
assert resp.status_code == 200
devices = resp.json()
assert len(devices) == 1
# The push_token_preview is the first 20 chars + "…"
assert devices[0]["push_token_preview"].startswith("ExponentPushToken[ow")
finally:
_cleanup(app)
# ---------------------------------------------------------------------------
# Tests DELETE /mobile/devices/{device_id}
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDeactivateDevice:
"""Tests for DELETE /api/mobile/devices/{device_id}."""
def test_deactivate_own_device(self, mob_engine, mob_session):
"""Deactivating a device sets is_active to False."""
from app.main import app
device = MobileDevice(
owner_id=_OWNER,
push_token=_EXPO_TOKEN,
platform="ios",
is_active=True,
)
mob_session.add(device)
mob_session.commit()
mob_session.refresh(device)
device_id = device.id
client = _make_client(mob_engine)
try:
resp = client.delete(f"/api/mobile/devices/{device_id}")
assert resp.status_code == 204
mob_session.expire_all()
updated = mob_session.get(MobileDevice, device_id)
assert updated is not None
assert updated.is_active is False
finally:
_cleanup(app)
def test_deactivate_other_users_device_returns_404(self, mob_engine, mob_session):
"""Attempting to deactivate another user's device returns 404."""
from app.main import app
device = MobileDevice(
owner_id=_OTHER_OWNER,
push_token="ExponentPushToken[other-token]",
platform="ios",
is_active=True,
)
mob_session.add(device)
mob_session.commit()
mob_session.refresh(device)
device_id = device.id
client = _make_client(mob_engine)
try:
resp = client.delete(f"/api/mobile/devices/{device_id}")
assert resp.status_code == 404
finally:
_cleanup(app)
def test_deactivate_nonexistent_device_returns_404(self, mob_engine):
"""Deactivating a device that does not exist returns 404."""
from app.main import app
client = _make_client(mob_engine)
try:
resp = client.delete("/api/mobile/devices/99999")
assert resp.status_code == 404
finally:
_cleanup(app)
# ---------------------------------------------------------------------------
# Tests /mobile/whoami
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestWhoAmI:
"""Tests for GET /api/mobile/whoami."""
def test_whoami_with_no_profile(self, mob_engine):
"""Returns owner_id and inferred email even when no UserProfile exists."""
from app.main import app
client = _make_client(mob_engine)
try:
resp = client.get("/api/mobile/whoami")
assert resp.status_code == 200
data = resp.json()
assert data["owner_id"] == _OWNER
assert data["display_name"] is None
# _OWNER contains "@" so email is inferred from owner_id
assert data["email"] == _OWNER
assert data["avatar_url"] is not None # Gravatar URL from email
assert data["is_admin"] is False
finally:
_cleanup(app)
def test_whoami_with_profile(self, mob_engine, mob_session):
"""Returns full profile data when a UserProfile record exists."""
from app.main import app
from app.models import UserProfile
profile = UserProfile(
user_id=_OWNER,
display_name="Alice Test",
)
mob_session.add(profile)
mob_session.commit()
client = _make_client(mob_engine)
try:
resp = client.get("/api/mobile/whoami")
assert resp.status_code == 200
data = resp.json()
assert data["owner_id"] == _OWNER
assert data["display_name"] == "Alice Test"
# owner_id contains "@" so email is inferred from it
assert data["email"] == _OWNER
assert data["avatar_url"] is not None # Gravatar URL
assert data["is_admin"] is False
finally:
_cleanup(app)
# ---------------------------------------------------------------------------
# Tests push notification utility
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestPushNotificationUtility:
"""Tests for app/utils/push_notification.py."""
def test_send_expo_push_empty_tokens(self):
"""send_expo_push_notification with no tokens returns empty list."""
from app.utils.push_notification import send_expo_push_notification
result = send_expo_push_notification([], "Title", "Body")
assert result == []
def test_send_expo_push_calls_expo_api(self):
"""send_expo_push_notification POSTs to the Expo push API."""
from app.utils.push_notification import send_expo_push_notification
mock_response = MagicMock()
mock_response.json.return_value = {"data": [{"status": "ok"}]}
mock_response.raise_for_status = MagicMock()
with patch("app.utils.push_notification.httpx.post", return_value=mock_response) as mock_post:
result = send_expo_push_notification(
tokens=["ExponentPushToken[abc]"],
title="Test",
body="Message",
)
mock_post.assert_called_once()
call_kwargs = mock_post.call_args
assert "exp.host" in call_kwargs[0][0]
payload = call_kwargs[1]["json"]
assert len(payload) == 1
assert payload[0]["to"] == "ExponentPushToken[abc]"
assert payload[0]["title"] == "Test"
def test_send_push_to_owner_no_devices(self, mob_engine):
"""send_push_to_owner silently does nothing when no devices are registered."""
from app.utils.push_notification import send_push_to_owner
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.all.return_value = []
mock_session.close = MagicMock()
with patch("app.utils.push_notification.SessionLocal", return_value=mock_session):
with patch("app.utils.push_notification.send_expo_push_notification") as mock_send:
send_push_to_owner("user@example.com", "Title", "Body")
mock_send.assert_not_called()
def test_send_push_to_owner_with_devices(self):
"""send_push_to_owner calls send_expo_push_notification with device tokens."""
from app.utils.push_notification import send_push_to_owner
fake_device = MagicMock()
fake_device.push_token = "ExponentPushToken[device1]"
fake_device.is_active = True
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.all.return_value = [fake_device]
mock_session.close = MagicMock()
with patch("app.utils.push_notification.SessionLocal", return_value=mock_session):
with patch("app.utils.push_notification.send_expo_push_notification") as mock_send:
mock_send.return_value = [{"status": "ok"}]
send_push_to_owner("user@example.com", "Processed!", "Your doc is ready.")
mock_send.assert_called_once()
call_kwargs = mock_send.call_args[1]
assert "ExponentPushToken[device1]" in call_kwargs["tokens"]