feat: add method to retrieve latest call log and update history, enhancing call tracking
All checks were successful
/ build (push) Successful in 11m33s
/ build-stealth (push) Successful in 11m33s
/ mirror (push) Successful in 5s

This commit is contained in:
AlexisDanlos 2025-07-07 12:08:48 +02:00
parent 58a9919dc7
commit 22b65fd9fe
3 changed files with 203 additions and 120 deletions

View File

@ -230,16 +230,25 @@ class MainActivity : FlutterActivity() {
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALLLOG_CHANNEL) MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALLLOG_CHANNEL)
.setMethodCallHandler { call, result -> .setMethodCallHandler { call, result ->
if (call.method == "getCallLogs") { when (call.method) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALL_LOG) == PackageManager.PERMISSION_GRANTED) { "getCallLogs" -> {
val callLogs = getCallLogs() if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALL_LOG) == PackageManager.PERMISSION_GRANTED) {
result.success(callLogs) val callLogs = getCallLogs()
} else { result.success(callLogs)
requestPermissions(arrayOf(Manifest.permission.READ_CALL_LOG), REQUEST_CODE_CALL_LOG_PERMISSION) } else {
result.error("PERMISSION_DENIED", "Call log permission not granted", null) requestPermissions(arrayOf(Manifest.permission.READ_CALL_LOG), REQUEST_CODE_CALL_LOG_PERMISSION)
result.error("PERMISSION_DENIED", "Call log permission not granted", null)
}
} }
} else { "getLatestCallLog" -> {
result.notImplemented() if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALL_LOG) == PackageManager.PERMISSION_GRANTED) {
val latestCallLog = getLatestCallLog()
result.success(latestCallLog)
} else {
result.error("PERMISSION_DENIED", "Call log permission not granted", null)
}
}
else -> result.notImplemented()
} }
} }
} }
@ -354,6 +363,50 @@ class MainActivity : FlutterActivity() {
return logsList return logsList
} }
private fun getLatestCallLog(): Map<String, Any?>? {
val cursor: Cursor? = contentResolver.query(
CallLog.Calls.CONTENT_URI,
null,
null,
null,
CallLog.Calls.DATE + " DESC"
)
cursor?.use {
if (it.moveToNext()) {
val number = it.getString(it.getColumnIndexOrThrow(CallLog.Calls.NUMBER))
val type = it.getInt(it.getColumnIndexOrThrow(CallLog.Calls.TYPE))
val date = it.getLong(it.getColumnIndexOrThrow(CallLog.Calls.DATE))
val duration = it.getLong(it.getColumnIndexOrThrow(CallLog.Calls.DURATION))
// Extract subscription ID (SIM card info) if available
var subscriptionId: Int? = null
var simName: String? = null
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
val subIdColumnIndex = it.getColumnIndex("subscription_id")
if (subIdColumnIndex >= 0) {
subscriptionId = it.getInt(subIdColumnIndex)
// Get the actual SIM name
simName = getSimNameFromSubscriptionId(subscriptionId)
}
}
} catch (e: Exception) {
Log.w(TAG, "Failed to get subscription_id: ${e.message}")
}
return mapOf(
"number" to number,
"type" to type,
"date" to date,
"duration" to duration,
"subscription_id" to subscriptionId,
"sim_name" to simName
)
}
}
return null
}
private fun getSimNameFromSubscriptionId(subscriptionId: Int?): String? { private fun getSimNameFromSubscriptionId(subscriptionId: Int?): String? {
if (subscriptionId == null) return null if (subscriptionId == null) return null

View File

@ -5,6 +5,8 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../../presentation/features/call/call_page.dart'; import '../../presentation/features/call/call_page.dart';
import '../../presentation/features/call/incoming_call_page.dart'; // Import the new page import '../../presentation/features/call/incoming_call_page.dart'; // Import the new page
import 'contact_service.dart'; import 'contact_service.dart';
// Import for history update callback
import '../../presentation/features/history/history_page.dart';
class CallService { class CallService {
static const MethodChannel _channel = MethodChannel('call_service'); static const MethodChannel _channel = MethodChannel('call_service');
@ -114,6 +116,13 @@ class CallService {
if (wasPhoneLocked) { if (wasPhoneLocked) {
await _channel.invokeMethod("callEndedFromFlutter"); await _channel.invokeMethod("callEndedFromFlutter");
} }
// Notify history page to add the latest call
// Add a small delay to ensure call log is updated by the system
Timer(const Duration(milliseconds: 500), () {
HistoryPageState.addNewCallToHistory();
});
_activeCallNumber = null; _activeCallNumber = null;
// Handle pending SIM switch after call is disconnected // Handle pending SIM switch after call is disconnected
_handlePendingSimSwitch(); _handlePendingSimSwitch();
@ -179,6 +188,13 @@ class CallService {
if (wasPhoneLocked) { if (wasPhoneLocked) {
await _channel.invokeMethod("callEndedFromFlutter"); await _channel.invokeMethod("callEndedFromFlutter");
} }
// Notify history page to add the latest call
// Add a small delay to ensure call log is updated by the system
Timer(const Duration(milliseconds: 500), () {
HistoryPageState.addNewCallToHistory();
});
currentPhoneNumber = null; currentPhoneNumber = null;
currentDisplayName = null; currentDisplayName = null;
currentThumbnail = null; currentThumbnail = null;

View File

@ -40,9 +40,13 @@ class HistoryPage extends StatefulWidget {
class HistoryPageState extends State<HistoryPage> class HistoryPageState extends State<HistoryPage>
with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin, WidgetsBindingObserver { with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin, WidgetsBindingObserver {
List<History> histories = []; // Static histories list shared across all instances
static List<History> _globalHistories = [];
// Getter to access the global histories list
List<History> get histories => _globalHistories;
bool _isInitialLoad = true; bool _isInitialLoad = true;
bool _isBackgroundLoading = false;
int? _expandedIndex; int? _expandedIndex;
final ObfuscateService _obfuscateService = ObfuscateService(); final ObfuscateService _obfuscateService = ObfuscateService();
final CallService _callService = CallService(); final CallService _callService = CallService();
@ -50,6 +54,12 @@ class HistoryPageState extends State<HistoryPage>
// Create a MethodChannel instance. // Create a MethodChannel instance.
static const MethodChannel _channel = MethodChannel('com.example.calllog'); static const MethodChannel _channel = MethodChannel('com.example.calllog');
// Static reference to the current instance for call-end notifications
static HistoryPageState? _currentInstance;
// Global flag to track if history has been loaded once across all instances
static bool _hasLoadedInitialHistory = false;
@override @override
bool get wantKeepAlive => true; // Preserve state when switching pages bool get wantKeepAlive => true; // Preserve state when switching pages
@ -58,31 +68,50 @@ class HistoryPageState extends State<HistoryPage>
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
// Load initial data _currentInstance = this; // Register this instance
_buildHistories();
// Only load initial data if it hasn't been loaded before
if (!_hasLoadedInitialHistory) {
_buildHistories();
} else {
// If history was already loaded, just mark this instance as not doing initial load
_isInitialLoad = false;
}
} }
/// Public method to trigger reload when page becomes visible /// Public method to trigger reload when page becomes visible
void triggerReload() { void triggerReload() {
if (!_isInitialLoad && !_isBackgroundLoading) { // Disabled automatic reloading - only load once and add new entries via addNewCallToHistory
_debouncedReload(); print("HistoryPage: triggerReload called but disabled to prevent full reload");
}
} }
@override @override
void dispose() { void dispose() {
_debounceTimer?.cancel(); _debounceTimer?.cancel();
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
if (_currentInstance == this) {
_currentInstance = null; // Unregister this instance
}
super.dispose(); super.dispose();
} }
/// Static method to add a new call to the history list
static void addNewCallToHistory() {
_currentInstance?._addLatestCallToHistory();
}
/// Notify all instances to refresh UI when history changes
static void _notifyHistoryChanged() {
_currentInstance?.setState(() {
// Trigger UI rebuild for the current instance
});
}
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
super.didChangeAppLifecycleState(state); super.didChangeAppLifecycleState(state);
// Reload data when app comes back to foreground // Disabled automatic reloading when app comes to foreground
if (state == AppLifecycleState.resumed && !_isInitialLoad && !_isBackgroundLoading) { print("HistoryPage: didChangeAppLifecycleState called but disabled to prevent full reload");
_debouncedReload();
}
} }
@override @override
@ -92,16 +121,6 @@ class HistoryPageState extends State<HistoryPage>
// We'll use a different approach with RouteAware or manual detection // We'll use a different approach with RouteAware or manual detection
} }
/// Debounced reload to prevent multiple rapid reloads
void _debouncedReload() {
_debounceTimer?.cancel();
_debounceTimer = Timer(const Duration(milliseconds: 300), () {
if (mounted && !_isBackgroundLoading) {
_reloadHistoriesInBackground();
}
});
}
Future<void> _refreshContacts() async { Future<void> _refreshContacts() async {
final contactState = ContactState.of(context); final contactState = ContactState.of(context);
try { try {
@ -290,37 +309,35 @@ class HistoryPageState extends State<HistoryPage>
if (mounted) { if (mounted) {
setState(() { setState(() {
histories = callHistories; _globalHistories = callHistories;
_isInitialLoad = false; _isInitialLoad = false;
_hasLoadedInitialHistory = true; // Mark that history has been loaded once
}); });
// Notify other instances about the initial load
_notifyHistoryChanged();
} }
} }
/// Reload histories in the background without showing loading indicators /// Add the latest call log entry to the history list
Future<void> _reloadHistoriesInBackground() async { Future<void> _addLatestCallToHistory() async {
if (_isBackgroundLoading) return;
_isBackgroundLoading = true;
try { try {
// Request permission. // Get the latest call log entry
bool hasPermission = await _requestCallLogPermission(); final dynamic rawEntry = await _channel.invokeMethod('getLatestCallLog');
if (!hasPermission) {
_isBackgroundLoading = false; if (rawEntry == null) {
print("No latest call log entry found");
return; return;
} }
// Retrieve call logs from native code. // Convert to proper type - handle the method channel result properly
List<dynamic> nativeLogs = []; final Map<String, dynamic> latestEntry = Map<String, dynamic>.from(
try { (rawEntry as Map<Object?, Object?>).cast<String, dynamic>()
nativeLogs = await _channel.invokeMethod('getCallLogs'); );
} on PlatformException catch (e) {
print("Error fetching call logs: ${e.message}");
_isBackgroundLoading = false;
return;
}
// Ensure contacts are loaded. final String number = latestEntry['number'] ?? '';
if (number.isEmpty) return;
// Ensure contacts are loaded
final contactState = ContactState.of(context); final contactState = ContactState.of(context);
if (contactState.loading) { if (contactState.loading) {
await Future.doWhile(() async { await Future.doWhile(() async {
@ -330,83 +347,80 @@ class HistoryPageState extends State<HistoryPage>
} }
List<Contact> contacts = contactState.contacts; List<Contact> contacts = contactState.contacts;
List<History> callHistories = []; // Convert timestamp to DateTime
// Process each log entry with intermittent yields to avoid freezing. DateTime callDate = DateTime.fromMillisecondsSinceEpoch(latestEntry['date'] ?? 0);
for (int i = 0; i < nativeLogs.length; i++) {
final entry = nativeLogs[i];
final String number = entry['number'] ?? '';
if (number.isEmpty) continue;
// Convert timestamp to DateTime. int typeInt = latestEntry['type'] ?? 0;
DateTime callDate = int duration = latestEntry['duration'] ?? 0;
DateTime.fromMillisecondsSinceEpoch(entry['date'] ?? 0); String callType;
String callStatus;
int typeInt = entry['type'] ?? 0; // Map integer values to call type/status
int duration = entry['duration'] ?? 0; switch (typeInt) {
String callType; case 1:
String callStatus; callType = "incoming";
callStatus = (duration == 0) ? "missed" : "answered";
// Map integer values to call type/status. break;
// Commonly: 1 = incoming, 2 = outgoing, 3 = missed. case 2:
switch (typeInt) { callType = "outgoing";
case 1: callStatus = "answered";
callType = "incoming"; break;
callStatus = (duration == 0) ? "missed" : "answered"; case 3:
break; callType = "incoming";
case 2: callStatus = "missed";
callType = "outgoing"; break;
callStatus = "answered"; default:
break; callType = "unknown";
case 3: callStatus = "unknown";
callType = "incoming";
callStatus = "missed";
break;
default:
callType = "unknown";
callStatus = "unknown";
}
// Try to find a matching contact.
Contact? matchedContact = findContactForNumber(number, contacts);
if (matchedContact == null) {
// Create a dummy contact if not found.
matchedContact = Contact(
id: "dummy-$number",
displayName: number,
phones: [Phone(number)],
);
}
// Extract SIM information if available
String? simName;
if (entry.containsKey('sim_name') && entry['sim_name'] != null) {
simName = entry['sim_name'] as String;
print("DEBUG: Found sim_name: $simName for number: $number"); // Debug print
} else if (entry.containsKey('subscription_id')) {
final subId = entry['subscription_id'];
print("DEBUG: Found subscription_id: $subId for number: $number, but no sim_name"); // Debug print
simName = _getSimNameFromSubscriptionId(subId);
print("DEBUG: Mapped to SIM name: $simName"); // Debug print
} else {
print("DEBUG: No SIM info found for number: $number"); // Debug print
}
callHistories
.add(History(matchedContact, callDate, callType, callStatus, 1, simName));
// Yield every 10 iterations to avoid blocking the UI.
if (i % 10 == 0) await Future.delayed(Duration(milliseconds: 1));
} }
// Sort histories by most recent. // Try to find a matching contact
callHistories.sort((a, b) => b.date.compareTo(a.date)); Contact? matchedContact = findContactForNumber(number, contacts);
if (matchedContact == null) {
// Create a dummy contact if not found
matchedContact = Contact(
id: "dummy-$number",
displayName: number,
phones: [Phone(number)],
);
}
if (mounted) { // Extract SIM information if available
String? simName;
if (latestEntry.containsKey('sim_name') && latestEntry['sim_name'] != null) {
simName = latestEntry['sim_name'] as String;
print("DEBUG: Found sim_name: $simName for number: $number");
} else if (latestEntry.containsKey('subscription_id')) {
final subId = latestEntry['subscription_id'];
print("DEBUG: Found subscription_id: $subId for number: $number, but no sim_name");
simName = _getSimNameFromSubscriptionId(subId);
print("DEBUG: Mapped to SIM name: $simName");
} else {
print("DEBUG: No SIM info found for number: $number");
}
// Create new history entry
History newHistory = History(matchedContact, callDate, callType, callStatus, 1, simName);
// Check if this call is already in the list (avoid duplicates)
bool alreadyExists = _globalHistories.any((history) =>
history.contact.phones.isNotEmpty &&
sanitizeNumber(history.contact.phones.first.number) == sanitizeNumber(number) &&
history.date.difference(callDate).abs().inSeconds < 5); // Within 5 seconds
if (!alreadyExists && mounted) {
setState(() { setState(() {
histories = callHistories; // Insert at the beginning since it's the most recent
_globalHistories.insert(0, newHistory);
}); });
// Notify other instances about the change
_notifyHistoryChanged();
print("Added new call to history: $number at $callDate");
} else {
print("Call already exists in history or widget unmounted");
} }
} finally { } catch (e) {
_isBackgroundLoading = false; print("Error adding latest call to history: $e");
} }
} }