Yaltopia-Tickets-App/app/sms-scan.tsx
2026-03-11 22:48:53 +03:00

367 lines
11 KiB
TypeScript

import React, { useState } from "react";
import {
View,
ScrollView,
Pressable,
PermissionsAndroid,
Platform,
ActivityIndicator,
} from "react-native";
import { Text } from "@/components/ui/text";
import { Card } from "@/components/ui/card";
import { ScreenWrapper } from "@/components/ScreenWrapper";
import { toast } from "@/lib/toast-store";
import { ArrowLeft, MessageSquare, RefreshCw } from "@/lib/icons";
import { useColorScheme } from "nativewind";
import { useSirouRouter } from "@sirou/react-native";
import { AppRoutes } from "@/lib/routes";
// Installed via: npm install react-native-get-sms-android --legacy-peer-deps
// Android only — iOS does not permit reading SMS
let SmsAndroid: any = null;
try {
const smsModule = require("react-native-get-sms-android");
SmsAndroid = smsModule.default || smsModule;
} catch (e) {
console.log("[SMS] Module require failed:", e);
}
// Keywords to match Ethiopian banking SMS messages
const BANK_KEYWORDS = ["CBE", "DashenBank", "Dashen", "127", "telebirr"];
interface SmsMessage {
_id: string;
address: string;
body: string;
date: number;
date_sent: number;
}
interface ParsedPayment {
smsId: string;
bank: string;
amount: string;
ref: string;
date: number;
body: string;
sender: string;
}
export default function SmsScanScreen() {
const nav = useSirouRouter<AppRoutes>();
const { colorScheme } = useColorScheme();
const isDark = colorScheme === "dark";
const [messages, setMessages] = useState<SmsMessage[]>([]);
const [loading, setLoading] = useState(false);
const [scanned, setScanned] = useState(false);
const scanSms = async () => {
if (Platform.OS !== "android") {
toast.error("Android Only", "SMS reading is only supported on Android.");
return;
}
if (!SmsAndroid) {
toast.error(
"Native Module Error",
"SMS scanning requires a Development Build. Expo Go does not support this package.",
);
return;
}
setLoading(true);
try {
// Request SMS permission
const granted = await PermissionsAndroid.request(
PermissionsAndroid.PERMISSIONS.READ_SMS,
{
title: "SMS Access Required",
message:
"Yaltopia needs access to read your banking SMS messages to match payments.",
buttonPositive: "Allow",
buttonNegative: "Deny",
},
);
if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
toast.error("Permission Denied", "SMS access was not granted.");
return;
}
// Only look at messages from the past 20 minutes
const fiveMinutesAgo = Date.now() - 20 * 60 * 1000;
const filter = {
box: "inbox",
minDate: fiveMinutesAgo,
maxCount: 50,
};
SmsAndroid.list(
JSON.stringify(filter),
(fail: string) => {
console.error("[SMS] Failed to read:", fail);
toast.error("Read Failed", "Could not read SMS messages.");
setLoading(false);
},
(count: number, smsList: string) => {
const allMessages: SmsMessage[] = JSON.parse(smsList);
// Filter for banking messages only
const bankMessages = allMessages.filter((sms) => {
const body = sms.body?.toUpperCase() || "";
const address = sms.address?.toUpperCase() || "";
return BANK_KEYWORDS.some(
(kw) =>
body.includes(kw.toUpperCase()) ||
address.includes(kw.toUpperCase()),
);
});
setMessages(bankMessages);
setScanned(true);
setLoading(false);
if (bankMessages.length === 0) {
toast.info(
"No Matches",
"No banking SMS found in the last 5 minutes.",
);
} else {
toast.success(
"Found!",
`${bankMessages.length} banking message(s) detected.`,
);
}
},
);
} catch (err: any) {
console.error("[SMS] Error:", err);
toast.error("Error", err.message);
setLoading(false);
}
};
const formatTime = (timestamp: number) => {
const date = new Date(timestamp);
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
};
const parseMessage = (sms: SmsMessage): ParsedPayment | null => {
const body = sms.body;
const addr = sms.address.toUpperCase();
const text = (body + addr).toUpperCase();
let bank = "Unknown";
let amount = "";
let ref = "";
// CBE Patterns
if (text.includes("CBE") || addr === "CBE") {
bank = "CBE";
// Pattern: "ETB 1,234.56" or "ETB 1,234"
const amtMatch = body.match(/ETB\s*([\d,.]+)/i);
if (amtMatch) amount = amtMatch[1];
// Pattern: "Ref: 123456789"
const refMatch = body.match(/Ref:?\s*(\w+)/i);
if (refMatch) ref = refMatch[1];
}
// Telebirr Patterns
else if (text.includes("TELEBIRR") || addr === "TELEBIRR") {
bank = "Telebirr";
// Pattern: "Birr 1,234.56"
const amtMatch = body.match(/Birr\s*([\d,.]+)/i);
if (amtMatch) amount = amtMatch[1];
// Pattern: "Trans ID: 12345678"
const refMatch = body.match(/Trans ID:?\s*(\w+)/i);
if (refMatch) ref = refMatch[1];
}
// Dashen Patterns
else if (text.includes("DASHEN") || addr === "DASHEN") {
bank = "Dashen";
// Pattern: "ETB 1,234.56"
const amtMatch = body.match(/ETB\s*([\d,.]+)/i);
if (amtMatch) amount = amtMatch[1];
// Pattern: "Reference No: 12345678"
const refMatch = body.match(/(?:Ref(?:erence)?(?:\s*No)?):?\s*(\w+)/i);
if (refMatch) ref = refMatch[1];
}
if (bank === "Unknown") return null;
return {
smsId: sms._id,
bank,
amount,
ref,
date: sms.date,
body: sms.body,
sender: sms.address,
};
};
const getBankColor = (bank: string) => {
switch (bank) {
case "CBE":
return "#16a34a";
case "Telebirr":
return "#7c3aed";
case "Dashen":
return "#1d4ed8";
default:
return "#ea580c";
}
};
return (
<ScreenWrapper className="bg-background">
{/* Header */}
<View className="px-6 pt-4 flex-row justify-between items-center">
<Pressable
onPress={() => nav.back()}
className="h-10 w-10 rounded-[10px] bg-card items-center justify-center border border-border"
>
<ArrowLeft color={isDark ? "#fff" : "#0f172a"} size={20} />
</Pressable>
<Text variant="h4" className="text-foreground font-semibold">
Scan SMS
</Text>
<View className="w-10" /> {/* Spacer */}
</View>
<View className="px-5 pt-6 pb-4">
<Text variant="h3" className="text-foreground font-bold">
Scan SMS
</Text>
<Text variant="muted" className="mt-1">
Finds banking messages from the last 5 minutes
</Text>
</View>
{/* Scan Button */}
<View className="px-5 mb-4">
<Pressable
onPress={scanSms}
disabled={loading}
className="h-12 rounded-[10px] bg-primary items-center justify-center flex-row gap-2"
>
{loading ? (
<ActivityIndicator color="white" />
) : (
<>
<RefreshCw color="white" size={16} />
<Text className="text-white font-bold uppercase tracking-widest text-xs">
{scanned ? "Scan Again" : "Scan Now"}
</Text>
</>
)}
</Pressable>
</View>
<ScrollView
className="flex-1 px-5"
contentContainerStyle={{ paddingBottom: 150 }}
showsVerticalScrollIndicator={false}
>
{!scanned && !loading && (
<View className="flex-1 items-center justify-center py-20 gap-4">
<View className="bg-primary/10 p-6 rounded-[24px]">
<MessageSquare
size={40}
className="text-primary"
color="#ea580c"
strokeWidth={1.5}
/>
</View>
<Text variant="muted" className="text-center px-10">
Tap "Scan Now" to search for CBE, Dashen Bank, and Telebirr
messages from the last 5 minutes.
</Text>
</View>
)}
{scanned && messages.length === 0 && (
<View className="flex-1 items-center justify-center py-20 gap-4">
<Text variant="muted" className="text-center">
No banking messages found in the last 5 minutes.
</Text>
</View>
)}
<View className="gap-4">
{messages.map((sms) => {
const parsed = parseMessage(sms);
if (!parsed) return null;
const bankColor = getBankColor(parsed.bank);
return (
<Card
key={sms._id}
className="rounded-[16px] bg-card p-4 border border-border/40"
>
<View className="flex-row items-center justify-between mb-3">
<View
className="px-3 py-1 rounded-full"
style={{ backgroundColor: bankColor + "15" }}
>
<Text
className="text-xs font-bold uppercase tracking-wider"
style={{ color: bankColor }}
>
{parsed.bank}
</Text>
</View>
<Text variant="muted" className="text-xs">
{formatTime(sms.date)}
</Text>
</View>
{/* Extracted Data */}
<View className="flex-row gap-4 mb-3">
{parsed.amount ? (
<View className="flex-1 bg-muted/30 p-2 rounded-[8px]">
<Text
variant="muted"
className="text-[10px] uppercase font-bold"
>
Amount
</Text>
<Text className="text-foreground font-bold text-sm">
ETB {parsed.amount}
</Text>
</View>
) : null}
{parsed.ref ? (
<View className="flex-1 bg-muted/30 p-2 rounded-[8px]">
<Text
variant="muted"
className="text-[10px] uppercase font-bold"
>
Reference
</Text>
<Text
className="text-foreground font-bold text-sm"
numberOfLines={1}
>
{parsed.ref}
</Text>
</View>
) : null}
</View>
<Text className="text-foreground/70 text-xs leading-relaxed italic">
"{sms.body}"
</Text>
</Card>
);
})}
</View>
</ScrollView>
</ScreenWrapper>
);
}