367 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|