From 3a7c9718dde7bf186622e3315b554d2dcf2083d7 Mon Sep 17 00:00:00 2001 From: Florian Griffon Date: Tue, 4 Mar 2025 13:05:42 +0000 Subject: [PATCH 01/13] fix: call correctly in history page (#41) Reviewed-on: https://git.gmoker.com/icing/monorepo/pulls/41 Co-authored-by: Florian Griffon Co-committed-by: Florian Griffon --- dialer/lib/features/history/history_page.dart | 112 ++++++++++-------- 1 file changed, 63 insertions(+), 49 deletions(-) diff --git a/dialer/lib/features/history/history_page.dart b/dialer/lib/features/history/history_page.dart index 1108a2e..117d1e8 100644 --- a/dialer/lib/features/history/history_page.dart +++ b/dialer/lib/features/history/history_page.dart @@ -11,6 +11,7 @@ import 'package:dialer/features/contacts/contact_state.dart'; import 'package:dialer/widgets/username_color_generator.dart'; import '../../services/block_service.dart'; import '../contacts/widgets/contact_modal.dart'; +import '../../services/call_service.dart'; class History { final Contact contact; @@ -20,12 +21,12 @@ class History { final int attempts; History( - this.contact, - this.date, - this.callType, - this.callStatus, - this.attempts, - ); + this.contact, + this.date, + this.callType, + this.callStatus, + this.attempts, + ); } class HistoryPage extends StatefulWidget { @@ -41,6 +42,7 @@ class _HistoryPageState extends State bool loading = true; int? _expandedIndex; final ObfuscateService _obfuscateService = ObfuscateService(); + final CallService _callService = CallService(); // Create a MethodChannel instance. static const MethodChannel _channel = MethodChannel('com.example.calllog'); @@ -83,8 +85,8 @@ class _HistoryPageState extends State } } catch (e) { print("Error updating favorite status: $e"); - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text('Failed to update favorite status'))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to update favorite status'))); } } @@ -155,7 +157,7 @@ class _HistoryPageState extends State // Convert timestamp to DateTime. DateTime callDate = - DateTime.fromMillisecondsSinceEpoch(entry['date'] ?? 0); + DateTime.fromMillisecondsSinceEpoch(entry['date'] ?? 0); int typeInt = entry['type'] ?? 0; int duration = entry['duration'] ?? 0; @@ -193,7 +195,8 @@ class _HistoryPageState extends State ); } - callHistories.add(History(matchedContact, callDate, callType, callStatus, 1)); + callHistories + .add(History(matchedContact, callDate, callType, callStatus, 1)); } // Sort histories by most recent. @@ -218,7 +221,7 @@ class _HistoryPageState extends State for (var history in historyList) { final callDate = - DateTime(history.date.year, history.date.month, history.date.day); + DateTime(history.date.year, history.date.month, history.date.day); if (callDate == today) { todayHistories.add(history); } else if (callDate == yesterday) { @@ -291,7 +294,7 @@ class _HistoryPageState extends State } List missedCalls = - histories.where((h) => h.callStatus == 'missed').toList(); + histories.where((h) => h.callStatus == 'missed').toList(); final allItems = _buildGroupedList(histories); final missedItems = _buildGroupedList(missedCalls); @@ -360,7 +363,8 @@ class _HistoryPageState extends State onEdit: () async { if (await FlutterContacts.requestPermission()) { final updatedContact = - await FlutterContacts.openExternalEdit(contact.id); + await FlutterContacts.openExternalEdit( + contact.id); if (updatedContact != null) { await _refreshContacts(); Navigator.of(context).pop(); @@ -415,18 +419,11 @@ class _HistoryPageState extends State icon: const Icon(Icons.phone, color: Colors.green), onPressed: () async { if (contact.phones.isNotEmpty) { - final Uri callUri = - Uri(scheme: 'tel', path: contact.phones.first.number); - if (await canLaunchUrl(callUri)) { - await launchUrl(callUri); - } else { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Could not launch call')), - ); - } + _callService.makeGsmCall(contact.phones.first.number); } else { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Contact has no phone number')), + const SnackBar( + content: Text('Contact has no phone number')), ); } }, @@ -444,7 +441,9 @@ class _HistoryPageState extends State color: Colors.grey[850], child: FutureBuilder( future: BlockService().isNumberBlocked( - contact.phones.isNotEmpty ? contact.phones.first.number : ''), + contact.phones.isNotEmpty + ? contact.phones.first.number + : ''), builder: (context, snapshot) { final isBlocked = snapshot.data ?? false; return Row( @@ -460,29 +459,37 @@ class _HistoryPageState extends State await launchUrl(smsUri); } else { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Could not send message')), + const SnackBar( + content: + Text('Could not send message')), ); } } else { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Contact has no phone number')), + const SnackBar( + content: + Text('Contact has no phone number')), ); } }, - icon: const Icon(Icons.message, color: Colors.white), - label: const Text('Message', style: TextStyle(color: Colors.white)), + icon: + const Icon(Icons.message, color: Colors.white), + label: const Text('Message', + style: TextStyle(color: Colors.white)), ), TextButton.icon( onPressed: () { Navigator.push( context, MaterialPageRoute( - builder: (_) => CallDetailsPage(history: history), + builder: (_) => + CallDetailsPage(history: history), ), ); }, icon: const Icon(Icons.info, color: Colors.white), - label: const Text('Details', style: TextStyle(color: Colors.white)), + label: const Text('Details', + style: TextStyle(color: Colors.white)), ), TextButton.icon( onPressed: () async { @@ -491,24 +498,29 @@ class _HistoryPageState extends State : null; if (phoneNumber == null) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Contact has no phone number')), + const SnackBar( + content: + Text('Contact has no phone number')), ); return; } if (isBlocked) { await BlockService().unblockNumber(phoneNumber); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('$phoneNumber unblocked')), + SnackBar( + content: Text('$phoneNumber unblocked')), ); } else { await BlockService().blockNumber(phoneNumber); ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('$phoneNumber blocked')), + SnackBar( + content: Text('$phoneNumber blocked')), ); } setState(() {}); }, - icon: Icon(isBlocked ? Icons.lock_open : Icons.block, + icon: Icon( + isBlocked ? Icons.lock_open : Icons.block, color: Colors.white), label: Text(isBlocked ? 'Unblock' : 'Block', style: const TextStyle(color: Colors.white)), @@ -554,21 +566,22 @@ class CallDetailsPage extends StatelessWidget { children: [ (contact.thumbnail != null && contact.thumbnail!.isNotEmpty) ? ObfuscatedAvatar( - imageBytes: contact.thumbnail, - radius: 30, - backgroundColor: contactBg, - fallbackInitial: contact.displayName, - ) + imageBytes: contact.thumbnail, + radius: 30, + backgroundColor: contactBg, + fallbackInitial: contact.displayName, + ) : CircleAvatar( - backgroundColor: generateColorFromName(contact.displayName), - radius: 30, - child: Text( - contact.displayName.isNotEmpty - ? contact.displayName[0].toUpperCase() - : '?', - style: TextStyle(color: contactLetter), - ), - ), + backgroundColor: + generateColorFromName(contact.displayName), + radius: 30, + child: Text( + contact.displayName.isNotEmpty + ? contact.displayName[0].toUpperCase() + : '?', + style: TextStyle(color: contactLetter), + ), + ), const SizedBox(width: 16), Expanded( child: Text( @@ -600,7 +613,8 @@ class CallDetailsPage extends StatelessWidget { if (contact.phones.isNotEmpty) DetailRow( label: 'Number:', - value: _obfuscateService.obfuscateData(contact.phones.first.number), + value: _obfuscateService + .obfuscateData(contact.phones.first.number), ), ], ), From 2d3519592ad38bce0b2ca3ea1a74361dc1eb9658 Mon Sep 17 00:00:00 2001 From: ange Date: Tue, 4 Mar 2025 13:06:08 +0000 Subject: [PATCH 02/13] cicd-stealth (#40) Reviewed-on: https://git.gmoker.com/icing/monorepo/pulls/40 Co-authored-by: ange Co-committed-by: ange --- .gitea/workflows/apk.yaml | 16 +++++++++++++++- dialer/build.sh | 7 ++++++- dialer/run.sh | 7 ++++++- dialer/stealth_local_run.sh | 3 ++- 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/apk.yaml b/.gitea/workflows/apk.yaml index db63776..5ddefac 100644 --- a/.gitea/workflows/apk.yaml +++ b/.gitea/workflows/apk.yaml @@ -10,8 +10,22 @@ jobs: - uses: actions/checkout@v1 with: subpath: dialer/ - - uses: icing/flutter@main + - uses: docker://git.gmoker.com/icing/flutter:main - uses: actions/upload-artifact@v1 with: name: icing-dialer-${{ gitea.ref_name }}-${{ gitea.run_id }}.apk path: build/app/outputs/flutter-apk/app-release.apk + + build-stealth: + runs-on: debian + steps: + - uses: actions/checkout@v1 + with: + subpath: dialer/ + - uses: docker://git.gmoker.com/icing/flutter:main + with: + args: "build apk --dart-define=STEALTH=true" + - uses: actions/upload-artifact@v1 + with: + name: icing-dialer-stealth-${{ gitea.ref_name }}-${{ gitea.run_id }}.apk + path: build/app/outputs/flutter-apk/app-release.apk diff --git a/dialer/build.sh b/dialer/build.sh index 6416762..c8f5e05 100755 --- a/dialer/build.sh +++ b/dialer/build.sh @@ -2,4 +2,9 @@ IMG=git.gmoker.com/icing/flutter:main -docker run --rm -v "$PWD:/app/" "$IMG" build apk +if [ "$1" == '-s' ]; then + OPT+=(--dart-define=STEALTH=true) +fi + +set -x +docker run --rm -v "$PWD:/app/" "$IMG" build apk "${OPT[@]}" diff --git a/dialer/run.sh b/dialer/run.sh index 3a8ccb7..2aa3244 100755 --- a/dialer/run.sh +++ b/dialer/run.sh @@ -2,4 +2,9 @@ IMG=git.gmoker.com/icing/flutter:main -docker run --rm -p 5037:5037 -v "$PWD:/app/" "$IMG" run +if [ "$1" == '-s' ]; then + OPT+=(--dart-define=STEALTH=true) +fi + +set -x +docker run --rm -p 5037:5037 -v "$PWD:/app/" "$IMG" run "${OPTS[@]}" diff --git a/dialer/stealth_local_run.sh b/dialer/stealth_local_run.sh index 95cc270..ae202a9 100755 --- a/dialer/stealth_local_run.sh +++ b/dialer/stealth_local_run.sh @@ -1,3 +1,4 @@ #!/usr/bin/env bash + echo "Running Icing Dialer in STEALTH mode..." -flutter run --dart-define=STEALTH=true \ No newline at end of file +flutter run --dart-define=STEALTH=true From 2ea2c679b2d30a0b0ca1bd33271d1fbedb1aee1c Mon Sep 17 00:00:00 2001 From: Florian Griffon Date: Tue, 4 Mar 2025 13:10:42 +0000 Subject: [PATCH 03/13] fix: search bar upgrade (#42) Reviewed-on: https://git.gmoker.com/icing/monorepo/pulls/42 Co-authored-by: Florian Griffon Co-committed-by: Florian Griffon --- dialer/lib/features/home/home_page.dart | 159 ++++++++++++++++++------ 1 file changed, 120 insertions(+), 39 deletions(-) diff --git a/dialer/lib/features/home/home_page.dart b/dialer/lib/features/home/home_page.dart index 97e2c88..65adaa8 100644 --- a/dialer/lib/features/home/home_page.dart +++ b/dialer/lib/features/home/home_page.dart @@ -8,6 +8,7 @@ import 'package:flutter_contacts/flutter_contacts.dart'; import 'package:dialer/features/settings/settings.dart'; import '../../services/contact_service.dart'; import 'package:dialer/features/voicemail/voicemail_page.dart'; +import '../contacts/widgets/contact_modal.dart'; class _MyHomePageState extends State @@ -17,6 +18,8 @@ class _MyHomePageState extends State List _contactSuggestions = []; final ContactService _contactService = ContactService(); final ObfuscateService _obfuscateService = ObfuscateService(); + final TextEditingController _searchController = TextEditingController(); + @override void initState() { @@ -32,12 +35,15 @@ class _MyHomePageState extends State setState(() {}); } - void _onSearchChanged(String query) { - print("Search query: $query"); + void _clearSearch() { + _searchController.clear(); + _onSearchChanged(''); + } + void _onSearchChanged(String query) { setState(() { if (query.isEmpty) { - _contactSuggestions = List.from(_allContacts); + _contactSuggestions = List.from(_allContacts); // Reset suggestions } else { _contactSuggestions = _allContacts.where((contact) { return contact.displayName @@ -50,6 +56,7 @@ class _MyHomePageState extends State @override void dispose() { + _searchController.dispose(); _tabController.removeListener(_handleTabIndex); _tabController.dispose(); super.dispose(); @@ -59,6 +66,34 @@ class _MyHomePageState extends State setState(() {}); } + void _toggleFavorite(Contact contact) async { + try { + if (await FlutterContacts.requestPermission()) { + Contact? fullContact = await FlutterContacts.getContact(contact.id, + withProperties: true, + withAccounts: true, + withPhoto: true, + withThumbnail: true); + + if (fullContact != null) { + fullContact.isStarred = !fullContact.isStarred; + await FlutterContacts.updateContact(fullContact); + setState(() { + // Updating the contact list after toggling the favorite + _fetchContacts(); + }); + } + } else { + print("Could not fetch contact details"); + } + } catch (e) { + print("Error updating favorite status: $e"); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to update contact favorite status')), + ); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -80,63 +115,109 @@ class _MyHomePageState extends State decoration: BoxDecoration( color: const Color.fromARGB(255, 30, 30, 30), borderRadius: BorderRadius.circular(12.0), - border: Border( - top: BorderSide(color: Colors.grey.shade800, width: 1), - left: BorderSide(color: Colors.grey.shade800, width: 1), - right: BorderSide(color: Colors.grey.shade800, width: 1), - bottom: - BorderSide(color: Colors.grey.shade800, width: 2), - ), + border: Border.all(color: Colors.grey.shade800, width: 1), ), child: SearchAnchor( builder: (BuildContext context, SearchController controller) { - return SearchBar( - controller: controller, - padding: - WidgetStateProperty.all( - const EdgeInsets.only( - top: 6.0, - bottom: 6.0, - left: 16.0, - right: 16.0, - ), - ), + return GestureDetector( onTap: () { - controller.openView(); - _onSearchChanged(''); + controller.openView(); // Open the search view }, - backgroundColor: WidgetStateProperty.all( - const Color.fromARGB(255, 30, 30, 30)), - hintText: 'Search contacts', - hintStyle: WidgetStateProperty.all( - const TextStyle(color: Colors.grey, fontSize: 16.0), - ), - leading: const Icon( - Icons.search, - color: Colors.grey, - size: 24.0, - ), - shape: - WidgetStateProperty.all( - RoundedRectangleBorder( + child: Container( + decoration: BoxDecoration( + color: const Color.fromARGB(255, 30, 30, 30), borderRadius: BorderRadius.circular(12.0), + border: Border.all( + color: Colors.grey.shade800, width: 1), + ), + padding: const EdgeInsets.symmetric( + vertical: 12.0, horizontal: 16.0), + child: Row( + children: [ + const Icon(Icons.search, + color: Colors.grey, size: 24.0), + const SizedBox(width: 8.0), + Text( + _searchController.text.isEmpty + ? 'Search contacts' + : _searchController.text, + style: const TextStyle( + color: Colors.grey, fontSize: 16.0), + ), + const Spacer(), + if (_searchController.text.isNotEmpty) + GestureDetector( + onTap: _clearSearch, + child: const Icon( + Icons.clear, + color: Colors.grey, + size: 24.0, + ), + ), + ], ), ), ); }, viewOnChanged: (query) { - _onSearchChanged(query); + _onSearchChanged(query); // Update immediately }, suggestionsBuilder: (BuildContext context, SearchController controller) { return _contactSuggestions.map((contact) { - return ListTile( + return ListTile( key: ValueKey(contact.id), title: Text(_obfuscateService.obfuscateData(contact.displayName), style: const TextStyle(color: Colors.white)), onTap: () { + // Clear the search text input + controller.text = ''; + + // Close the search view controller.closeView(contact.displayName); + + // Show the ContactModal when a contact is tapped + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) { + return ContactModal( + contact: contact, + onEdit: () async { + if (await FlutterContacts + .requestPermission()) { + final updatedContact = + await FlutterContacts + .openExternalEdit(contact.id); + if (updatedContact != null) { + _fetchContacts(); + Navigator.of(context).pop(); + ScaffoldMessenger.of(context) + .showSnackBar( + SnackBar( + content: Text( + '${contact.displayName} updated successfully!'), + ), + ); + } else { + ScaffoldMessenger.of(context) + .showSnackBar( + SnackBar( + content: Text( + 'Edit canceled or failed.'), + ), + ); + } + } + }, + onToggleFavorite: () => + _toggleFavorite(contact), + isFavorite: contact.isStarred, + ); + }, + ); }, ); }).toList(); From fb5f155430dadafdbdcb09f2e9ef0f0bf96d23ce Mon Sep 17 00:00:00 2001 From: alexis Date: Fri, 7 Mar 2025 22:40:16 +0000 Subject: [PATCH 04/13] Add CallPage for initiating calls with contact details (#37) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demo call page avec les features de base: - Haut parleur - Couper/activer micro - keypad - raccrocher - Display Icing state (toucher pour switch l'état) S'active en faisant un appui long sur le bouton d'appel depuis les détails du contact. Compatible avec l'obfuscation des contacts. Co-authored-by: AlexisDanlos <91090088+AlexisDanlos@users.noreply.github.com> Reviewed-on: https://git.gmoker.com/icing/monorepo/pulls/37 Co-authored-by: alexis Co-committed-by: alexis --- dialer/lib/features/call/call_page.dart | 332 ++++++++++++++++++ .../contacts/widgets/contact_modal.dart | 13 + 2 files changed, 345 insertions(+) create mode 100644 dialer/lib/features/call/call_page.dart diff --git a/dialer/lib/features/call/call_page.dart b/dialer/lib/features/call/call_page.dart new file mode 100644 index 0000000..e8edf82 --- /dev/null +++ b/dialer/lib/features/call/call_page.dart @@ -0,0 +1,332 @@ +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:dialer/services/obfuscate_service.dart'; +import 'package:dialer/widgets/username_color_generator.dart'; + +class CallPage extends StatefulWidget { + final String displayName; + final Uint8List? thumbnail; + + const CallPage({super.key, required this.displayName, this.thumbnail}); + + @override + _CallPageState createState() => _CallPageState(); +} + +class _CallPageState extends State { + final ObfuscateService _obfuscateService = ObfuscateService(); + bool isMuted = false; + bool isSpeakerOn = false; + bool isKeypadVisible = false; + bool icingProtocolOk = true; + String _typedDigits = ""; // New state variable for pressed digits + + void _addDigit(String digit) { + setState(() { + _typedDigits += digit; + }); + } + + void _toggleMute() { + setState(() { + isMuted = !isMuted; + }); + } + + void _toggleSpeaker() { + setState(() { + isSpeakerOn = !isSpeakerOn; + }); + } + + void _toggleKeypad() { + setState(() { + isKeypadVisible = !isKeypadVisible; + }); + } + + void _toggleIcingProtocol() { + setState(() { + icingProtocolOk = !icingProtocolOk; + }); + } + + void _hangUp() { + Navigator.pop(context); + } + + @override + Widget build(BuildContext context) { + final double avatarRadius = isKeypadVisible ? 45.0 : 45.0; // Smaller avatar + final double nameFontSize = isKeypadVisible ? 24.0 : 24.0; // Smaller font + final double statusFontSize = isKeypadVisible ? 16.0 : 16.0; // Smaller status + + return Scaffold( + body: Container( + color: Colors.black, + child: SafeArea( + child: Column( + children: [ + // Top section - make it more compact + Container( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(height: 35), + ObfuscatedAvatar( + imageBytes: widget.thumbnail, + radius: avatarRadius, + backgroundColor: generateColorFromName(widget.displayName), + fallbackInitial: widget.displayName, + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icingProtocolOk ? Icons.lock : Icons.lock_open, + color: icingProtocolOk ? Colors.green : Colors.red, + size: 16, + ), + const SizedBox(width: 4), + Text( + 'Icing protocol: ${icingProtocolOk ? "ok" : "ko"}', + style: TextStyle( + color: icingProtocolOk ? Colors.green : Colors.red, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + _obfuscateService.obfuscateData(widget.displayName), + style: TextStyle( + fontSize: nameFontSize, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + Text( + 'Calling...', + style: TextStyle(fontSize: statusFontSize, color: Colors.white70), + ), + ], + ), + ), + + // Middle section - make it flexible and scrollable if needed + Expanded( + child: Column( + children: [ + if (isKeypadVisible) ...[ + // Add spacer to push keypad down + const Spacer(flex: 2), + + // Typed digits display + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + _typedDigits, + maxLines: 1, + textAlign: TextAlign.right, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 24, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + padding: EdgeInsets.zero, + onPressed: _toggleKeypad, + icon: const Icon(Icons.close, color: Colors.white), + ), + ], + ), + ), + + // Keypad grid + Container( + height: MediaQuery.of(context).size.height * 0.35, + margin: const EdgeInsets.symmetric(horizontal: 20), + child: GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 3, + childAspectRatio: 1.3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + children: List.generate(12, (index) { + String label; + if (index < 9) { + label = '${index + 1}'; + } else if (index == 9) { + label = '*'; + } else if (index == 10) { + label = '0'; + } else { + label = '#'; + } + return GestureDetector( + onTap: () => _addDigit(label), + child: Container( + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.transparent, + ), + child: Center( + child: Text( + label, + style: const TextStyle(fontSize: 32, color: Colors.white), + ), + ), + ), + ); + }), + ), + ), + + // Add spacer after keypad + const Spacer(flex: 1), + ] else ...[ + const Spacer(), + // Control buttons + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Main control buttons + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // Mute + Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: _toggleMute, + icon: Icon( + isMuted ? Icons.mic_off : Icons.mic, + color: Colors.white, + size: 32, + ), + ), + Text( + isMuted ? 'Unmute' : 'Mute', + style: const TextStyle(color: Colors.white, fontSize: 14), + ), + ], + ), + // Keypad + Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: _toggleKeypad, + icon: const Icon(Icons.dialpad, color: Colors.white, size: 32), + ), + const Text( + 'Keypad', + style: TextStyle(color: Colors.white, fontSize: 14), + ), + ], + ), + // Speaker + Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: _toggleSpeaker, + icon: Icon( + isSpeakerOn ? Icons.volume_up : Icons.volume_off, + color: isSpeakerOn ? Colors.amber : Colors.white, + size: 32, + ), + ), + const Text( + 'Speaker', + style: TextStyle(color: Colors.white, fontSize: 14), + ), + ], + ), + ], + ), + const SizedBox(height: 20), + // Additional buttons + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // Add Contact + Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () { + // ...existing code... + }, + icon: const Icon(Icons.person_add, color: Colors.white, size: 32), + ), + const Text('Add Contact', + style: TextStyle(color: Colors.white, fontSize: 14)), + ], + ), + // Change SIM + Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () { + // ...existing code... + }, + icon: const Icon(Icons.sim_card, color: Colors.white, size: 32), + ), + const Text('Change SIM', + style: TextStyle(color: Colors.white, fontSize: 14)), + ], + ), + ], + ), + ], + ), + ), + const Spacer(flex: 3), + ], + ], + ), + ), + + // Bottom section - hang up button + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: GestureDetector( + onTap: _hangUp, + child: Container( + padding: const EdgeInsets.all(12), + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.call_end, + color: Colors.white, + size: 32, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/dialer/lib/features/contacts/widgets/contact_modal.dart b/dialer/lib/features/contacts/widgets/contact_modal.dart index 9f641ad..0a6efad 100644 --- a/dialer/lib/features/contacts/widgets/contact_modal.dart +++ b/dialer/lib/features/contacts/widgets/contact_modal.dart @@ -5,6 +5,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:dialer/widgets/username_color_generator.dart'; import '../../../services/block_service.dart'; import '../../../services/contact_service.dart'; +import '../../../features/call/call_page.dart'; import '../../../services/call_service.dart'; // Import CallService class ContactModal extends StatefulWidget { @@ -265,6 +266,18 @@ class _ContactModalState extends State { await _callService.makeGsmCall(phoneNumber); } }, + onLongPress: () { + // Navigate to the beautiful calling page demo + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => CallPage( + displayName: widget.contact.displayName, + thumbnail: widget.contact.thumbnail, + ), + ), + ); + }, ), ListTile( leading: const Icon(Icons.message, color: Colors.blue), From 37349fdc1397ed83d1b62dfbbfecb62c9e57c65c Mon Sep 17 00:00:00 2001 From: Florian Griffon Date: Mon, 24 Mar 2025 11:11:11 +0000 Subject: [PATCH 05/13] feat: app is now default dialer app | callpage UI | incoming call UI | receive and call from our app (#48) Reviewed-on: https://git.gmoker.com/icing/monorepo/pulls/48 Co-authored-by: Florian Griffon Co-committed-by: Florian Griffon --- dialer/android/.gitignore | 1 + .../android/app/src/main/AndroidManifest.xml | 47 ++++- .../icing/dialer/activities/MainActivity.kt | 190 +++++++++++++----- .../dialer/services/CallConnectionService.kt | 82 ++++++++ .../com/icing/dialer/services/CallService.kt | 55 +++-- .../icing/dialer/services/MyInCallService.kt | 69 +++++++ dialer/lib/features/call/call_page.dart | 62 +++--- .../lib/features/call/incoming_call_page.dart | 181 +++++++++++++++++ .../lib/features/composition/composition.dart | 6 +- .../contacts/widgets/contact_modal.dart | 42 ++-- dialer/lib/features/history/history_page.dart | 2 +- dialer/lib/main.dart | 31 ++- dialer/lib/services/call_service.dart | 148 +++++++++++++- 13 files changed, 767 insertions(+), 149 deletions(-) create mode 100644 dialer/android/app/src/main/kotlin/com/icing/dialer/services/CallConnectionService.kt create mode 100644 dialer/android/app/src/main/kotlin/com/icing/dialer/services/MyInCallService.kt create mode 100644 dialer/lib/features/call/incoming_call_page.dart diff --git a/dialer/android/.gitignore b/dialer/android/.gitignore index e6d71b3..ebc61c7 100644 --- a/dialer/android/.gitignore +++ b/dialer/android/.gitignore @@ -7,6 +7,7 @@ gradle-wrapper.jar /gradle.properties GeneratedPluginRegistrant.java gradle.properties +.cxx # Remember to never publicly share your keystore. # See https://flutter.dev/to/reference-keystore diff --git a/dialer/android/app/src/main/AndroidManifest.xml b/dialer/android/app/src/main/AndroidManifest.xml index e0de6a4..fcaf71f 100644 --- a/dialer/android/app/src/main/AndroidManifest.xml +++ b/dialer/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,6 @@ + + @@ -7,7 +9,9 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - when (call.method) { - "makeGsmCall" -> { - val phoneNumber = call.argument("phoneNumber") - if (phoneNumber != null) { - CallService.makeGsmCall(this, phoneNumber) - result.success("Calling $phoneNumber") - } else { - result.error("INVALID_PHONE_NUMBER", "Phone number is required", null) - } - } - "hangUpCall" -> { - CallService.hangUpCall(this) - result.success("Call ended") - } - else -> result.notImplemented() - } - } - - // Set up the keystore channel. - MethodChannel(flutterEngine.dartExecutor.binaryMessenger, KEYSTORE_CHANNEL) + MyInCallService.channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALL_CHANNEL) + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALL_CHANNEL) .setMethodCallHandler { call, result -> - // Delegate method calls to KeystoreHelper. - KeystoreHelper(call, result).handleMethodCall() + when (call.method) { + "permissionsGranted" -> { + Log.d(TAG, "Received permissionsGranted from Flutter") + checkAndRequestDefaultDialer() + result.success(true) + } + "makeGsmCall" -> { + val phoneNumber = call.argument("phoneNumber") + if (phoneNumber != null) { + val success = CallService.makeGsmCall(this, phoneNumber) + if (success) { + result.success(mapOf("status" to "calling", "phoneNumber" to phoneNumber)) + } else { + result.error("CALL_FAILED", "Failed to initiate call", null) + } + } else { + result.error("INVALID_PHONE_NUMBER", "Phone number is required", null) + } + } + "hangUpCall" -> { + val success = CallService.hangUpCall(this) + if (success) { + result.success(mapOf("status" to "ended")) + } else { + result.error("HANGUP_FAILED", "Failed to end call", null) + } + } + "answerCall" -> { + val success = MyInCallService.currentCall?.let { + it.answer(0) // 0 for default video state (audio-only) + Log.d(TAG, "Answered call") + true + } ?: false + if (success) { + result.success(mapOf("status" to "answered")) + } else { + result.error("ANSWER_FAILED", "No active call to answer", null) + } + } + else -> result.notImplemented() + } } - // Set up the call log channel. + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, KEYSTORE_CHANNEL) + .setMethodCallHandler { call, result -> KeystoreHelper(call, result).handleMethodCall() } + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALLLOG_CHANNEL) .setMethodCallHandler { call, result -> if (call.method == "getCallLogs") { @@ -60,35 +97,78 @@ class MainActivity: FlutterActivity() { } } - /** - * Queries the Android call log and returns a list of maps. - * Each map contains keys: "number", "type", "date", and "duration". - */ + private fun checkAndRequestDefaultDialer() { + val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager + val currentDefault = telecomManager.defaultDialerPackage + Log.d(TAG, "Current default dialer: $currentDefault, My package: $packageName") + + if (currentDefault != packageName) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val roleManager = getSystemService(Context.ROLE_SERVICE) as RoleManager + if (roleManager.isRoleAvailable(RoleManager.ROLE_DIALER) && !roleManager.isRoleHeld(RoleManager.ROLE_DIALER)) { + val intent = roleManager.createRequestRoleIntent(RoleManager.ROLE_DIALER) + startActivityForResult(intent, REQUEST_CODE_SET_DEFAULT_DIALER) + Log.d(TAG, "Launched RoleManager intent for default dialer on API 29+") + } else { + Log.d(TAG, "RoleManager: Available=${roleManager.isRoleAvailable(RoleManager.ROLE_DIALER)}, Held=${roleManager.isRoleHeld(RoleManager.ROLE_DIALER)}") + } + } else { + val intent = Intent(TelecomManager.ACTION_CHANGE_DEFAULT_DIALER) + .putExtra(TelecomManager.EXTRA_CHANGE_DEFAULT_DIALER_PACKAGE_NAME, packageName) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + startActivityForResult(intent, REQUEST_CODE_SET_DEFAULT_DIALER) + Log.d(TAG, "Launched TelecomManager intent for default dialer") + } catch (e: Exception) { + Log.e(TAG, "Failed to launch default dialer prompt: ${e.message}", e) + launchDefaultAppsSettings() + } + } + } else { + Log.d(TAG, "Already the default dialer") + } + } + + private fun launchDefaultAppsSettings() { + val settingsIntent = Intent(android.provider.Settings.ACTION_MANAGE_DEFAULT_APPS_SETTINGS) + startActivity(settingsIntent) + Log.d(TAG, "Opened default apps settings as fallback") + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + Log.d(TAG, "onActivityResult: requestCode=$requestCode, resultCode=$resultCode, data=$data") + if (requestCode == REQUEST_CODE_SET_DEFAULT_DIALER) { + if (resultCode == RESULT_OK) { + Log.d(TAG, "User accepted default dialer change") + } else { + Log.w(TAG, "Default dialer prompt canceled or failed (resultCode=$resultCode)") + launchDefaultAppsSettings() + } + } + } + private fun getCallLogs(): List> { val logsList = mutableListOf>() val cursor: Cursor? = contentResolver.query( - CallLog.Calls.CONTENT_URI, - null, - null, - null, - CallLog.Calls.DATE + " DESC" + CallLog.Calls.CONTENT_URI, null, null, null, CallLog.Calls.DATE + " DESC" ) - if (cursor != null) { - while (cursor.moveToNext()) { - val number = cursor.getString(cursor.getColumnIndexOrThrow(CallLog.Calls.NUMBER)) - val type = cursor.getInt(cursor.getColumnIndexOrThrow(CallLog.Calls.TYPE)) - val date = cursor.getLong(cursor.getColumnIndexOrThrow(CallLog.Calls.DATE)) - val duration = cursor.getLong(cursor.getColumnIndexOrThrow(CallLog.Calls.DURATION)) + cursor?.use { + while (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)) - val map = HashMap() - map["number"] = number - map["type"] = type // Typically: 1 for incoming, 2 for outgoing, 3 for missed. - map["date"] = date - map["duration"] = duration + val map = mutableMapOf( + "number" to number, + "type" to type, + "date" to date, + "duration" to duration + ) logsList.add(map) } - cursor.close() } return logsList } -} +} \ No newline at end of file diff --git a/dialer/android/app/src/main/kotlin/com/icing/dialer/services/CallConnectionService.kt b/dialer/android/app/src/main/kotlin/com/icing/dialer/services/CallConnectionService.kt new file mode 100644 index 0000000..c39d608 --- /dev/null +++ b/dialer/android/app/src/main/kotlin/com/icing/dialer/services/CallConnectionService.kt @@ -0,0 +1,82 @@ +// package com.icing.dialer.services + +// import android.telecom.Connection +// import android.telecom.ConnectionService +// import android.telecom.PhoneAccountHandle +// import android.telecom.TelecomManager +// import android.telecom.DisconnectCause +// import android.util.Log +// import io.flutter.plugin.common.MethodChannel + +// class CallConnectionService : ConnectionService() { +// companion object { +// var channel: MethodChannel? = null +// private const val TAG = "CallConnectionService" +// } + +// init { +// Log.d(TAG, "CallConnectionService initialized") +// } + +// override fun onCreate() { +// super.onCreate() +// Log.d(TAG, "Service created") +// } + +// override fun onDestroy() { +// super.onDestroy() +// Log.d(TAG, "Service destroyed") +// } + +// override fun onCreateOutgoingConnection( +// connectionManagerPhoneAccount: PhoneAccountHandle?, +// request: android.telecom.ConnectionRequest +// ): Connection { +// Log.d(TAG, "Creating outgoing connection for ${request.address}, account: $connectionManagerPhoneAccount") +// val connection = object : Connection() { +// override fun onStateChanged(state: Int) { +// super.onStateChanged(state) +// Log.d(TAG, "Connection state changed: $state") +// val stateStr = when (state) { +// STATE_DIALING -> "dialing" +// STATE_ACTIVE -> "active" +// STATE_DISCONNECTED -> "disconnected" +// else -> "unknown" +// } +// channel?.invokeMethod("callStateChanged", mapOf("state" to stateStr, "phoneNumber" to request.address.toString())) +// } + +// override fun onDisconnect() { +// Log.d(TAG, "Connection disconnected") +// setDisconnected(DisconnectCause(DisconnectCause.LOCAL)) +// destroy() +// } +// } +// connection.setAddress(request.address, TelecomManager.PRESENTATION_ALLOWED) +// connection.setInitialized() +// connection.setDialing() +// return connection +// } + +// override fun onCreateIncomingConnection( +// connectionManagerPhoneAccount: PhoneAccountHandle?, +// request: android.telecom.ConnectionRequest +// ): Connection { +// Log.d(TAG, "Creating incoming connection for ${request.address}, account: $connectionManagerPhoneAccount") +// val connection = object : Connection() { +// override fun onAnswer() { +// Log.d(TAG, "Connection answered") +// setActive() +// } + +// override fun onDisconnect() { +// Log.d(TAG, "Connection disconnected") +// setDisconnected(DisconnectCause(DisconnectCause.LOCAL)) +// destroy() +// } +// } +// connection.setAddress(request.address, TelecomManager.PRESENTATION_ALLOWED) +// connection.setRinging() +// return connection +// } +// } \ No newline at end of file diff --git a/dialer/android/app/src/main/kotlin/com/icing/dialer/services/CallService.kt b/dialer/android/app/src/main/kotlin/com/icing/dialer/services/CallService.kt index e0016dc..7958799 100644 --- a/dialer/android/app/src/main/kotlin/com/icing/dialer/services/CallService.kt +++ b/dialer/android/app/src/main/kotlin/com/icing/dialer/services/CallService.kt @@ -1,30 +1,55 @@ package com.icing.dialer.services import android.content.Context -import android.content.Intent import android.net.Uri -import android.telecom.TelecomManager import android.os.Build +import android.os.Bundle +import android.telecom.TelecomManager import android.util.Log +import androidx.core.content.ContextCompat +import android.content.pm.PackageManager +import android.Manifest object CallService { - - fun makeGsmCall(context: Context, phoneNumber: String) { - try { - val intent = Intent(Intent.ACTION_CALL) - intent.data = Uri.parse("tel:$phoneNumber") - context.startActivity(intent) + private val TAG = "CallService" + + fun makeGsmCall(context: Context, phoneNumber: String): Boolean { + return try { + val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager + val uri = Uri.parse("tel:$phoneNumber") + + if (ContextCompat.checkSelfPermission(context, Manifest.permission.CALL_PHONE) == PackageManager.PERMISSION_GRANTED) { + telecomManager.placeCall(uri, Bundle()) + Log.d(TAG, "Initiated call to $phoneNumber") + true + } else { + Log.e(TAG, "CALL_PHONE permission not granted") + false + } } catch (e: Exception) { - Log.e("CallService", "Error making GSM call: ${e.message}") + Log.e(TAG, "Error making GSM call: ${e.message}", e) + false } } - fun hangUpCall(context: Context) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager - telecomManager.endCall() - } else { - Log.e("CallService", "Hangup call is only supported on Android P or later.") + fun hangUpCall(context: Context): Boolean { + return try { + if (MyInCallService.currentCall != null) { + MyInCallService.currentCall?.disconnect() + Log.d(TAG, "Disconnected active call via MyInCallService") + true + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager + telecomManager.endCall() + Log.d(TAG, "Ended call via TelecomManager (no active call in MyInCallService)") + true + } else { + Log.e(TAG, "No active call and hangup not supported below Android P") + false + } + } catch (e: Exception) { + Log.e(TAG, "Error hanging up call: ${e.message}", e) + false } } } \ No newline at end of file diff --git a/dialer/android/app/src/main/kotlin/com/icing/dialer/services/MyInCallService.kt b/dialer/android/app/src/main/kotlin/com/icing/dialer/services/MyInCallService.kt new file mode 100644 index 0000000..48b7edd --- /dev/null +++ b/dialer/android/app/src/main/kotlin/com/icing/dialer/services/MyInCallService.kt @@ -0,0 +1,69 @@ +package com.icing.dialer.services + +import android.telecom.Call +import android.telecom.InCallService +import android.util.Log +import io.flutter.plugin.common.MethodChannel + +class MyInCallService : InCallService() { + companion object { + var channel: MethodChannel? = null + var currentCall: Call? = null + private const val TAG = "MyInCallService" + } + + private val callCallback = object : Call.Callback() { + override fun onStateChanged(call: Call, state: Int) { + super.onStateChanged(call, state) + val stateStr = when (state) { + Call.STATE_DIALING -> "dialing" + Call.STATE_ACTIVE -> "active" + Call.STATE_DISCONNECTED -> "disconnected" + Call.STATE_DISCONNECTING -> "disconnecting" + Call.STATE_RINGING -> "ringing" + else -> "unknown" + } + Log.d(TAG, "State changed: $stateStr for call ${call.details.handle}") + channel?.invokeMethod("callStateChanged", mapOf( + "callId" to call.details.handle.toString(), + "state" to stateStr + )) + if (state == Call.STATE_DISCONNECTED || state == Call.STATE_DISCONNECTING) { + Log.d(TAG, "Call ended: ${call.details.handle}") + channel?.invokeMethod("callEnded", mapOf("callId" to call.details.handle.toString())) + currentCall = null + } + } + } + + override fun onCallAdded(call: Call) { + super.onCallAdded(call) + currentCall = call + val stateStr = when (call.state) { + Call.STATE_DIALING -> "dialing" + Call.STATE_ACTIVE -> "active" + Call.STATE_RINGING -> "ringing" + else -> "dialing" // Default for outgoing + } + Log.d(TAG, "Call added: ${call.details.handle}, state: $stateStr") + channel?.invokeMethod("callAdded", mapOf( + "callId" to call.details.handle.toString(), + "state" to stateStr + )) + call.registerCallback(callCallback) + } + + override fun onCallRemoved(call: Call) { + super.onCallRemoved(call) + Log.d(TAG, "Call removed: ${call.details.handle}") + call.unregisterCallback(callCallback) + channel?.invokeMethod("callRemoved", mapOf("callId" to call.details.handle.toString())) + currentCall = null + } + + override fun onCallAudioStateChanged(state: android.telecom.CallAudioState) { + super.onCallAudioStateChanged(state) + Log.d(TAG, "Audio state changed: route=${state.route}") + channel?.invokeMethod("audioStateChanged", mapOf("route" to state.route)) + } +} \ No newline at end of file diff --git a/dialer/lib/features/call/call_page.dart b/dialer/lib/features/call/call_page.dart index e8edf82..1416a98 100644 --- a/dialer/lib/features/call/call_page.dart +++ b/dialer/lib/features/call/call_page.dart @@ -1,13 +1,20 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:dialer/services/call_service.dart'; import 'package:dialer/services/obfuscate_service.dart'; import 'package:dialer/widgets/username_color_generator.dart'; class CallPage extends StatefulWidget { final String displayName; + final String phoneNumber; final Uint8List? thumbnail; - const CallPage({super.key, required this.displayName, this.thumbnail}); + const CallPage({ + super.key, + required this.displayName, + required this.phoneNumber, + this.thumbnail, + }); @override _CallPageState createState() => _CallPageState(); @@ -15,11 +22,12 @@ class CallPage extends StatefulWidget { class _CallPageState extends State { final ObfuscateService _obfuscateService = ObfuscateService(); + final CallService _callService = CallService(); bool isMuted = false; bool isSpeakerOn = false; bool isKeypadVisible = false; bool icingProtocolOk = true; - String _typedDigits = ""; // New state variable for pressed digits + String _typedDigits = ""; void _addDigit(String digit) { setState(() { @@ -51,15 +59,19 @@ class _CallPageState extends State { }); } - void _hangUp() { - Navigator.pop(context); + void _hangUp() async { + try { + await _callService.hangUpCall(context); + } catch (e) { + print("Error hanging up: $e"); + } } @override Widget build(BuildContext context) { - final double avatarRadius = isKeypadVisible ? 45.0 : 45.0; // Smaller avatar - final double nameFontSize = isKeypadVisible ? 24.0 : 24.0; // Smaller font - final double statusFontSize = isKeypadVisible ? 16.0 : 16.0; // Smaller status + final double avatarRadius = isKeypadVisible ? 45.0 : 45.0; + final double nameFontSize = isKeypadVisible ? 24.0 : 24.0; + final double statusFontSize = isKeypadVisible ? 16.0 : 16.0; return Scaffold( body: Container( @@ -67,7 +79,6 @@ class _CallPageState extends State { child: SafeArea( child: Column( children: [ - // Top section - make it more compact Container( padding: const EdgeInsets.symmetric(vertical: 8.0), child: Column( @@ -75,7 +86,7 @@ class _CallPageState extends State { children: [ SizedBox(height: 35), ObfuscatedAvatar( - imageBytes: widget.thumbnail, + imageBytes: widget.thumbnail, // Uses thumbnail if provided radius: avatarRadius, backgroundColor: generateColorFromName(widget.displayName), fallbackInitial: widget.displayName, @@ -109,6 +120,10 @@ class _CallPageState extends State { fontWeight: FontWeight.bold, ), ), + Text( + widget.phoneNumber, + style: TextStyle(fontSize: statusFontSize, color: Colors.white70), + ), Text( 'Calling...', style: TextStyle(fontSize: statusFontSize, color: Colors.white70), @@ -116,16 +131,11 @@ class _CallPageState extends State { ], ), ), - - // Middle section - make it flexible and scrollable if needed Expanded( child: Column( children: [ if (isKeypadVisible) ...[ - // Add spacer to push keypad down const Spacer(flex: 2), - - // Typed digits display Padding( padding: const EdgeInsets.symmetric(horizontal: 20.0), child: Row( @@ -152,8 +162,6 @@ class _CallPageState extends State { ], ), ), - - // Keypad grid Container( height: MediaQuery.of(context).size.height * 0.35, margin: const EdgeInsets.symmetric(horizontal: 20), @@ -193,22 +201,17 @@ class _CallPageState extends State { }), ), ), - - // Add spacer after keypad const Spacer(flex: 1), ] else ...[ const Spacer(), - // Control buttons Padding( padding: const EdgeInsets.symmetric(horizontal: 32.0), child: Column( mainAxisSize: MainAxisSize.min, children: [ - // Main control buttons Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - // Mute Column( mainAxisSize: MainAxisSize.min, children: [ @@ -226,7 +229,6 @@ class _CallPageState extends State { ), ], ), - // Keypad Column( mainAxisSize: MainAxisSize.min, children: [ @@ -240,7 +242,6 @@ class _CallPageState extends State { ), ], ), - // Speaker Column( mainAxisSize: MainAxisSize.min, children: [ @@ -261,32 +262,25 @@ class _CallPageState extends State { ], ), const SizedBox(height: 20), - // Additional buttons Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - // Add Contact Column( mainAxisSize: MainAxisSize.min, children: [ IconButton( - onPressed: () { - // ...existing code... - }, + onPressed: () {}, icon: const Icon(Icons.person_add, color: Colors.white, size: 32), ), const Text('Add Contact', style: TextStyle(color: Colors.white, fontSize: 14)), ], ), - // Change SIM Column( mainAxisSize: MainAxisSize.min, children: [ IconButton( - onPressed: () { - // ...existing code... - }, + onPressed: () {}, icon: const Icon(Icons.sim_card, color: Colors.white, size: 32), ), const Text('Change SIM', @@ -303,8 +297,6 @@ class _CallPageState extends State { ], ), ), - - // Bottom section - hang up button Padding( padding: const EdgeInsets.only(bottom: 16.0), child: GestureDetector( @@ -329,4 +321,4 @@ class _CallPageState extends State { ), ); } -} +} \ No newline at end of file diff --git a/dialer/lib/features/call/incoming_call_page.dart b/dialer/lib/features/call/incoming_call_page.dart new file mode 100644 index 0000000..2bad2eb --- /dev/null +++ b/dialer/lib/features/call/incoming_call_page.dart @@ -0,0 +1,181 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:dialer/services/call_service.dart'; +import 'package:dialer/services/obfuscate_service.dart'; +import 'package:dialer/widgets/username_color_generator.dart'; +import 'package:dialer/features/call/call_page.dart'; + +class IncomingCallPage extends StatefulWidget { + final String displayName; + final String phoneNumber; + final Uint8List? thumbnail; + + const IncomingCallPage({ + super.key, + required this.displayName, + required this.phoneNumber, + this.thumbnail, + }); + + @override + _IncomingCallPageState createState() => _IncomingCallPageState(); +} + +class _IncomingCallPageState extends State { + static const MethodChannel _channel = MethodChannel('call_service'); + final ObfuscateService _obfuscateService = ObfuscateService(); + final CallService _callService = CallService(); + bool icingProtocolOk = true; + + void _toggleIcingProtocol() { + setState(() { + icingProtocolOk = !icingProtocolOk; + }); + } + + void _answerCall() async { + try { + final result = await _channel.invokeMethod('answerCall'); + print('IncomingCallPage: Answer call result: $result'); + if (result["status"] == "answered") { + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => CallPage( + displayName: widget.displayName, + phoneNumber: widget.phoneNumber, + thumbnail: widget.thumbnail, + ), + ), + ); + } + } catch (e) { + print("IncomingCallPage: Error answering call: $e"); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Error answering call: $e")), + ); + } + } + + void _declineCall() async { + try { + await _callService.hangUpCall(context); + } catch (e) { + print("IncomingCallPage: Error declining call: $e"); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Error declining call: $e")), + ); + } + } + + @override + Widget build(BuildContext context) { + const double avatarRadius = 45.0; + const double nameFontSize = 24.0; + const double statusFontSize = 16.0; + + return Scaffold( + body: Container( + color: Colors.black, + child: SafeArea( + child: Column( + children: [ + Container( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 35), + ObfuscatedAvatar( + imageBytes: widget.thumbnail, + radius: avatarRadius, + backgroundColor: generateColorFromName(widget.displayName), + fallbackInitial: widget.displayName, + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icingProtocolOk ? Icons.lock : Icons.lock_open, + color: icingProtocolOk ? Colors.green : Colors.red, + size: 16, + ), + const SizedBox(width: 4), + Text( + 'Icing protocol: ${icingProtocolOk ? "ok" : "ko"}', + style: TextStyle( + color: icingProtocolOk ? Colors.green : Colors.red, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + _obfuscateService.obfuscateData(widget.displayName), + style: const TextStyle( + fontSize: nameFontSize, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + Text( + widget.phoneNumber, + style: const TextStyle(fontSize: statusFontSize, color: Colors.white70), + ), + const Text( + 'Incoming Call...', + style: TextStyle(fontSize: statusFontSize, color: Colors.white70), + ), + ], + ), + ), + const Spacer(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0, vertical: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + GestureDetector( + onTap: _declineCall, + child: Container( + padding: const EdgeInsets.all(12), + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.call_end, + color: Colors.white, + size: 32, + ), + ), + ), + GestureDetector( + onTap: _answerCall, + child: Container( + padding: const EdgeInsets.all(12), + decoration: const BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.call, + color: Colors.white, + size: 32, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/dialer/lib/features/composition/composition.dart b/dialer/lib/features/composition/composition.dart index 9bde112..b807ce4 100644 --- a/dialer/lib/features/composition/composition.dart +++ b/dialer/lib/features/composition/composition.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_contacts/flutter_contacts.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../services/contact_service.dart'; -import '../../services/obfuscate_service.dart'; // Import ObfuscateService -import '../../services/call_service.dart'; // Import the CallService +import '../../services/obfuscate_service.dart'; +import '../../services/call_service.dart'; import '../contacts/widgets/add_contact_button.dart'; class CompositionPage extends StatefulWidget { @@ -76,7 +76,7 @@ class _CompositionPageState extends State { // Function to call a contact's number using the CallService void _makeCall(String phoneNumber) async { try { - await _callService.makeGsmCall(phoneNumber); + await _callService.makeGsmCall(context, phoneNumber: phoneNumber); setState(() { dialedNumber = phoneNumber; }); diff --git a/dialer/lib/features/contacts/widgets/contact_modal.dart b/dialer/lib/features/contacts/widgets/contact_modal.dart index 0a6efad..198f614 100644 --- a/dialer/lib/features/contacts/widgets/contact_modal.dart +++ b/dialer/lib/features/contacts/widgets/contact_modal.dart @@ -5,8 +5,7 @@ import 'package:url_launcher/url_launcher.dart'; import 'package:dialer/widgets/username_color_generator.dart'; import '../../../services/block_service.dart'; import '../../../services/contact_service.dart'; -import '../../../features/call/call_page.dart'; -import '../../../services/call_service.dart'; // Import CallService +import '../../../services/call_service.dart'; class ContactModal extends StatefulWidget { final Contact contact; @@ -30,7 +29,7 @@ class _ContactModalState extends State { late String phoneNumber; bool isBlocked = false; final ObfuscateService _obfuscateService = ObfuscateService(); - final CallService _callService = CallService(); // Instantiate CallService + final CallService _callService = CallService(); @override void initState() { @@ -127,7 +126,9 @@ class _ContactModalState extends State { // Show success message ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('${_obfuscateService.obfuscateData(widget.contact.displayName)} deleted')), + SnackBar( + content: Text( + '${_obfuscateService.obfuscateData(widget.contact.displayName)} deleted')), ); // Close the modal @@ -135,7 +136,9 @@ class _ContactModalState extends State { } catch (e) { // Handle errors and show a failure message ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to delete ${widget.contact.displayName}: $e')), + SnackBar( + content: + Text('Failed to delete ${widget.contact.displayName}: $e')), ); } } @@ -163,7 +166,7 @@ class _ContactModalState extends State { decoration: BoxDecoration( color: Colors.grey[900], borderRadius: - const BorderRadius.vertical(top: Radius.circular(20)), + const BorderRadius.vertical(top: Radius.circular(20)), ), child: Column( mainAxisSize: MainAxisSize.min, @@ -217,7 +220,7 @@ class _ContactModalState extends State { const PopupMenuItem( value: 'create_shortcut', child: - Text('Create shortcut (to home screen)'), + Text('Create shortcut (to home screen)'), ), const PopupMenuItem( value: 'set_ringtone', @@ -239,12 +242,13 @@ class _ContactModalState extends State { imageBytes: widget.contact.thumbnail, radius: 50, backgroundColor: - generateColorFromName(widget.contact.displayName), + generateColorFromName(widget.contact.displayName), fallbackInitial: widget.contact.displayName, ), const SizedBox(height: 10), Text( - _obfuscateService.obfuscateData(widget.contact.displayName), + _obfuscateService + .obfuscateData(widget.contact.displayName), style: const TextStyle( fontSize: 24, fontWeight: FontWeight.bold, @@ -263,21 +267,10 @@ class _ContactModalState extends State { ), onTap: () async { if (widget.contact.phones.isNotEmpty) { - await _callService.makeGsmCall(phoneNumber); + await _callService.makeGsmCall(context, + phoneNumber: phoneNumber); } }, - onLongPress: () { - // Navigate to the beautiful calling page demo - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => CallPage( - displayName: widget.contact.displayName, - thumbnail: widget.contact.thumbnail, - ), - ), - ); - }, ), ListTile( leading: const Icon(Icons.message, color: Colors.blue), @@ -320,9 +313,8 @@ class _ContactModalState extends State { icon: Icon(widget.isFavorite ? Icons.star : Icons.star_border), - label: Text(widget.isFavorite - ? 'Unfavorite' - : 'Favorite'), + label: Text( + widget.isFavorite ? 'Unfavorite' : 'Favorite'), ), ), const SizedBox(height: 10), diff --git a/dialer/lib/features/history/history_page.dart b/dialer/lib/features/history/history_page.dart index 117d1e8..2ce20b8 100644 --- a/dialer/lib/features/history/history_page.dart +++ b/dialer/lib/features/history/history_page.dart @@ -419,7 +419,7 @@ class _HistoryPageState extends State icon: const Icon(Icons.phone, color: Colors.green), onPressed: () async { if (contact.phones.isNotEmpty) { - _callService.makeGsmCall(contact.phones.first.number); + _callService.makeGsmCall(context, phoneNumber: contact.phones.first.number); } else { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( diff --git a/dialer/lib/main.dart b/dialer/lib/main.dart index afde3b9..d3513cf 100644 --- a/dialer/lib/main.dart +++ b/dialer/lib/main.dart @@ -1,8 +1,11 @@ import 'package:dialer/features/home/home_page.dart'; import 'package:flutter/material.dart'; import 'package:dialer/features/contacts/contact_state.dart'; +import 'package:dialer/services/call_service.dart'; +import 'package:flutter/services.dart'; import 'globals.dart' as globals; import 'package:dialer/services/cryptography/asymmetric_crypto_service.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; void main() async { @@ -13,19 +16,38 @@ void main() async { final AsymmetricCryptoService cryptoService = AsymmetricCryptoService(); await cryptoService.initializeDefaultKeyPair(); + // Request permissions before running the app + await _requestPermissions(); + + CallService(); + runApp( MultiProvider( providers: [ Provider( create: (_) => cryptoService, ), - // Add other providers here ], child: Dialer(), ), ); } +Future _requestPermissions() async { + Map statuses = await [ + Permission.phone, + Permission.contacts, + Permission.microphone, + ].request(); + if (statuses.values.every((status) => status.isGranted)) { + print("All required permissions granted"); + const channel = MethodChannel('call_service'); + await channel.invokeMethod('permissionsGranted'); + } else { + print("Permissions denied: ${statuses.entries.where((e) => !e.value.isGranted).map((e) => e.key).join(', ')}"); + } +} + class Dialer extends StatelessWidget { const Dialer({super.key}); @@ -33,11 +55,12 @@ class Dialer extends StatelessWidget { Widget build(BuildContext context) { return ContactState( child: MaterialApp( + navigatorKey: CallService.navigatorKey, theme: ThemeData( - brightness: Brightness.dark + brightness: Brightness.dark, ), home: SafeArea(child: MyHomePage()), - ) + ), ); } -} +} \ No newline at end of file diff --git a/dialer/lib/services/call_service.dart b/dialer/lib/services/call_service.dart index c07027e..d42326e 100644 --- a/dialer/lib/services/call_service.dart +++ b/dialer/lib/services/call_service.dart @@ -1,26 +1,154 @@ +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import '../features/call/call_page.dart'; +import '../features/call/incoming_call_page.dart'; // Import the new page -// Service to manage call-related operations class CallService { static const MethodChannel _channel = MethodChannel('call_service'); + static String? currentPhoneNumber; + static bool _isCallPageVisible = false; - // Function to make a GSM call - Future makeGsmCall(String phoneNumber) async { + static final GlobalKey navigatorKey = GlobalKey(); + + CallService() { + _channel.setMethodCallHandler((call) async { + final context = navigatorKey.currentContext; + print('CallService: Received method ${call.method} with args ${call.arguments}'); + if (context == null) { + print('CallService: Navigator context is null, cannot navigate'); + return; + } + + switch (call.method) { + case "callAdded": + final phoneNumber = call.arguments["callId"] as String; + final state = call.arguments["state"] as String; + currentPhoneNumber = phoneNumber.replaceFirst('tel:', ''); + print('CallService: Call added, number: $currentPhoneNumber, state: $state'); + if (state == "ringing") { + _navigateToIncomingCallPage(context); + } else { + _navigateToCallPage(context); + } + break; + case "callStateChanged": + final state = call.arguments["state"] as String; + print('CallService: State changed to $state'); + if (state == "disconnected" || state == "disconnecting") { + _closeCallPage(context); + } else if (state == "active" || state == "dialing") { + _navigateToCallPage(context); + } else if (state == "ringing") { + _navigateToIncomingCallPage(context); + } + break; + case "callEnded": + case "callRemoved": + print('CallService: Call ended/removed'); + _closeCallPage(context); + currentPhoneNumber = null; + break; + } + }); + } + + void _navigateToCallPage(BuildContext context) { + if (_isCallPageVisible && ModalRoute.of(context)?.settings.name == '/call') { + print('CallService: CallPage already visible, skipping navigation'); + return; + } + print('CallService: Navigating to CallPage'); + Navigator.pushReplacement( + context, + MaterialPageRoute( + settings: const RouteSettings(name: '/call'), + builder: (context) => CallPage( + displayName: currentPhoneNumber!, + phoneNumber: currentPhoneNumber!, + thumbnail: null, + ), + ), + ).then((_) { + _isCallPageVisible = false; + }); + _isCallPageVisible = true; + } + + void _navigateToIncomingCallPage(BuildContext context) { + if (_isCallPageVisible && ModalRoute.of(context)?.settings.name == '/incoming_call') { + print('CallService: IncomingCallPage already visible, skipping navigation'); + return; + } + print('CallService: Navigating to IncomingCallPage'); + Navigator.push( + context, + MaterialPageRoute( + settings: const RouteSettings(name: '/incoming_call'), + builder: (context) => IncomingCallPage( + displayName: currentPhoneNumber!, + phoneNumber: currentPhoneNumber!, + thumbnail: null, + ), + ), + ).then((_) { + _isCallPageVisible = false; + }); + _isCallPageVisible = true; + } + + void _closeCallPage(BuildContext context) { + if (!_isCallPageVisible) { + print('CallService: CallPage not visible, skipping pop'); + return; + } + if (Navigator.canPop(context)) { + print('CallService: Popping CallPage'); + Navigator.pop(context); + _isCallPageVisible = false; + } + } + + Future makeGsmCall( + BuildContext context, { + required String phoneNumber, + String? displayName, + Uint8List? thumbnail, + }) async { try { - await _channel.invokeMethod('makeGsmCall', {"phoneNumber": phoneNumber}); + currentPhoneNumber = phoneNumber; + print('CallService: Making GSM call to $phoneNumber'); + final result = await _channel.invokeMethod('makeGsmCall', {"phoneNumber": phoneNumber}); + print('CallService: makeGsmCall result: $result'); + if (result["status"] != "calling") { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Failed to initiate call")), + ); + } } catch (e) { - print("Error making call: $e"); + print("CallService: Error making call: $e"); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Error making call: $e")), + ); rethrow; } } - // Function to hang up the current call - Future hangUpCall() async { + Future hangUpCall(BuildContext context) async { try { - await _channel.invokeMethod('hangUpCall'); + print('CallService: Hanging up call'); + final result = await _channel.invokeMethod('hangUpCall'); + print('CallService: hangUpCall result: $result'); + if (result["status"] != "ended") { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Failed to end call")), + ); + } } catch (e) { - print("Error hanging up call: $e"); + print("CallService: Error hanging up call: $e"); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Error hanging up call: $e")), + ); rethrow; } } -} +} \ No newline at end of file From 608218175abc8f821deff08036bdbe7e1f540a9d Mon Sep 17 00:00:00 2001 From: alexis Date: Sat, 5 Apr 2025 08:27:20 +0000 Subject: [PATCH 06/13] fix: improve history page performance and state management (#47) smooth switch to history page Co-authored-by: AlexisDanlos <91090088+AlexisDanlos@users.noreply.github.com> Co-authored-by: stcb <21@stcb.cc> Reviewed-on: https://git.gmoker.com/icing/monorepo/pulls/47 Co-authored-by: alexis Co-committed-by: alexis --- dialer/lib/features/history/history_page.dart | 16 +++++++++++----- dialer/lib/features/home/home_page.dart | 2 +- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/dialer/lib/features/history/history_page.dart b/dialer/lib/features/history/history_page.dart index 2ce20b8..3c5b1f1 100644 --- a/dialer/lib/features/history/history_page.dart +++ b/dialer/lib/features/history/history_page.dart @@ -37,7 +37,7 @@ class HistoryPage extends StatefulWidget { } class _HistoryPageState extends State - with SingleTickerProviderStateMixin { + with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin { List histories = []; bool loading = true; int? _expandedIndex; @@ -47,10 +47,13 @@ class _HistoryPageState extends State // Create a MethodChannel instance. static const MethodChannel _channel = MethodChannel('com.example.calllog'); + @override + bool get wantKeepAlive => true; // Preserve state when switching pages + @override void didChangeDependencies() { super.didChangeDependencies(); - if (loading) { + if (loading && histories.isEmpty) { _buildHistories(); } } @@ -149,9 +152,9 @@ class _HistoryPageState extends State List contacts = contactState.contacts; List callHistories = []; - // Process each log entry. - for (var entry in nativeLogs) { - // Each entry is a Map with keys: number, type, date, duration. + // Process each log entry with intermittent yields to avoid freezing. + for (int i = 0; i < nativeLogs.length; i++) { + final entry = nativeLogs[i]; final String number = entry['number'] ?? ''; if (number.isEmpty) continue; @@ -197,6 +200,8 @@ class _HistoryPageState extends State callHistories .add(History(matchedContact, callDate, callType, callStatus, 1)); + // Yield every 10 iterations to avoid blocking the UI. + if (i % 10 == 0) await Future.delayed(Duration(milliseconds: 1)); } // Sort histories by most recent. @@ -272,6 +277,7 @@ class _HistoryPageState extends State @override Widget build(BuildContext context) { + super.build(context); // required due to AutomaticKeepAliveClientMixin final contactState = ContactState.of(context); if (loading || contactState.loading) { diff --git a/dialer/lib/features/home/home_page.dart b/dialer/lib/features/home/home_page.dart index 65adaa8..dcffb07 100644 --- a/dialer/lib/features/home/home_page.dart +++ b/dialer/lib/features/home/home_page.dart @@ -25,7 +25,7 @@ class _MyHomePageState extends State void initState() { super.initState(); // Set the TabController length to 4 - _tabController = TabController(length: 4, vsync: this, initialIndex: 1); + _tabController = TabController(length: 4, vsync: this, initialIndex: 2); _tabController.addListener(_handleTabIndex); _fetchContacts(); } From 58ccd3a24c78820292e060672cbce4d1d83346d1 Mon Sep 17 00:00:00 2001 From: Florian Griffon Date: Sat, 5 Apr 2025 08:36:52 +0000 Subject: [PATCH 07/13] feat: improved composition page (#51) Add '+' on Long Press of 0 Add grey '+' below '0' Green Call Button at the bottom Add a contact button below contact list Delete last character and not whole line Delete whole line on long press Reviewed-on: https://git.gmoker.com/icing/monorepo/pulls/51 Co-authored-by: Florian Griffon Co-committed-by: Florian Griffon --- .../lib/features/composition/composition.dart | 233 ++++++++++-------- 1 file changed, 136 insertions(+), 97 deletions(-) diff --git a/dialer/lib/features/composition/composition.dart b/dialer/lib/features/composition/composition.dart index b807ce4..6fb9962 100644 --- a/dialer/lib/features/composition/composition.dart +++ b/dialer/lib/features/composition/composition.dart @@ -4,7 +4,6 @@ import 'package:url_launcher/url_launcher.dart'; import '../../services/contact_service.dart'; import '../../services/obfuscate_service.dart'; import '../../services/call_service.dart'; -import '../contacts/widgets/add_contact_button.dart'; class CompositionPage extends StatefulWidget { const CompositionPage({super.key}); @@ -18,11 +17,7 @@ class _CompositionPageState extends State { List _allContacts = []; List _filteredContacts = []; final ContactService _contactService = ContactService(); - - // Instantiate the ObfuscateService final ObfuscateService _obfuscateService = ObfuscateService(); - - // Instantiate the CallService final CallService _callService = CallService(); @override @@ -40,8 +35,13 @@ class _CompositionPageState extends State { void _filterContacts() { setState(() { _filteredContacts = _allContacts.where((contact) { - final phoneMatch = contact.phones.any((phone) => - phone.number.replaceAll(RegExp(r'\D'), '').contains(dialedNumber)); + bool phoneMatch = contact.phones.any((phone) { + final rawPhoneNumber = phone.number; + final strippedPhoneNumber = rawPhoneNumber.replaceAll(RegExp(r'\D'), ''); + final strippedDialedNumber = dialedNumber.replaceAll(RegExp(r'\D'), ''); + return rawPhoneNumber.contains(dialedNumber) || + strippedPhoneNumber.contains(strippedDialedNumber); + }); final nameMatch = contact.displayName .toLowerCase() .contains(dialedNumber.toLowerCase()); @@ -57,6 +57,13 @@ class _CompositionPageState extends State { }); } + void _onPlusPress() { + setState(() { + dialedNumber += '+'; + _filterContacts(); + }); + } + void _onDeletePress() { setState(() { if (dialedNumber.isNotEmpty) { @@ -73,7 +80,6 @@ class _CompositionPageState extends State { }); } - // Function to call a contact's number using the CallService void _makeCall(String phoneNumber) async { try { await _callService.makeGsmCall(context, phoneNumber: phoneNumber); @@ -82,10 +88,12 @@ class _CompositionPageState extends State { }); } catch (e) { debugPrint("Error making call: $e"); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to make call: $e')), + ); } } - // Function to send an SMS to a contact's number void _launchSms(String phoneNumber) async { final uri = Uri(scheme: 'sms', path: phoneNumber); if (await canLaunchUrl(uri)) { @@ -95,6 +103,20 @@ class _CompositionPageState extends State { } } + void _addContact() async { + if (await FlutterContacts.requestPermission()) { + final newContact = Contact() + ..phones = [Phone(dialedNumber.isNotEmpty ? dialedNumber : '')]; + final updatedContact = await FlutterContacts.openExternalInsert(newContact); + if (updatedContact != null) { + _fetchContacts(); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Contact added successfully!')), + ); + } + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -103,7 +125,6 @@ class _CompositionPageState extends State { children: [ Column( children: [ - // Top half: Display contacts matching dialed number Expanded( flex: 2, child: Container( @@ -115,57 +136,51 @@ class _CompositionPageState extends State { children: [ Expanded( child: ListView( - children: _filteredContacts.isNotEmpty - ? _filteredContacts.map((contact) { - final phoneNumber = contact.phones.isNotEmpty - ? contact.phones.first.number - : 'No phone number'; - return ListTile( - title: Text( - _obfuscateService.obfuscateData(contact.displayName), - style: const TextStyle(color: Colors.white), + children: [ + ..._filteredContacts.map((contact) { + final phoneNumber = contact.phones.isNotEmpty + ? contact.phones.first.number + : 'No phone number'; + return ListTile( + title: Text( + _obfuscateService.obfuscateData(contact.displayName), + style: const TextStyle(color: Colors.white), + ), + subtitle: Text( + _obfuscateService.obfuscateData(phoneNumber), + style: const TextStyle(color: Colors.grey), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Icon(Icons.phone, color: Colors.green[300], size: 20), + onPressed: () => _makeCall(phoneNumber), + ), + IconButton( + icon: Icon(Icons.message, color: Colors.blue[300], size: 20), + onPressed: () => _launchSms(phoneNumber), + ), + ], + ), + onTap: () {}, + ); + }).toList(), + ListTile( + title: const Text( + 'Add a contact', + style: TextStyle(color: Colors.white), ), - subtitle: Text( - _obfuscateService.obfuscateData(phoneNumber), - style: const TextStyle(color: Colors.grey), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Call button (Now using CallService) - IconButton( - icon: Icon(Icons.phone, - color: Colors.green[300], - size: 20), - onPressed: () { - _makeCall(phoneNumber); // Make a call using CallService - }, - ), - // Message button - IconButton( - icon: Icon(Icons.message, - color: Colors.blue[300], - size: 20), - onPressed: () { - _launchSms(phoneNumber); - }, - ), - ], - ), - onTap: () { - // Handle contact selection if needed - }, - ); - }).toList() - : [], + trailing: Icon(Icons.add, color: Colors.grey[600]), + onTap: _addContact, + ), + ], ), ), ], ), ), ), - - // Bottom half: Dialpad and Dialed number display with erase button Expanded( flex: 2, child: Container( @@ -173,7 +188,6 @@ class _CompositionPageState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.start, children: [ - // Display dialed number with erase button Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -182,61 +196,57 @@ class _CompositionPageState extends State { alignment: Alignment.center, child: Text( dialedNumber, - style: const TextStyle( - fontSize: 24, color: Colors.white), + style: const TextStyle(fontSize: 24, color: Colors.white), overflow: TextOverflow.ellipsis, ), ), ), - IconButton( - onPressed: _onClearPress, - icon: const Icon(Icons.backspace, - color: Colors.white), + GestureDetector( + onTap: _onDeletePress, + onLongPress: _onClearPress, + child: const Padding( + padding: EdgeInsets.all(8.0), + child: Icon(Icons.backspace, color: Colors.white), + ), ), ], ), const SizedBox(height: 10), - - // Dialpad Expanded( child: SingleChildScrollView( child: Column( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ Row( - mainAxisAlignment: - MainAxisAlignment.spaceEvenly, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - _buildDialButton('1'), - _buildDialButton('2'), - _buildDialButton('3'), + _buildDialButton('1', Colors.white), + _buildDialButton('2', Colors.white), + _buildDialButton('3', Colors.white), ], ), Row( - mainAxisAlignment: - MainAxisAlignment.spaceEvenly, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - _buildDialButton('4'), - _buildDialButton('5'), - _buildDialButton('6'), + _buildDialButton('4', Colors.white), + _buildDialButton('5', Colors.white), + _buildDialButton('6', Colors.white), ], ), Row( - mainAxisAlignment: - MainAxisAlignment.spaceEvenly, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - _buildDialButton('7'), - _buildDialButton('8'), - _buildDialButton('9'), + _buildDialButton('7', Colors.white), + _buildDialButton('8', Colors.white), + _buildDialButton('9', Colors.white), ], ), Row( - mainAxisAlignment: - MainAxisAlignment.spaceEvenly, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ - _buildDialButton('*'), - _buildDialButton('0'), - _buildDialButton('#'), + _buildDialButton('*', const Color.fromRGBO(117, 117, 117, 1)), + _buildDialButtonWithPlus('0'), + _buildDialButton('#', const Color.fromRGBO(117, 117, 117, 1)), ], ), ], @@ -249,26 +259,28 @@ class _CompositionPageState extends State { ), ], ), - - // Add Contact Button Positioned( bottom: 20.0, left: 0, right: 0, child: Center( - child: AddContactButton(), + child: ElevatedButton( + onPressed: dialedNumber.isNotEmpty ? () => _makeCall(dialedNumber) : null, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green[700], + shape: const CircleBorder(), + padding: const EdgeInsets.all(20), + ), + child: const Icon(Icons.phone, color: Colors.white, size: 30), + ), ), ), - - // Top Row with Back Arrow Positioned( top: 40.0, left: 16.0, child: IconButton( icon: const Icon(Icons.arrow_back, color: Colors.white), - onPressed: () { - Navigator.pop(context); - }, + onPressed: () => Navigator.pop(context), ), ), ], @@ -276,7 +288,7 @@ class _CompositionPageState extends State { ); } - Widget _buildDialButton(String number) { + Widget _buildDialButton(String number, Color textColor) { return ElevatedButton( onPressed: () => _onNumberPress(number), style: ElevatedButton.styleFrom( @@ -286,11 +298,38 @@ class _CompositionPageState extends State { ), child: Text( number, - style: const TextStyle( - fontSize: 24, - color: Colors.white, - ), + style: TextStyle(fontSize: 24, color: textColor), ), ); } -} + + Widget _buildDialButtonWithPlus(String number) { + return Stack( + alignment: Alignment.center, + children: [ + GestureDetector( + onLongPress: _onPlusPress, + child: ElevatedButton( + onPressed: () => _onNumberPress(number), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.black, + shape: const CircleBorder(), + padding: const EdgeInsets.all(16), + ), + child: Text( + number, + style: const TextStyle(fontSize: 24, color: Colors.white), + ), + ), + ), + Positioned( + bottom: 8, + child: Text( + '+', + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + ), + ], + ); + } +} \ No newline at end of file From b9dd156ecad779b1958826e666da7b5534bb321c Mon Sep 17 00:00:00 2001 From: Florian Griffon Date: Wed, 9 Apr 2025 10:43:31 +0000 Subject: [PATCH 08/13] fix: search bar is non case sensitive and don't have delay (contact page) (#50) Co-authored-by: stcb <21@stcb.cc> Reviewed-on: https://git.gmoker.com/icing/monorepo/pulls/50 Co-authored-by: Florian Griffon Co-committed-by: Florian Griffon --- dialer/lib/features/home/home_page.dart | 149 +++++++++++++----------- 1 file changed, 83 insertions(+), 66 deletions(-) diff --git a/dialer/lib/features/home/home_page.dart b/dialer/lib/features/home/home_page.dart index dcffb07..47ded26 100644 --- a/dialer/lib/features/home/home_page.dart +++ b/dialer/lib/features/home/home_page.dart @@ -10,53 +10,82 @@ import '../../services/contact_service.dart'; import 'package:dialer/features/voicemail/voicemail_page.dart'; import '../contacts/widgets/contact_modal.dart'; - -class _MyHomePageState extends State - with SingleTickerProviderStateMixin { +class _MyHomePageState extends State with SingleTickerProviderStateMixin { late TabController _tabController; List _allContacts = []; List _contactSuggestions = []; final ContactService _contactService = ContactService(); final ObfuscateService _obfuscateService = ObfuscateService(); final TextEditingController _searchController = TextEditingController(); - + late SearchController _searchBarController; + String _rawSearchInput = ''; @override void initState() { super.initState(); - // Set the TabController length to 4 _tabController = TabController(length: 4, vsync: this, initialIndex: 2); _tabController.addListener(_handleTabIndex); + _searchBarController = SearchController(); + _searchBarController.addListener(() { + if (_searchController.text != _searchBarController.text) { + _rawSearchInput = _searchBarController.text; + _searchController.text = _rawSearchInput; + _onSearchChanged(_searchBarController.text); + } + }); _fetchContacts(); } void _fetchContacts() async { _allContacts = await _contactService.fetchContacts(); - setState(() {}); + _contactSuggestions = List.from(_allContacts); + if (mounted) setState(() {}); } void _clearSearch() { _searchController.clear(); + _searchBarController.clear(); + _rawSearchInput = ''; _onSearchChanged(''); } void _onSearchChanged(String query) { setState(() { if (query.isEmpty) { - _contactSuggestions = List.from(_allContacts); // Reset suggestions + _contactSuggestions = List.from(_allContacts); } else { + final normalizedQuery = _normalizeString(query.toLowerCase()); _contactSuggestions = _allContacts.where((contact) { - return contact.displayName - .toLowerCase() - .contains(query.toLowerCase()); + final normalizedName = _normalizeString(contact.displayName.toLowerCase()); + return normalizedName.contains(normalizedQuery); }).toList(); } }); } + String _normalizeString(String input) { + const accentMap = { + 'àáâãäå': 'a', + 'èéêë': 'e', + 'ìíîï': 'i', + 'òóôõö': 'o', + 'ùúûü': 'u', + 'ç': 'c', + 'ñ': 'n', + }; + String normalized = input; + accentMap.forEach((accents, base) { + for (var accent in accents.split('')) { + normalized = normalized.replaceAll(accent, base); + } + }); + return normalized; + } + @override void dispose() { _searchController.dispose(); + _searchBarController.dispose(); _tabController.removeListener(_handleTabIndex); _tabController.dispose(); super.dispose(); @@ -69,19 +98,18 @@ class _MyHomePageState extends State void _toggleFavorite(Contact contact) async { try { if (await FlutterContacts.requestPermission()) { - Contact? fullContact = await FlutterContacts.getContact(contact.id, - withProperties: true, - withAccounts: true, - withPhoto: true, - withThumbnail: true); + Contact? fullContact = await FlutterContacts.getContact( + contact.id, + withProperties: true, + withAccounts: true, + withPhoto: true, + withThumbnail: true, + ); if (fullContact != null) { fullContact.isStarred = !fullContact.isStarred; await FlutterContacts.updateContact(fullContact); - setState(() { - // Updating the contact list after toggling the favorite - _fetchContacts(); - }); + _fetchContacts(); } } else { print("Could not fetch contact details"); @@ -100,7 +128,6 @@ class _MyHomePageState extends State backgroundColor: Colors.black, body: Column( children: [ - // Persistent Search Bar Padding( padding: const EdgeInsets.only( top: 24.0, @@ -118,35 +145,33 @@ class _MyHomePageState extends State border: Border.all(color: Colors.grey.shade800, width: 1), ), child: SearchAnchor( - builder: - (BuildContext context, SearchController controller) { + searchController: _searchBarController, + builder: (BuildContext context, SearchController controller) { return GestureDetector( onTap: () { - controller.openView(); // Open the search view + controller.openView(); }, child: Container( decoration: BoxDecoration( color: const Color.fromARGB(255, 30, 30, 30), borderRadius: BorderRadius.circular(12.0), - border: Border.all( - color: Colors.grey.shade800, width: 1), + border: Border.all(color: Colors.grey.shade800, width: 1), ), - padding: const EdgeInsets.symmetric( - vertical: 12.0, horizontal: 16.0), + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0), child: Row( children: [ - const Icon(Icons.search, - color: Colors.grey, size: 24.0), + const Icon(Icons.search, color: Colors.grey, size: 24.0), const SizedBox(width: 8.0), - Text( - _searchController.text.isEmpty - ? 'Search contacts' - : _searchController.text, - style: const TextStyle( - color: Colors.grey, fontSize: 16.0), + Expanded( + child: Text( + _rawSearchInput.isEmpty + ? 'Search contacts' + : _rawSearchInput, + style: const TextStyle(color: Colors.grey, fontSize: 16.0), + overflow: TextOverflow.ellipsis, + ), ), - const Spacer(), - if (_searchController.text.isNotEmpty) + if (_rawSearchInput.isNotEmpty) GestureDetector( onTap: _clearSearch, child: const Icon( @@ -161,23 +186,24 @@ class _MyHomePageState extends State ); }, viewOnChanged: (query) { - _onSearchChanged(query); // Update immediately + + if (_searchBarController.text != query) { + _rawSearchInput = query; + _searchBarController.text = query; + _searchController.text = query; + } + _onSearchChanged(query); }, - suggestionsBuilder: - (BuildContext context, SearchController controller) { + suggestionsBuilder: (BuildContext context, SearchController controller) { return _contactSuggestions.map((contact) { return ListTile( key: ValueKey(contact.id), - title: Text(_obfuscateService.obfuscateData(contact.displayName), - style: const TextStyle(color: Colors.white)), + title: Text( + _obfuscateService.obfuscateData(contact.displayName), + style: const TextStyle(color: Colors.white), + ), onTap: () { - // Clear the search text input - controller.text = ''; - - // Close the search view controller.closeView(contact.displayName); - - // Show the ContactModal when a contact is tapped showModalBottomSheet( context: context, isScrollControlled: true, @@ -186,34 +212,28 @@ class _MyHomePageState extends State return ContactModal( contact: contact, onEdit: () async { - if (await FlutterContacts - .requestPermission()) { - final updatedContact = - await FlutterContacts - .openExternalEdit(contact.id); + if (await FlutterContacts.requestPermission()) { + final updatedContact = await FlutterContacts + .openExternalEdit(contact.id); if (updatedContact != null) { _fetchContacts(); Navigator.of(context).pop(); - ScaffoldMessenger.of(context) - .showSnackBar( + ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( '${contact.displayName} updated successfully!'), ), ); } else { - ScaffoldMessenger.of(context) - .showSnackBar( + ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text( - 'Edit canceled or failed.'), + content: Text('Edit canceled or failed.'), ), ); } } }, - onToggleFavorite: () => - _toggleFavorite(contact), + onToggleFavorite: () => _toggleFavorite(contact), isFavorite: contact.isStarred, ); }, @@ -225,7 +245,6 @@ class _MyHomePageState extends State ), ), ), - // 3-dot menu PopupMenuButton( icon: const Icon(Icons.more_vert, color: Colors.white), itemBuilder: (BuildContext context) => [ @@ -238,8 +257,7 @@ class _MyHomePageState extends State if (value == 'settings') { Navigator.push( context, - MaterialPageRoute( - builder: (context) => const SettingsPage()), + MaterialPageRoute(builder: (context) => const SettingsPage()), ); } }, @@ -247,7 +265,6 @@ class _MyHomePageState extends State ], ), ), - // Main content with TabBarView Expanded( child: Stack( children: [ @@ -322,4 +339,4 @@ class MyHomePage extends StatefulWidget { @override _MyHomePageState createState() => _MyHomePageState(); -} +} \ No newline at end of file From 22941f78d081752b1f1898d5e412cf07a8e53f49 Mon Sep 17 00:00:00 2001 From: stcb <21@stcb.cc> Date: Tue, 15 Apr 2025 12:54:41 +0000 Subject: [PATCH 09/13] Argiliser exemples (#53) Reviewed-on: https://git.gmoker.com/icing/monorepo/pulls/53 --- docs/beta_test_plan.md | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/docs/beta_test_plan.md b/docs/beta_test_plan.md index dfefa39..d4d5e4d 100644 --- a/docs/beta_test_plan.md +++ b/docs/beta_test_plan.md @@ -27,14 +27,13 @@ The protocol definition will include as completed: - Handshakes - Real-time data-stream encryption (and decryption) - Encrypted stream compression -- Transmission over audio stream -- Minimal error correction in audio-based transmission -- Error handling and user prevention +- Transmission over audio stream (at least one modulation type) +- First steps in FEC (Forward Error Correction): detecting half of transmission errors And should include prototype or scratches functionalities, among which: -- Embedded silent data transmission (silently transmit light data during an encrypted phone call) +- Embedded silent data transmission (such as DTMF) - On-the-fly key exchange (does not require prior key exchange, sacrifying some security) -- Strong error correction +- Stronger FEC: detecting >80%, correcting 20% of transmission errors #### The Icing dialer (based on Icing kotlin library, an Icing protocol implementation) @@ -128,16 +127,15 @@ The remote bank advisor asks him to authenticate, making him type his password o By using the Icing protocol, not only would Jeff and the bank be assured that the informations are transmitted safely, but also that the call is coming from Jeff's phone and not an impersonator. -Elise is a 42 years-old extreme reporter. -After interviewing Russians opposition's leader, the FSB is looking to interview her. -She tries to stay discreet and hidden, but those measures constrains her to barely receive cellular network. -She suspects her phone line to be monitored, so the best she can do to call safely, is to use her Icing dialer. +Elise, 42 years-old, is a journalist covering sensitive topics. +Her work draws attention from people who want to know what she's saying - and to whom. +Forced to stay discreet, with unreliable signal and a likely monitored phone line, +she uses Icing dialer to make secure calls without exposing herself. -Paul, a 22 years-old developer working for a big company, decides to go to China for vacations. +Paul, a 22 years-old developer, is enjoying its vacations abroad. But everything goes wrong! The company's product he works on, is failling in the middle of the day and no one is -qualified to fix it. Paul doesn't have WiFi and his phone plan only covers voice calls in China. -With Icing dialer, he can call his collegues and help fix the -problem, safe from potential Chinese spies. +qualified to fix it. Paul doesn't have WiFi and his phone plan only covers voice calls in his country. +With Icing dialer, he can call his collegues and help fix the problem, completely safe. ## Evaluation Criteria ### Protocol and lib From ec1779bb15f62acdf0ec4f5d7619582a0038ad10 Mon Sep 17 00:00:00 2001 From: florian Date: Thu, 17 Apr 2025 12:26:32 +0000 Subject: [PATCH 10/13] callNotifications and various fix related to calls (#49) Reviewed-on: https://git.gmoker.com/icing/monorepo/pulls/49 Co-authored-by: florian Co-committed-by: florian --- .../android/app/src/main/AndroidManifest.xml | 2 + .../icing/dialer/activities/MainActivity.kt | 134 ++++++++++++++++-- .../icing/dialer/services/MyInCallService.kt | 101 ++++++++++++- dialer/lib/features/call/call_page.dart | 62 +++++--- dialer/lib/services/call_service.dart | 113 +++++++++++---- 5 files changed, 345 insertions(+), 67 deletions(-) diff --git a/dialer/android/app/src/main/AndroidManifest.xml b/dialer/android/app/src/main/AndroidManifest.xml index fcaf71f..4c1cb5f 100644 --- a/dialer/android/app/src/main/AndroidManifest.xml +++ b/dialer/android/app/src/main/AndroidManifest.xml @@ -12,6 +12,8 @@ + + ? = null + private var wasPhoneLocked: Boolean = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Log.d(TAG, "onCreate started") - Log.d(TAG, "Waiting for Flutter to signal permissions") + wasPhoneLocked = intent.getBooleanExtra("wasPhoneLocked", false) + Log.d(TAG, "Was phone locked at start: $wasPhoneLocked") + updateLockScreenFlags(intent) + handleIncomingCallIntent(intent) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + wasPhoneLocked = intent.getBooleanExtra("wasPhoneLocked", false) + Log.d(TAG, "onNewIntent, wasPhoneLocked: $wasPhoneLocked") + updateLockScreenFlags(intent) + handleIncomingCallIntent(intent) + } + + private fun updateLockScreenFlags(intent: Intent?) { + val isIncomingCall = intent?.getBooleanExtra("isIncomingCall", false) ?: false + if (isIncomingCall && wasPhoneLocked) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true) + setTurnScreenOn(true) + } else { + @Suppress("DEPRECATION") + window.addFlags( + android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + ) + } + Log.d(TAG, "Enabled showWhenLocked and turnScreenOn for incoming call") + } else { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(false) + setTurnScreenOn(false) + } else { + @Suppress("DEPRECATION") + window.clearFlags( + android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + ) + } + Log.d(TAG, "Disabled showWhenLocked and turnScreenOn for normal usage") + } } override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) Log.d(TAG, "Configuring Flutter engine") - MyInCallService.channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALL_CHANNEL) + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALL_CHANNEL) .setMethodCallHandler { call, result -> when (call.method) { "permissionsGranted" -> { Log.d(TAG, "Received permissionsGranted from Flutter") + pendingIncomingCall?.let { (phoneNumber, showScreen) -> + if (showScreen) { + MyInCallService.channel?.invokeMethod("incomingCallFromNotification", mapOf( + "phoneNumber" to phoneNumber, + "wasPhoneLocked" to wasPhoneLocked + )) + pendingIncomingCall = null + } + } checkAndRequestDefaultDialer() result.success(true) } @@ -60,37 +112,66 @@ class MainActivity : FlutterActivity() { } } "hangUpCall" -> { - val success = CallService.hangUpCall(this) + val success = MyInCallService.currentCall?.let { + it.disconnect() + Log.d(TAG, "Call disconnected") + MyInCallService.channel?.invokeMethod("callEnded", mapOf( + "callId" to it.details.handle.toString(), + "wasPhoneLocked" to wasPhoneLocked + )) + true + } ?: false if (success) { result.success(mapOf("status" to "ended")) + if (wasPhoneLocked) { + Log.d(TAG, "Finishing and removing task after hangup, phone was locked") + finishAndRemoveTask() + } } else { - result.error("HANGUP_FAILED", "Failed to end call", null) + Log.w(TAG, "No active call to hang up") + result.error("HANGUP_FAILED", "No active call to hang up", null) } } "answerCall" -> { val success = MyInCallService.currentCall?.let { - it.answer(0) // 0 for default video state (audio-only) + it.answer(0) Log.d(TAG, "Answered call") true } ?: false if (success) { result.success(mapOf("status" to "answered")) } else { + Log.w(TAG, "No active call to answer") result.error("ANSWER_FAILED", "No active call to answer", null) } } + "callEndedFromFlutter" -> { + Log.d(TAG, "Call ended from Flutter, wasPhoneLocked: $wasPhoneLocked") + if (wasPhoneLocked) { + finishAndRemoveTask() + Log.d(TAG, "Finishing and removing task after call ended, phone was locked") + } + result.success(true) + } else -> result.notImplemented() } } MethodChannel(flutterEngine.dartExecutor.binaryMessenger, KEYSTORE_CHANNEL) - .setMethodCallHandler { call, result -> KeystoreHelper(call, result).handleMethodCall() } + .setMethodCallHandler { call, result -> + KeystoreHelper(call, result).handleMethodCall() + } MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALLLOG_CHANNEL) .setMethodCallHandler { call, result -> if (call.method == "getCallLogs") { - val callLogs = getCallLogs() - result.success(callLogs) + if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALL_LOG) == PackageManager.PERMISSION_GRANTED) { + val callLogs = getCallLogs() + result.success(callLogs) + } else { + requestPermissions(arrayOf(Manifest.permission.READ_CALL_LOG), REQUEST_CODE_CALL_LOG_PERMISSION) + result.error("PERMISSION_DENIED", "Call log permission not granted", null) + } } else { result.notImplemented() } @@ -109,8 +190,6 @@ class MainActivity : FlutterActivity() { val intent = roleManager.createRequestRoleIntent(RoleManager.ROLE_DIALER) startActivityForResult(intent, REQUEST_CODE_SET_DEFAULT_DIALER) Log.d(TAG, "Launched RoleManager intent for default dialer on API 29+") - } else { - Log.d(TAG, "RoleManager: Available=${roleManager.isRoleAvailable(RoleManager.ROLE_DIALER)}, Held=${roleManager.isRoleHeld(RoleManager.ROLE_DIALER)}") } } else { val intent = Intent(TelecomManager.ACTION_CHANGE_DEFAULT_DIALER) @@ -148,6 +227,18 @@ class MainActivity : FlutterActivity() { } } + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == REQUEST_CODE_CALL_LOG_PERMISSION) { + if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + Log.d(TAG, "Call log permission granted") + MyInCallService.channel?.invokeMethod("callLogPermissionGranted", null) + } else { + Log.w(TAG, "Call log permission denied") + } + } + } + private fun getCallLogs(): List> { val logsList = mutableListOf>() val cursor: Cursor? = contentResolver.query( @@ -171,4 +262,25 @@ class MainActivity : FlutterActivity() { } return logsList } + + private fun handleIncomingCallIntent(intent: Intent?) { + intent?.let { + if (it.getBooleanExtra("isIncomingCall", false)) { + val phoneNumber = it.getStringExtra("phoneNumber") + val showScreen = it.getBooleanExtra("showIncomingCallScreen", false) + Log.d(TAG, "Received incoming call intent for $phoneNumber, showScreen=$showScreen, wasPhoneLocked=$wasPhoneLocked") + if (showScreen) { + if (MyInCallService.channel != null) { + MyInCallService.channel?.invokeMethod("incomingCallFromNotification", mapOf( + "phoneNumber" to phoneNumber, + "wasPhoneLocked" to wasPhoneLocked + )) + } else { + pendingIncomingCall = Pair(phoneNumber, true) + Log.d(TAG, "Flutter channel not ready, storing pending call: $phoneNumber") + } + } + } + } + } } \ No newline at end of file diff --git a/dialer/android/app/src/main/kotlin/com/icing/dialer/services/MyInCallService.kt b/dialer/android/app/src/main/kotlin/com/icing/dialer/services/MyInCallService.kt index 48b7edd..b5a4c8a 100644 --- a/dialer/android/app/src/main/kotlin/com/icing/dialer/services/MyInCallService.kt +++ b/dialer/android/app/src/main/kotlin/com/icing/dialer/services/MyInCallService.kt @@ -1,8 +1,17 @@ package com.icing.dialer.services +import android.app.KeyguardManager +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build import android.telecom.Call import android.telecom.InCallService import android.util.Log +import androidx.core.app.NotificationCompat +import com.icing.dialer.activities.MainActivity import io.flutter.plugin.common.MethodChannel class MyInCallService : InCallService() { @@ -10,6 +19,9 @@ class MyInCallService : InCallService() { var channel: MethodChannel? = null var currentCall: Call? = null private const val TAG = "MyInCallService" + private const val NOTIFICATION_CHANNEL_ID = "incoming_call_channel" + private const val NOTIFICATION_ID = 1 + var wasPhoneLocked: Boolean = false } private val callCallback = object : Call.Callback() { @@ -26,12 +38,22 @@ class MyInCallService : InCallService() { Log.d(TAG, "State changed: $stateStr for call ${call.details.handle}") channel?.invokeMethod("callStateChanged", mapOf( "callId" to call.details.handle.toString(), - "state" to stateStr + "state" to stateStr, + "wasPhoneLocked" to wasPhoneLocked )) - if (state == Call.STATE_DISCONNECTED || state == Call.STATE_DISCONNECTING) { - Log.d(TAG, "Call ended: ${call.details.handle}") - channel?.invokeMethod("callEnded", mapOf("callId" to call.details.handle.toString())) + if (state == Call.STATE_RINGING) { + val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + wasPhoneLocked = keyguardManager.isKeyguardLocked + Log.d(TAG, "Phone locked at ringing: $wasPhoneLocked") + showIncomingCallScreen(call.details.handle.toString().replace("tel:", "")) + } else if (state == Call.STATE_DISCONNECTED || state == Call.STATE_DISCONNECTING) { + Log.d(TAG, "Call ended: ${call.details.handle}, wasPhoneLocked: $wasPhoneLocked") + channel?.invokeMethod("callEnded", mapOf( + "callId" to call.details.handle.toString(), + "wasPhoneLocked" to wasPhoneLocked + )) currentCall = null + cancelNotification() } } } @@ -43,22 +65,32 @@ class MyInCallService : InCallService() { Call.STATE_DIALING -> "dialing" Call.STATE_ACTIVE -> "active" Call.STATE_RINGING -> "ringing" - else -> "dialing" // Default for outgoing + else -> "dialing" } Log.d(TAG, "Call added: ${call.details.handle}, state: $stateStr") channel?.invokeMethod("callAdded", mapOf( "callId" to call.details.handle.toString(), "state" to stateStr )) + if (stateStr == "ringing") { + val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + wasPhoneLocked = keyguardManager.isKeyguardLocked + Log.d(TAG, "Phone locked at call added: $wasPhoneLocked") + showIncomingCallScreen(call.details.handle.toString().replace("tel:", "")) + } call.registerCallback(callCallback) } override fun onCallRemoved(call: Call) { super.onCallRemoved(call) - Log.d(TAG, "Call removed: ${call.details.handle}") + Log.d(TAG, "Call removed: ${call.details.handle}, wasPhoneLocked: $wasPhoneLocked") call.unregisterCallback(callCallback) - channel?.invokeMethod("callRemoved", mapOf("callId" to call.details.handle.toString())) + channel?.invokeMethod("callRemoved", mapOf( + "callId" to call.details.handle.toString(), + "wasPhoneLocked" to wasPhoneLocked + )) currentCall = null + cancelNotification() } override fun onCallAudioStateChanged(state: android.telecom.CallAudioState) { @@ -66,4 +98,59 @@ class MyInCallService : InCallService() { Log.d(TAG, "Audio state changed: route=${state.route}") channel?.invokeMethod("audioStateChanged", mapOf("route" to state.route)) } + + private fun showIncomingCallScreen(phoneNumber: String) { + val intent = Intent(this, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + putExtra("phoneNumber", phoneNumber) + putExtra("isIncomingCall", true) + putExtra("showIncomingCallScreen", true) + putExtra("wasPhoneLocked", wasPhoneLocked) + } + + val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager + if (keyguardManager.isKeyguardLocked) { + startActivity(intent) + Log.d(TAG, "Launched MainActivity directly for locked screen, phoneNumber: $phoneNumber") + } else { + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + "Incoming Calls", + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = "Notifications for incoming calls" + enableVibration(true) + setShowBadge(true) + } + notificationManager.createNotificationChannel(channel) + } + + val pendingIntent = PendingIntent.getActivity( + this, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0) + ) + + val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_dialog_alert) + .setContentTitle("Incoming Call") + .setContentText("Call from $phoneNumber") + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setFullScreenIntent(pendingIntent, true) + .setAutoCancel(true) + .setOngoing(true) + .build() + + startActivity(intent) + notificationManager.notify(NOTIFICATION_ID, notification) + Log.d(TAG, "Launched MainActivity with notification for unlocked screen, phoneNumber: $phoneNumber") + } + } + + private fun cancelNotification() { + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(NOTIFICATION_ID) + Log.d(TAG, "Notification canceled") + } } \ No newline at end of file diff --git a/dialer/lib/features/call/call_page.dart b/dialer/lib/features/call/call_page.dart index 1416a98..368ffde 100644 --- a/dialer/lib/features/call/call_page.dart +++ b/dialer/lib/features/call/call_page.dart @@ -61,9 +61,16 @@ class _CallPageState extends State { void _hangUp() async { try { - await _callService.hangUpCall(context); + final result = await _callService.hangUpCall(context); + print('CallPage: Hang up result: $result'); + if (result["status"] == "ended" && mounted && Navigator.canPop(context)) { + Navigator.pop(context); + } } catch (e) { - print("Error hanging up: $e"); + print("CallPage: Error hanging up: $e"); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Error hanging up: $e")), + ); } } @@ -86,9 +93,10 @@ class _CallPageState extends State { children: [ SizedBox(height: 35), ObfuscatedAvatar( - imageBytes: widget.thumbnail, // Uses thumbnail if provided + imageBytes: widget.thumbnail, radius: avatarRadius, - backgroundColor: generateColorFromName(widget.displayName), + backgroundColor: + generateColorFromName(widget.displayName), fallbackInitial: widget.displayName, ), const SizedBox(height: 4), @@ -122,11 +130,13 @@ class _CallPageState extends State { ), Text( widget.phoneNumber, - style: TextStyle(fontSize: statusFontSize, color: Colors.white70), + style: TextStyle( + fontSize: statusFontSize, color: Colors.white70), ), Text( 'Calling...', - style: TextStyle(fontSize: statusFontSize, color: Colors.white70), + style: TextStyle( + fontSize: statusFontSize, color: Colors.white70), ), ], ), @@ -157,7 +167,8 @@ class _CallPageState extends State { IconButton( padding: EdgeInsets.zero, onPressed: _toggleKeypad, - icon: const Icon(Icons.close, color: Colors.white), + icon: + const Icon(Icons.close, color: Colors.white), ), ], ), @@ -193,7 +204,8 @@ class _CallPageState extends State { child: Center( child: Text( label, - style: const TextStyle(fontSize: 32, color: Colors.white), + style: const TextStyle( + fontSize: 32, color: Colors.white), ), ), ), @@ -225,7 +237,8 @@ class _CallPageState extends State { ), Text( isMuted ? 'Unmute' : 'Mute', - style: const TextStyle(color: Colors.white, fontSize: 14), + style: const TextStyle( + color: Colors.white, fontSize: 14), ), ], ), @@ -234,11 +247,13 @@ class _CallPageState extends State { children: [ IconButton( onPressed: _toggleKeypad, - icon: const Icon(Icons.dialpad, color: Colors.white, size: 32), + icon: const Icon(Icons.dialpad, + color: Colors.white, size: 32), ), const Text( 'Keypad', - style: TextStyle(color: Colors.white, fontSize: 14), + style: TextStyle( + color: Colors.white, fontSize: 14), ), ], ), @@ -248,14 +263,19 @@ class _CallPageState extends State { IconButton( onPressed: _toggleSpeaker, icon: Icon( - isSpeakerOn ? Icons.volume_up : Icons.volume_off, - color: isSpeakerOn ? Colors.amber : Colors.white, + isSpeakerOn + ? Icons.volume_up + : Icons.volume_off, + color: isSpeakerOn + ? Colors.amber + : Colors.white, size: 32, ), ), const Text( 'Speaker', - style: TextStyle(color: Colors.white, fontSize: 14), + style: TextStyle( + color: Colors.white, fontSize: 14), ), ], ), @@ -270,10 +290,12 @@ class _CallPageState extends State { children: [ IconButton( onPressed: () {}, - icon: const Icon(Icons.person_add, color: Colors.white, size: 32), + icon: const Icon(Icons.person_add, + color: Colors.white, size: 32), ), const Text('Add Contact', - style: TextStyle(color: Colors.white, fontSize: 14)), + style: TextStyle( + color: Colors.white, fontSize: 14)), ], ), Column( @@ -281,10 +303,12 @@ class _CallPageState extends State { children: [ IconButton( onPressed: () {}, - icon: const Icon(Icons.sim_card, color: Colors.white, size: 32), + icon: const Icon(Icons.sim_card, + color: Colors.white, size: 32), ), const Text('Change SIM', - style: TextStyle(color: Colors.white, fontSize: 14)), + style: TextStyle( + color: Colors.white, fontSize: 14)), ], ), ], @@ -321,4 +345,4 @@ class _CallPageState extends State { ), ); } -} \ No newline at end of file +} diff --git a/dialer/lib/services/call_service.dart b/dialer/lib/services/call_service.dart index d42326e..e03c652 100644 --- a/dialer/lib/services/call_service.dart +++ b/dialer/lib/services/call_service.dart @@ -1,24 +1,20 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../features/call/call_page.dart'; -import '../features/call/incoming_call_page.dart'; // Import the new page +import '../features/call/incoming_call_page.dart'; class CallService { static const MethodChannel _channel = MethodChannel('call_service'); static String? currentPhoneNumber; static bool _isCallPageVisible = false; + static Map? _pendingCall; + static bool wasPhoneLocked = false; static final GlobalKey navigatorKey = GlobalKey(); CallService() { _channel.setMethodCallHandler((call) async { - final context = navigatorKey.currentContext; - print('CallService: Received method ${call.method} with args ${call.arguments}'); - if (context == null) { - print('CallService: Navigator context is null, cannot navigate'); - return; - } - + print('CallService: Handling method call: ${call.method}'); switch (call.method) { case "callAdded": final phoneNumber = call.arguments["callId"] as String; @@ -26,39 +22,86 @@ class CallService { currentPhoneNumber = phoneNumber.replaceFirst('tel:', ''); print('CallService: Call added, number: $currentPhoneNumber, state: $state'); if (state == "ringing") { - _navigateToIncomingCallPage(context); + _handleIncomingCall(phoneNumber); } else { - _navigateToCallPage(context); + _navigateToCallPage(); } break; case "callStateChanged": final state = call.arguments["state"] as String; - print('CallService: State changed to $state'); + wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false; + print('CallService: State changed to $state, wasPhoneLocked: $wasPhoneLocked'); if (state == "disconnected" || state == "disconnecting") { - _closeCallPage(context); + _closeCallPage(); + if (wasPhoneLocked) { + _channel.invokeMethod("callEndedFromFlutter"); + } } else if (state == "active" || state == "dialing") { - _navigateToCallPage(context); + _navigateToCallPage(); } else if (state == "ringing") { - _navigateToIncomingCallPage(context); + final phoneNumber = call.arguments["callId"] as String; + _handleIncomingCall(phoneNumber.replaceFirst('tel:', '')); } break; case "callEnded": case "callRemoved": - print('CallService: Call ended/removed'); - _closeCallPage(context); + wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false; + print('CallService: Call ended/removed, wasPhoneLocked: $wasPhoneLocked'); + _closeCallPage(); + if (wasPhoneLocked) { + _channel.invokeMethod("callEndedFromFlutter"); + } currentPhoneNumber = null; break; + case "incomingCallFromNotification": + final phoneNumber = call.arguments["phoneNumber"] as String; + wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false; + currentPhoneNumber = phoneNumber; + print('CallService: Incoming call from notification: $phoneNumber, wasPhoneLocked: $wasPhoneLocked'); + _handleIncomingCall(phoneNumber); + break; } }); } - void _navigateToCallPage(BuildContext context) { - if (_isCallPageVisible && ModalRoute.of(context)?.settings.name == '/call') { + void _handleIncomingCall(String phoneNumber) { + final context = navigatorKey.currentContext; + if (context == null) { + print('CallService: Context is null, queuing incoming call: $phoneNumber'); + _pendingCall = {"phoneNumber": phoneNumber}; + Future.delayed(Duration(milliseconds: 500), () => _checkPendingCall()); + } else { + _navigateToIncomingCallPage(context); + } + } + + void _checkPendingCall() { + if (_pendingCall != null) { + final context = navigatorKey.currentContext; + if (context != null) { + print('CallService: Processing queued call: ${_pendingCall!["phoneNumber"]}'); + currentPhoneNumber = _pendingCall!["phoneNumber"]; + _navigateToIncomingCallPage(context); + _pendingCall = null; + } else { + print('CallService: Context still null, retrying...'); + Future.delayed(Duration(milliseconds: 500), () => _checkPendingCall()); + } + } + } + + void _navigateToCallPage() { + final context = navigatorKey.currentContext; + if (context == null) { + print('CallService: Cannot navigate to CallPage, context is null'); + return; + } + if (_isCallPageVisible) { print('CallService: CallPage already visible, skipping navigation'); return; } print('CallService: Navigating to CallPage'); - Navigator.pushReplacement( + Navigator.push( context, MaterialPageRoute( settings: const RouteSettings(name: '/call'), @@ -70,12 +113,13 @@ class CallService { ), ).then((_) { _isCallPageVisible = false; + print('CallService: CallPage popped, _isCallPageVisible set to false'); }); _isCallPageVisible = true; } void _navigateToIncomingCallPage(BuildContext context) { - if (_isCallPageVisible && ModalRoute.of(context)?.settings.name == '/incoming_call') { + if (_isCallPageVisible) { print('CallService: IncomingCallPage already visible, skipping navigation'); return; } @@ -92,23 +136,28 @@ class CallService { ), ).then((_) { _isCallPageVisible = false; + print('CallService: IncomingCallPage popped, _isCallPageVisible set to false'); }); _isCallPageVisible = true; } - void _closeCallPage(BuildContext context) { - if (!_isCallPageVisible) { - print('CallService: CallPage not visible, skipping pop'); + void _closeCallPage() { + final context = navigatorKey.currentContext; + if (context == null) { + print('CallService: Cannot close page, context is null'); return; } + print('CallService: Closing call page, _isCallPageVisible: $_isCallPageVisible'); if (Navigator.canPop(context)) { - print('CallService: Popping CallPage'); + print('CallService: Popping call page'); Navigator.pop(context); _isCallPageVisible = false; + } else { + print('CallService: No page to pop'); } } - Future makeGsmCall( + Future> makeGsmCall( BuildContext context, { required String phoneNumber, String? displayName, @@ -119,36 +168,40 @@ class CallService { print('CallService: Making GSM call to $phoneNumber'); final result = await _channel.invokeMethod('makeGsmCall', {"phoneNumber": phoneNumber}); print('CallService: makeGsmCall result: $result'); - if (result["status"] != "calling") { + final resultMap = Map.from(result as Map); + if (resultMap["status"] != "calling") { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text("Failed to initiate call")), ); } + return resultMap; } catch (e) { print("CallService: Error making call: $e"); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text("Error making call: $e")), ); - rethrow; + return {"status": "error", "message": e.toString()}; } } - Future hangUpCall(BuildContext context) async { + Future> hangUpCall(BuildContext context) async { try { print('CallService: Hanging up call'); final result = await _channel.invokeMethod('hangUpCall'); print('CallService: hangUpCall result: $result'); - if (result["status"] != "ended") { + final resultMap = Map.from(result as Map); + if (resultMap["status"] != "ended") { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text("Failed to end call")), ); } + return resultMap; } catch (e) { print("CallService: Error hanging up call: $e"); ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text("Error hanging up call: $e")), ); - rethrow; + return {"status": "error", "message": e.toString()}; } } } \ No newline at end of file From dab3fe3790e754d17af73dfb627ddc611924d07d Mon Sep 17 00:00:00 2001 From: Florian Griffon Date: Wed, 30 Apr 2025 11:21:55 +0000 Subject: [PATCH 11/13] callPageV2 (#52) Update: Now has contact info when making a call Working on: Contact info when receiving a call Keep dialer open when in call Implement button actions Reviewed-on: https://git.gmoker.com/icing/monorepo/pulls/52 Co-authored-by: Florian Griffon Co-committed-by: Florian Griffon --- .../icing/dialer/activities/MainActivity.kt | 38 + .../icing/dialer/services/MyInCallService.kt | 52 +- dialer/lib/features/call/call_page.dart | 672 +++++++++++------- .../contacts/widgets/contact_modal.dart | 8 +- dialer/lib/features/history/history_page.dart | 7 +- dialer/lib/services/call_service.dart | 313 +++++++- 6 files changed, 797 insertions(+), 293 deletions(-) diff --git a/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt b/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt index 5f5b29a..ee194a2 100644 --- a/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt +++ b/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt @@ -153,6 +153,44 @@ class MainActivity : FlutterActivity() { } result.success(true) } + "getCallState" -> { + val stateStr = when (MyInCallService.currentCall?.state) { + android.telecom.Call.STATE_ACTIVE -> "active" + android.telecom.Call.STATE_RINGING -> "ringing" + android.telecom.Call.STATE_DIALING -> "dialing" + android.telecom.Call.STATE_DISCONNECTED -> "disconnected" + android.telecom.Call.STATE_DISCONNECTING -> "disconnecting" + else -> "unknown" + } + Log.d(TAG, "getCallState called, returning: $stateStr") + result.success(stateStr) + } + "muteCall" -> { + val mute = call.argument("mute") ?: false + val success = MyInCallService.currentCall?.let { + MyInCallService.toggleMute(mute) + } ?: false + if (success) { + Log.d(TAG, "Mute call set to $mute") + result.success(mapOf("status" to "success")) + } else { + Log.w(TAG, "No active call or failed to mute") + result.error("MUTE_FAILED", "No active call or failed to mute", null) + } + } + "speakerCall" -> { + val speaker = call.argument("speaker") ?: false + val success = MyInCallService.currentCall?.let { + MyInCallService.toggleSpeaker(speaker) + } ?: false + if (success) { + Log.d(TAG, "Speaker call set to $speaker") + result.success(mapOf("status" to "success")) + } else { + Log.w(TAG, "No active call or failed to set speaker") + result.error("SPEAKER_FAILED", "No active call or failed to set speaker", null) + } + } else -> result.notImplemented() } } diff --git a/dialer/android/app/src/main/kotlin/com/icing/dialer/services/MyInCallService.kt b/dialer/android/app/src/main/kotlin/com/icing/dialer/services/MyInCallService.kt index b5a4c8a..5469c6d 100644 --- a/dialer/android/app/src/main/kotlin/com/icing/dialer/services/MyInCallService.kt +++ b/dialer/android/app/src/main/kotlin/com/icing/dialer/services/MyInCallService.kt @@ -6,9 +6,11 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.media.AudioManager import android.os.Build import android.telecom.Call import android.telecom.InCallService +import android.telecom.CallAudioState import android.util.Log import androidx.core.app.NotificationCompat import com.icing.dialer.activities.MainActivity @@ -22,6 +24,34 @@ class MyInCallService : InCallService() { private const val NOTIFICATION_CHANNEL_ID = "incoming_call_channel" private const val NOTIFICATION_ID = 1 var wasPhoneLocked: Boolean = false + private var instance: MyInCallService? = null + + fun toggleMute(mute: Boolean): Boolean { + return instance?.let { service -> + try { + service.setMuted(mute) + Log.d(TAG, "Requested to set call mute state to $mute") + true + } catch (e: Exception) { + Log.e(TAG, "Failed to set mute state: $e") + false + } + } ?: false + } + + fun toggleSpeaker(speaker: Boolean): Boolean { + return instance?.let { service -> + try { + val route = if (speaker) CallAudioState.ROUTE_SPEAKER else CallAudioState.ROUTE_EARPIECE + service.setAudioRoute(route) + Log.d(TAG, "Requested to set audio route to $route") + true + } catch (e: Exception) { + Log.e(TAG, "Failed to set audio route: $e") + false + } + } ?: false + } } private val callCallback = object : Call.Callback() { @@ -60,6 +90,7 @@ class MyInCallService : InCallService() { override fun onCallAdded(call: Call) { super.onCallAdded(call) + instance = this currentCall = call val stateStr = when (call.state) { Call.STATE_DIALING -> "dialing" @@ -79,6 +110,16 @@ class MyInCallService : InCallService() { showIncomingCallScreen(call.details.handle.toString().replace("tel:", "")) } call.registerCallback(callCallback) + if (callAudioState != null) { + val audioState = callAudioState + channel?.invokeMethod("audioStateChanged", mapOf( + "route" to audioState.route, + "muted" to audioState.isMuted, + "speaker" to (audioState.route == CallAudioState.ROUTE_SPEAKER) + )) + } else { + Log.w("MyInCallService", "callAudioState is null in onCallAdded") + } } override fun onCallRemoved(call: Call) { @@ -90,13 +131,18 @@ class MyInCallService : InCallService() { "wasPhoneLocked" to wasPhoneLocked )) currentCall = null + instance = null cancelNotification() } - override fun onCallAudioStateChanged(state: android.telecom.CallAudioState) { + override fun onCallAudioStateChanged(state: CallAudioState) { super.onCallAudioStateChanged(state) - Log.d(TAG, "Audio state changed: route=${state.route}") - channel?.invokeMethod("audioStateChanged", mapOf("route" to state.route)) + Log.d(TAG, "Audio state changed: route=${state.route}, muted=${state.isMuted}") + channel?.invokeMethod("audioStateChanged", mapOf( + "route" to state.route, + "muted" to state.isMuted, + "speaker" to (state.route == CallAudioState.ROUTE_SPEAKER) + )) } private fun showIncomingCallScreen(phoneNumber: String) { diff --git a/dialer/lib/features/call/call_page.dart b/dialer/lib/features/call/call_page.dart index 368ffde..0d7cf48 100644 --- a/dialer/lib/features/call/call_page.dart +++ b/dialer/lib/features/call/call_page.dart @@ -1,5 +1,7 @@ +import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:flutter_contacts/flutter_contacts.dart'; import 'package:dialer/services/call_service.dart'; import 'package:dialer/services/obfuscate_service.dart'; import 'package:dialer/widgets/username_color_generator.dart'; @@ -24,10 +26,103 @@ class _CallPageState extends State { final ObfuscateService _obfuscateService = ObfuscateService(); final CallService _callService = CallService(); bool isMuted = false; - bool isSpeakerOn = false; + bool isSpeaker = false; bool isKeypadVisible = false; bool icingProtocolOk = true; String _typedDigits = ""; + Timer? _callTimer; + int _callSeconds = 0; + String _callStatus = "Calling..."; + StreamSubscription? _callStateSubscription; + StreamSubscription>? _audioStateSubscription; + + bool get isNumberUnknown => widget.displayName == widget.phoneNumber; + + @override + void initState() { + super.initState(); + _checkInitialCallState(); + _listenToCallState(); + _listenToAudioState(); + _setInitialAudioState(); + } + + @override + void dispose() { + _callTimer?.cancel(); + _callStateSubscription?.cancel(); + _audioStateSubscription?.cancel(); + super.dispose(); + } + + void _setInitialAudioState() { + final initialAudioState = _callService.currentAudioState; + if (initialAudioState != null) { + setState(() { + isMuted = initialAudioState['muted'] ?? false; + isSpeaker = initialAudioState['speaker'] ?? false; + }); + } + } + + void _checkInitialCallState() async { + try { + final state = await _callService.getCallState(); + print('CallPage: Initial call state: $state'); + if (mounted && state == "active") { + setState(() { + _callStatus = "00:00"; + _startCallTimer(); + }); + } + } catch (e) { + print('CallPage: Error checking initial state: $e'); + } + } + + void _listenToCallState() { + _callStateSubscription = _callService.callStateStream.listen((state) { + print('CallPage: Call state changed to $state'); + if (mounted) { + setState(() { + if (state == "active") { + _callStatus = "00:00"; + _startCallTimer(); + } else if (state == "disconnected" || state == "disconnecting") { + _callTimer?.cancel(); + _callStatus = "Call Ended"; + } else { + _callStatus = "Calling..."; + } + }); + } + }); + } + + void _listenToAudioState() { + _audioStateSubscription = _callService.audioStateStream.listen((state) { + if (mounted) { + setState(() { + isMuted = state['muted'] ?? isMuted; + isSpeaker = state['speaker'] ?? isSpeaker; + }); + } + }); + } + + void _startCallTimer() { + _callTimer?.cancel(); + _callTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (mounted) { + setState(() { + _callSeconds++; + final minutes = (_callSeconds ~/ 60).toString().padLeft(2, '0'); + final seconds = (_callSeconds % 60).toString().padLeft(2, '0'); + _callStatus = '$minutes:$seconds'; + }); + } + }); + } void _addDigit(String digit) { setState(() { @@ -35,16 +130,48 @@ class _CallPageState extends State { }); } - void _toggleMute() { - setState(() { - isMuted = !isMuted; - }); + void _toggleMute() async { + try { + print('CallPage: Toggling mute, current state: $isMuted'); + final result = await _callService.muteCall(context, mute: !isMuted); + print('CallPage: Mute call result: $result'); + if (mounted && result['status'] != 'success') { + print('CallPage: Failed to toggle mute: ${result['message']}'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to toggle mute: ${result['message']}')), + ); + } + } catch (e) { + print('CallPage: Error toggling mute: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error toggling mute: $e')), + ); + } + } } - void _toggleSpeaker() { - setState(() { - isSpeakerOn = !isSpeakerOn; - }); + Future _toggleSpeaker() async { + try { + print('CallPage: Toggling speaker, current state: $isSpeaker'); + final result = + await _callService.speakerCall(context, speaker: !isSpeaker); + print('CallPage: Speaker call result: $result'); + if (result['status'] != 'success') { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Failed to toggle speaker: ${result['message']}')), + ); + } + } catch (e) { + print('CallPage: Error toggling speaker: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error toggling speaker: $e')), + ); + } + } } void _toggleKeypad() { @@ -61,15 +188,32 @@ class _CallPageState extends State { void _hangUp() async { try { + print('CallPage: Initiating hangUp'); final result = await _callService.hangUpCall(context); print('CallPage: Hang up result: $result'); - if (result["status"] == "ended" && mounted && Navigator.canPop(context)) { - Navigator.pop(context); - } } catch (e) { - print("CallPage: Error hanging up: $e"); + print('CallPage: Error hanging up: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text("Error hanging up: $e")), + ); + } + } + } + + void _addContact() async { + if (await FlutterContacts.requestPermission()) { + final newContact = Contact()..phones = [Phone(widget.phoneNumber)]; + final updatedContact = + await FlutterContacts.openExternalInsert(newContact); + if (updatedContact != null) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Contact added successfully!')), + ); + } + } else { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Error hanging up: $e")), + SnackBar(content: Text('Permission denied for contacts')), ); } } @@ -80,269 +224,301 @@ class _CallPageState extends State { final double nameFontSize = isKeypadVisible ? 24.0 : 24.0; final double statusFontSize = isKeypadVisible ? 16.0 : 16.0; - return Scaffold( - body: Container( - color: Colors.black, - child: SafeArea( - child: Column( - children: [ - Container( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox(height: 35), - ObfuscatedAvatar( - imageBytes: widget.thumbnail, - radius: avatarRadius, - backgroundColor: - generateColorFromName(widget.displayName), - fallbackInitial: widget.displayName, - ), - const SizedBox(height: 4), - Row( - mainAxisAlignment: MainAxisAlignment.center, + print( + 'CallPage: Building UI, _callStatus: $_callStatus, route: ${ModalRoute.of(context)?.settings.name ?? "unknown"}'); + return PopScope( + canPop: + _callStatus == "Call Ended", // Allow navigation only if call ended + child: Scaffold( + body: Container( + color: Colors.black, + child: SafeArea( + child: Column( + children: [ + Container( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Icon( - icingProtocolOk ? Icons.lock : Icons.lock_open, - color: icingProtocolOk ? Colors.green : Colors.red, - size: 16, + const SizedBox(height: 35), + ObfuscatedAvatar( + imageBytes: widget.thumbnail, + radius: avatarRadius, + backgroundColor: + generateColorFromName(widget.displayName), + fallbackInitial: widget.displayName, ), - const SizedBox(width: 4), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icingProtocolOk ? Icons.lock : Icons.lock_open, + color: + icingProtocolOk ? Colors.green : Colors.red, + size: 16, + ), + const SizedBox(width: 4), + Text( + 'Icing protocol: ${icingProtocolOk ? "ok" : "ko"}', + style: TextStyle( + color: + icingProtocolOk ? Colors.green : Colors.red, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 4), Text( - 'Icing protocol: ${icingProtocolOk ? "ok" : "ko"}', + _obfuscateService.obfuscateData(widget.displayName), style: TextStyle( - color: icingProtocolOk ? Colors.green : Colors.red, - fontSize: 12, + fontSize: nameFontSize, + color: Colors.white, fontWeight: FontWeight.bold, ), ), + Text( + widget.phoneNumber, + style: TextStyle( + fontSize: statusFontSize, color: Colors.white70), + ), + Text( + _callStatus, + style: TextStyle( + fontSize: statusFontSize, color: Colors.white70), + ), ], ), - const SizedBox(height: 4), - Text( - _obfuscateService.obfuscateData(widget.displayName), - style: TextStyle( - fontSize: nameFontSize, - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - Text( - widget.phoneNumber, - style: TextStyle( - fontSize: statusFontSize, color: Colors.white70), - ), - Text( - 'Calling...', - style: TextStyle( - fontSize: statusFontSize, color: Colors.white70), - ), - ], - ), - ), - Expanded( - child: Column( - children: [ - if (isKeypadVisible) ...[ - const Spacer(flex: 2), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - _typedDigits, - maxLines: 1, - textAlign: TextAlign.right, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 24, - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ), - IconButton( - padding: EdgeInsets.zero, - onPressed: _toggleKeypad, - icon: - const Icon(Icons.close, color: Colors.white), - ), - ], - ), - ), - Container( - height: MediaQuery.of(context).size.height * 0.35, - margin: const EdgeInsets.symmetric(horizontal: 20), - child: GridView.count( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - crossAxisCount: 3, - childAspectRatio: 1.3, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - children: List.generate(12, (index) { - String label; - if (index < 9) { - label = '${index + 1}'; - } else if (index == 9) { - label = '*'; - } else if (index == 10) { - label = '0'; - } else { - label = '#'; - } - return GestureDetector( - onTap: () => _addDigit(label), - child: Container( - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Colors.transparent, - ), - child: Center( + ), + Expanded( + child: Column( + children: [ + if (isKeypadVisible) ...[ + const Spacer(flex: 2), + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 20.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( child: Text( - label, + _typedDigits, + maxLines: 1, + textAlign: TextAlign.right, + overflow: TextOverflow.ellipsis, style: const TextStyle( - fontSize: 32, color: Colors.white), + fontSize: 24, + color: Colors.white, + fontWeight: FontWeight.bold, + ), ), ), - ), - ); - }), - ), - ), - const Spacer(flex: 1), - ] else ...[ - const Spacer(), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 32.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + IconButton( + padding: EdgeInsets.zero, + onPressed: _toggleKeypad, + icon: const Icon(Icons.close, + color: Colors.white), + ), + ], + ), + ), + Container( + height: MediaQuery.of(context).size.height * 0.35, + margin: const EdgeInsets.symmetric(horizontal: 20), + child: GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 3, + childAspectRatio: 1.3, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + children: List.generate(12, (index) { + String label; + if (index < 9) { + label = '${index + 1}'; + } else if (index == 9) { + label = '*'; + } else if (index == 10) { + label = '0'; + } else { + label = '#'; + } + return GestureDetector( + onTap: () => _addDigit(label), + child: Container( + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.transparent, + ), + child: Center( + child: Text( + label, + style: const TextStyle( + fontSize: 32, color: Colors.white), + ), + ), + ), + ); + }), + ), + ), + const Spacer(flex: 1), + ] else ...[ + const Spacer(), + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 32.0), + child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Column( - mainAxisSize: MainAxisSize.min, + Row( + mainAxisAlignment: + MainAxisAlignment.spaceEvenly, children: [ - IconButton( - onPressed: _toggleMute, - icon: Icon( - isMuted ? Icons.mic_off : Icons.mic, - color: Colors.white, - size: 32, - ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: _toggleMute, + icon: Icon( + isMuted ? Icons.mic_off : Icons.mic, + color: isMuted + ? Colors.amber + : Colors.white, + size: 32, + ), + ), + Text( + isMuted ? 'Unmute' : 'Mute', + style: const TextStyle( + color: Colors.white, + fontSize: 14), + ), + ], ), - Text( - isMuted ? 'Unmute' : 'Mute', - style: const TextStyle( - color: Colors.white, fontSize: 14), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: _toggleKeypad, + icon: const Icon( + Icons.dialpad, + color: Colors.white, + size: 32, + ), + ), + const Text( + 'Keypad', + style: TextStyle( + color: Colors.white, + fontSize: 14), + ), + ], + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: _toggleSpeaker, + icon: Icon( + isSpeaker + ? Icons.volume_up + : Icons.volume_off, + color: isSpeaker + ? Colors.amber + : Colors.white, + size: 32, + ), + ), + const Text( + 'Speaker', + style: TextStyle( + color: Colors.white, + fontSize: 14), + ), + ], ), ], ), - Column( - mainAxisSize: MainAxisSize.min, + const SizedBox(height: 20), + Row( + mainAxisAlignment: + MainAxisAlignment.spaceEvenly, children: [ - IconButton( - onPressed: _toggleKeypad, - icon: const Icon(Icons.dialpad, - color: Colors.white, size: 32), - ), - const Text( - 'Keypad', - style: TextStyle( - color: Colors.white, fontSize: 14), - ), - ], - ), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: _toggleSpeaker, - icon: Icon( - isSpeakerOn - ? Icons.volume_up - : Icons.volume_off, - color: isSpeakerOn - ? Colors.amber - : Colors.white, - size: 32, + if (isNumberUnknown) + Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: _addContact, + icon: const Icon( + Icons.person_add, + color: Colors.white, + size: 32, + ), + ), + const Text( + 'Add Contact', + style: TextStyle( + color: Colors.white, + fontSize: 14), + ), + ], ), - ), - const Text( - 'Speaker', - style: TextStyle( - color: Colors.white, fontSize: 14), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () {}, + icon: const Icon( + Icons.sim_card, + color: Colors.white, + size: 32, + ), + ), + const Text( + 'Change SIM', + style: TextStyle( + color: Colors.white, + fontSize: 14), + ), + ], ), ], ), ], ), - const SizedBox(height: 20), - Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: () {}, - icon: const Icon(Icons.person_add, - color: Colors.white, size: 32), - ), - const Text('Add Contact', - style: TextStyle( - color: Colors.white, fontSize: 14)), - ], - ), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: () {}, - icon: const Icon(Icons.sim_card, - color: Colors.white, size: 32), - ), - const Text('Change SIM', - style: TextStyle( - color: Colors.white, fontSize: 14)), - ], - ), - ], - ), - ], - ), - ), - const Spacer(flex: 3), - ], - ], - ), - ), - Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: GestureDetector( - onTap: _hangUp, - child: Container( - padding: const EdgeInsets.all(12), - decoration: const BoxDecoration( - color: Colors.red, - shape: BoxShape.circle, - ), - child: const Icon( - Icons.call_end, - color: Colors.white, - size: 32, + ), + const Spacer(flex: 3), + ], + ], ), ), - ), + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: GestureDetector( + onTap: _hangUp, + child: Container( + padding: const EdgeInsets.all(12), + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.call_end, + color: Colors.white, + size: 32, + ), + ), + ), + ), + ], ), - ], + ), ), - ), - ), - ); + )); } } diff --git a/dialer/lib/features/contacts/widgets/contact_modal.dart b/dialer/lib/features/contacts/widgets/contact_modal.dart index 198f614..a73f7e6 100644 --- a/dialer/lib/features/contacts/widgets/contact_modal.dart +++ b/dialer/lib/features/contacts/widgets/contact_modal.dart @@ -267,8 +267,12 @@ class _ContactModalState extends State { ), onTap: () async { if (widget.contact.phones.isNotEmpty) { - await _callService.makeGsmCall(context, - phoneNumber: phoneNumber); + await _callService.makeGsmCall( + context, + phoneNumber: phoneNumber, + displayName: widget.contact.displayName, + thumbnail: widget.contact.thumbnail, + ); } }, ), diff --git a/dialer/lib/features/history/history_page.dart b/dialer/lib/features/history/history_page.dart index 3c5b1f1..9906908 100644 --- a/dialer/lib/features/history/history_page.dart +++ b/dialer/lib/features/history/history_page.dart @@ -425,7 +425,12 @@ class _HistoryPageState extends State icon: const Icon(Icons.phone, color: Colors.green), onPressed: () async { if (contact.phones.isNotEmpty) { - _callService.makeGsmCall(context, phoneNumber: contact.phones.first.number); + await _callService.makeGsmCall( + context, + phoneNumber: contact.phones.first.number, + displayName: contact.displayName, + thumbnail: contact.thumbnail, + ); } else { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( diff --git a/dialer/lib/services/call_service.dart b/dialer/lib/services/call_service.dart index e03c652..d14073e 100644 --- a/dialer/lib/services/call_service.dart +++ b/dialer/lib/services/call_service.dart @@ -1,46 +1,102 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../features/call/call_page.dart'; import '../features/call/incoming_call_page.dart'; +import '../services/contact_service.dart'; class CallService { static const MethodChannel _channel = MethodChannel('call_service'); static String? currentPhoneNumber; + static String? currentDisplayName; + static Uint8List? currentThumbnail; static bool _isCallPageVisible = false; static Map? _pendingCall; static bool wasPhoneLocked = false; + static String? _activeCallNumber; + static bool _isNavigating = false; + final ContactService _contactService = ContactService(); + final _callStateController = StreamController.broadcast(); + final _audioStateController = StreamController>.broadcast(); + Map? _currentAudioState; static final GlobalKey navigatorKey = GlobalKey(); + + Stream get callStateStream => _callStateController.stream; + Stream> get audioStateStream => _audioStateController.stream; + Map? get currentAudioState => _currentAudioState; CallService() { _channel.setMethodCallHandler((call) async { - print('CallService: Handling method call: ${call.method}'); + print('CallService: Handling method call: ${call.method}, with args: ${call.arguments}'); switch (call.method) { case "callAdded": - final phoneNumber = call.arguments["callId"] as String; - final state = call.arguments["state"] as String; - currentPhoneNumber = phoneNumber.replaceFirst('tel:', ''); - print('CallService: Call added, number: $currentPhoneNumber, state: $state'); + final phoneNumber = call.arguments["callId"] as String?; + final state = call.arguments["state"] as String?; + if (phoneNumber == null || state == null) { + print('CallService: Invalid callAdded args: $call.arguments'); + return; + } + final decodedPhoneNumber = Uri.decodeComponent(phoneNumber.replaceFirst('tel:', '')); + print('CallService: Decoded phone number: $decodedPhoneNumber'); + if (_activeCallNumber != decodedPhoneNumber) { + currentPhoneNumber = decodedPhoneNumber; + if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) { + await _fetchContactInfo(decodedPhoneNumber); + } + } + print('CallService: Call added, number: $currentPhoneNumber, displayName: $currentDisplayName, state: $state'); + _callStateController.add(state); if (state == "ringing") { - _handleIncomingCall(phoneNumber); + _handleIncomingCall(decodedPhoneNumber); } else { _navigateToCallPage(); } break; case "callStateChanged": - final state = call.arguments["state"] as String; + final state = call.arguments["state"] as String?; wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false; + if (state == null) { + print('CallService: Invalid callStateChanged args: $call.arguments'); + return; + } print('CallService: State changed to $state, wasPhoneLocked: $wasPhoneLocked'); + _callStateController.add(state); if (state == "disconnected" || state == "disconnecting") { _closeCallPage(); if (wasPhoneLocked) { - _channel.invokeMethod("callEndedFromFlutter"); + await _channel.invokeMethod("callEndedFromFlutter"); } + _activeCallNumber = null; } else if (state == "active" || state == "dialing") { + final phoneNumber = call.arguments["callId"] as String?; + if (phoneNumber != null && _activeCallNumber != Uri.decodeComponent(phoneNumber.replaceFirst('tel:', ''))) { + currentPhoneNumber = Uri.decodeComponent(phoneNumber.replaceFirst('tel:', '')); + if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) { + await _fetchContactInfo(currentPhoneNumber!); + } + } else if (currentPhoneNumber != null && _activeCallNumber != currentPhoneNumber) { + if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) { + await _fetchContactInfo(currentPhoneNumber!); + } + } else { + print('CallService: Skipping fetch, active call: $_activeCallNumber, current: $currentPhoneNumber, displayName: $currentDisplayName'); + } _navigateToCallPage(); } else if (state == "ringing") { - final phoneNumber = call.arguments["callId"] as String; - _handleIncomingCall(phoneNumber.replaceFirst('tel:', '')); + final phoneNumber = call.arguments["callId"] as String?; + if (phoneNumber == null) { + print('CallService: Invalid ringing callId: $call.arguments'); + return; + } + final decodedPhoneNumber = Uri.decodeComponent(phoneNumber.replaceFirst('tel:', '')); + if (_activeCallNumber != decodedPhoneNumber) { + currentPhoneNumber = decodedPhoneNumber; + if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) { + await _fetchContactInfo(decodedPhoneNumber); + } + } + _handleIncomingCall(decodedPhoneNumber); } break; case "callEnded": @@ -49,22 +105,139 @@ class CallService { print('CallService: Call ended/removed, wasPhoneLocked: $wasPhoneLocked'); _closeCallPage(); if (wasPhoneLocked) { - _channel.invokeMethod("callEndedFromFlutter"); + await _channel.invokeMethod("callEndedFromFlutter"); } currentPhoneNumber = null; + currentDisplayName = null; + currentThumbnail = null; + _activeCallNumber = null; break; case "incomingCallFromNotification": - final phoneNumber = call.arguments["phoneNumber"] as String; + final phoneNumber = call.arguments["phoneNumber"] as String?; wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false; - currentPhoneNumber = phoneNumber; - print('CallService: Incoming call from notification: $phoneNumber, wasPhoneLocked: $wasPhoneLocked'); - _handleIncomingCall(phoneNumber); + if (phoneNumber == null) { + print('CallService: Invalid incomingCallFromNotification args: $call.arguments'); + return; + } + final decodedPhoneNumber = Uri.decodeComponent(phoneNumber); + if (_activeCallNumber != decodedPhoneNumber) { + currentPhoneNumber = decodedPhoneNumber; + if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) { + await _fetchContactInfo(decodedPhoneNumber); + } + } + print('CallService: Incoming call from notification: $decodedPhoneNumber, displayName: $currentDisplayName, wasPhoneLocked: $wasPhoneLocked'); + _handleIncomingCall(decodedPhoneNumber); + break; + case "audioStateChanged": + final route = call.arguments["route"] as int?; + final muted = call.arguments["muted"] as bool?; + final speaker = call.arguments["speaker"] as bool?; + print('CallService: Audio state changed, route: $route, muted: $muted, speaker: $speaker'); + final audioState = { + "route": route, + "muted": muted, + "speaker": speaker, + }; + _currentAudioState = audioState; + _audioStateController.add(audioState); break; } }); } + Future getCallState() async { + try { + final state = await _channel.invokeMethod('getCallState'); + print('CallService: getCallState returned: $state'); + return state as String?; + } catch (e) { + print('CallService: Error getting call state: $e'); + return null; + } + } + + Future> muteCall(BuildContext context, {required bool mute}) async { + try { + print('CallService: Toggling mute to $mute'); + final result = await _channel.invokeMethod('muteCall', {'mute': mute}); + print('CallService: muteCall result: $result'); + final resultMap = Map.from(result as Map); + if (resultMap['status'] != 'success') { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to toggle mute')), + ); + } + return resultMap; + } catch (e) { + print('CallService: Error toggling mute: $e'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error toggling mute: $e')), + ); + return {'status': 'error', 'message': e.toString()}; + } + } + + Future> speakerCall(BuildContext context, {required bool speaker}) async { + try { + print('CallService: Toggling speaker to $speaker'); + final result = await _channel.invokeMethod('speakerCall', {'speaker': speaker}); + print('CallService: speakerCall result: $result'); + return Map.from(result); + } catch (e) { + print('CallService: Error toggling speaker: $e'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to toggle speaker: $e')), + ); + return {'status': 'error', 'message': e.toString()}; + } + } + + void dispose() { + _callStateController.close(); + _audioStateController.close(); + } + + Future _fetchContactInfo(String phoneNumber) async { + try { + print('CallService: Fetching contact info for $phoneNumber'); + final contacts = await _contactService.fetchContacts(); + print('CallService: Retrieved ${contacts.length} contacts'); + final normalizedPhoneNumber = _normalizePhoneNumber(phoneNumber); + print('CallService: Normalized phone number: $normalizedPhoneNumber'); + for (var contact in contacts) { + for (var phone in contact.phones) { + final normalizedContactNumber = _normalizePhoneNumber(phone.number); + print('CallService: Comparing $normalizedPhoneNumber with contact number $normalizedContactNumber'); + if (normalizedContactNumber == normalizedPhoneNumber) { + currentDisplayName = contact.displayName; + currentThumbnail = contact.thumbnail; + print('CallService: Found contact - displayName: $currentDisplayName, thumbnail: ${currentThumbnail != null ? "present" : "null"}'); + return; + } + } + } + currentDisplayName = phoneNumber; + currentThumbnail = null; + print('CallService: No contact match, using phoneNumber as displayName: $currentDisplayName'); + } catch (e) { + print('CallService: Error fetching contact info: $e'); + currentDisplayName = phoneNumber; + currentThumbnail = null; + } + } + + String _normalizePhoneNumber(String number) { + return number.replaceAll(RegExp(r'[\s\-\(\)]'), '').replaceFirst(RegExp(r'^\+'), ''); + } + void _handleIncomingCall(String phoneNumber) { + if (_activeCallNumber == phoneNumber && _isCallPageVisible) { + print('CallService: Incoming call for $phoneNumber already active, skipping'); + return; + } + _activeCallNumber = phoneNumber; + final context = navigatorKey.currentContext; if (context == null) { print('CallService: Context is null, queuing incoming call: $phoneNumber'); @@ -75,67 +248,119 @@ class CallService { } } - void _checkPendingCall() { - if (_pendingCall != null) { - final context = navigatorKey.currentContext; - if (context != null) { - print('CallService: Processing queued call: ${_pendingCall!["phoneNumber"]}'); - currentPhoneNumber = _pendingCall!["phoneNumber"]; - _navigateToIncomingCallPage(context); - _pendingCall = null; - } else { - print('CallService: Context still null, retrying...'); - Future.delayed(Duration(milliseconds: 500), () => _checkPendingCall()); - } + Future _checkPendingCall() async { + if (_pendingCall == null) { + print('CallService: No pending call to process'); + return; + } + + final phoneNumber = _pendingCall!["phoneNumber"]; + if (_activeCallNumber == phoneNumber && _isCallPageVisible) { + print('CallService: Pending call for $phoneNumber already active, clearing'); + _pendingCall = null; + return; + } + + final context = navigatorKey.currentContext; + if (context != null) { + print('CallService: Processing queued call: $phoneNumber'); + currentPhoneNumber = phoneNumber; + _activeCallNumber = phoneNumber; + await _fetchContactInfo(phoneNumber); + _navigateToIncomingCallPage(context); + _pendingCall = null; + } else { + print('CallService: Context still null, retrying...'); + Future.delayed(Duration(milliseconds: 500), () => _checkPendingCall()); } } void _navigateToCallPage() { + if (_isNavigating) { + print('CallService: Navigation already in progress, skipping'); + return; + } + _isNavigating = true; + final context = navigatorKey.currentContext; if (context == null) { print('CallService: Cannot navigate to CallPage, context is null'); + _isNavigating = false; return; } - if (_isCallPageVisible) { - print('CallService: CallPage already visible, skipping navigation'); + final currentRoute = ModalRoute.of(context)?.settings.name ?? 'unknown'; + print('CallService: Navigating to CallPage, Visible: $_isCallPageVisible, Current Route: $currentRoute, Active Call: $_activeCallNumber, DisplayName: $currentDisplayName'); + if (_isCallPageVisible && currentRoute == '/call' && _activeCallNumber == currentPhoneNumber) { + print('CallService: CallPage already visible for $_activeCallNumber, skipping navigation'); + _isNavigating = false; return; } - print('CallService: Navigating to CallPage'); - Navigator.push( + if (_isCallPageVisible && currentRoute == '/incoming_call' && _activeCallNumber == currentPhoneNumber) { + print('CallService: Popping IncomingCallPage before navigating to CallPage'); + Navigator.pop(context); + _isCallPageVisible = false; + } + if (currentPhoneNumber == null) { + print('CallService: Cannot navigate to CallPage, currentPhoneNumber is null'); + _isNavigating = false; + return; + } + _activeCallNumber = currentPhoneNumber; + Navigator.pushReplacement( context, MaterialPageRoute( settings: const RouteSettings(name: '/call'), builder: (context) => CallPage( - displayName: currentPhoneNumber!, + displayName: currentDisplayName ?? currentPhoneNumber!, phoneNumber: currentPhoneNumber!, - thumbnail: null, + thumbnail: currentThumbnail, ), ), ).then((_) { _isCallPageVisible = false; + _isNavigating = false; print('CallService: CallPage popped, _isCallPageVisible set to false'); }); _isCallPageVisible = true; } void _navigateToIncomingCallPage(BuildContext context) { - if (_isCallPageVisible) { - print('CallService: IncomingCallPage already visible, skipping navigation'); + if (_isNavigating) { + print('CallService: Navigation already in progress, skipping'); + return; + } + _isNavigating = true; + + final currentRoute = ModalRoute.of(context)?.settings.name ?? 'unknown'; + print('CallService: Navigating to IncomingCallPage, Visible: $_isCallPageVisible, Current Route: $currentRoute, Active Call: $_activeCallNumber, DisplayName: $currentDisplayName'); + if (_isCallPageVisible && currentRoute == '/incoming_call' && _activeCallNumber == currentPhoneNumber) { + print('CallService: IncomingCallPage already visible for $_activeCallNumber, skipping navigation'); + _isNavigating = false; + return; + } + if (_isCallPageVisible && currentRoute == '/call') { + print('CallService: CallPage visible, not showing IncomingCallPage'); + _isNavigating = false; + return; + } + if (currentPhoneNumber == null) { + print('CallService: Cannot navigate to IncomingCallPage, currentPhoneNumber is null'); + _isNavigating = false; return; } - print('CallService: Navigating to IncomingCallPage'); Navigator.push( context, MaterialPageRoute( settings: const RouteSettings(name: '/incoming_call'), builder: (context) => IncomingCallPage( - displayName: currentPhoneNumber!, + displayName: currentDisplayName ?? currentPhoneNumber!, phoneNumber: currentPhoneNumber!, - thumbnail: null, + thumbnail: currentThumbnail, ), ), ).then((_) { _isCallPageVisible = false; + _isNavigating = false; print('CallService: IncomingCallPage popped, _isCallPageVisible set to false'); }); _isCallPageVisible = true; @@ -155,6 +380,7 @@ class CallService { } else { print('CallService: No page to pop'); } + _activeCallNumber = null; } Future> makeGsmCall( @@ -164,8 +390,17 @@ class CallService { Uint8List? thumbnail, }) async { try { + if (_activeCallNumber == phoneNumber && _isCallPageVisible) { + print('CallService: Call already active for $phoneNumber, skipping'); + return {"status": "already_active", "message": "Call already in progress"}; + } currentPhoneNumber = phoneNumber; - print('CallService: Making GSM call to $phoneNumber'); + currentDisplayName = displayName ?? phoneNumber; + currentThumbnail = thumbnail; + if (displayName == null || thumbnail == null) { + await _fetchContactInfo(phoneNumber); + } + print('CallService: Making GSM call to $phoneNumber, displayName: $currentDisplayName'); final result = await _channel.invokeMethod('makeGsmCall', {"phoneNumber": phoneNumber}); print('CallService: makeGsmCall result: $result'); final resultMap = Map.from(result as Map); From 7e1c48d38e19843f39959d96a5bcf324a6ea6542 Mon Sep 17 00:00:00 2001 From: Florian Griffon Date: Wed, 14 May 2025 09:45:09 +0000 Subject: [PATCH 12/13] feat: default dialer prompt screen (#56) Reviewed-on: https://git.gmoker.com/icing/monorepo/pulls/56 Co-authored-by: Florian Griffon Co-committed-by: Florian Griffon --- .../icing/dialer/activities/MainActivity.kt | 17 ++- .../features/home/default_dialer_prompt.dart | 102 ++++++++++++++++++ dialer/lib/main.dart | 31 +++++- 3 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 dialer/lib/features/home/default_dialer_prompt.dart diff --git a/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt b/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt index ee194a2..fd1b904 100644 --- a/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt +++ b/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt @@ -95,7 +95,6 @@ class MainActivity : FlutterActivity() { pendingIncomingCall = null } } - checkAndRequestDefaultDialer() result.success(true) } "makeGsmCall" -> { @@ -191,6 +190,15 @@ class MainActivity : FlutterActivity() { result.error("SPEAKER_FAILED", "No active call or failed to set speaker", null) } } + "isDefaultDialer" -> { + val isDefault = isDefaultDialer() + Log.d(TAG, "isDefaultDialer called, returning: $isDefault") + result.success(isDefault) + } + "requestDefaultDialer" -> { + checkAndRequestDefaultDialer() + result.success(true) + } else -> result.notImplemented() } } @@ -216,6 +224,13 @@ class MainActivity : FlutterActivity() { } } + private fun isDefaultDialer(): Boolean { + val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager + val currentDefault = telecomManager.defaultDialerPackage + Log.d(TAG, "Checking default dialer: current=$currentDefault, myPackage=$packageName") + return currentDefault == packageName + } + private fun checkAndRequestDefaultDialer() { val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager val currentDefault = telecomManager.defaultDialerPackage diff --git a/dialer/lib/features/home/default_dialer_prompt.dart b/dialer/lib/features/home/default_dialer_prompt.dart new file mode 100644 index 0000000..c2fc0f3 --- /dev/null +++ b/dialer/lib/features/home/default_dialer_prompt.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class DefaultDialerPromptScreen extends StatelessWidget { + const DefaultDialerPromptScreen({super.key}); + + Future _requestDefaultDialer(BuildContext context) async { + const channel = MethodChannel('call_service'); + try { + await channel.invokeMethod('requestDefaultDialer'); + // Navigate to home page after requesting default dialer + Navigator.of(context).pushReplacementNamed('/home'); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error requesting default dialer: $e')), + ); + } + } + + void _exploreApp(BuildContext context) { + // Navigate to home page without requesting default dialer + Navigator.of(context).pushReplacementNamed('/home'); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.black, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Set as Default Dialer', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + textAlign: TextAlign.center, + ), + SizedBox(height: 16), + Text( + 'To handle calls effectively, Icing needs to be your default dialer app. This allows Icing to manage incoming and outgoing calls seamlessly.\n\nWithout the permission, Icing will not be able to encrypt calls.', + style: TextStyle( + fontSize: 16, + color: Colors.white70, + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + Row( + children: [ + Expanded( + flex: 1, + child: Padding( + padding: const EdgeInsets.only(right: 8.0), + child: ElevatedButton( + onPressed: () => _exploreApp(context), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey[800], + foregroundColor: Colors.white, + padding: EdgeInsets.symmetric( + horizontal: 16, vertical: 12), + ), + child: Text('Explore App first'), + ), + ), + ), + Expanded( + flex: 1, + child: Padding( + padding: const EdgeInsets.only(left: 8.0), + child: ElevatedButton( + onPressed: () => _requestDefaultDialer(context), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + padding: EdgeInsets.symmetric( + horizontal: 16, vertical: 12), + ), + child: Text('Set as Default Dialer'), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/dialer/lib/main.dart b/dialer/lib/main.dart index d3513cf..2cc72e3 100644 --- a/dialer/lib/main.dart +++ b/dialer/lib/main.dart @@ -1,4 +1,5 @@ import 'package:dialer/features/home/home_page.dart'; +import 'package:dialer/features/home/default_dialer_prompt.dart'; import 'package:flutter/material.dart'; import 'package:dialer/features/contacts/contact_state.dart'; import 'package:dialer/services/call_service.dart'; @@ -51,6 +52,17 @@ Future _requestPermissions() async { class Dialer extends StatelessWidget { const Dialer({super.key}); + Future _isDefaultDialer() async { + const channel = MethodChannel('call_service'); + try { + final isDefault = await channel.invokeMethod('isDefaultDialer'); + return isDefault ?? false; + } catch (e) { + print('Error checking default dialer: $e'); + return false; + } + } + @override Widget build(BuildContext context) { return ContactState( @@ -59,7 +71,24 @@ class Dialer extends StatelessWidget { theme: ThemeData( brightness: Brightness.dark, ), - home: SafeArea(child: MyHomePage()), + initialRoute: '/', + routes: { + '/': (context) => FutureBuilder( + future: _isDefaultDialer(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return Scaffold( + body: Center(child: CircularProgressIndicator()), + ); + } + if (snapshot.hasError || !snapshot.hasData || snapshot.data == false) { + return DefaultDialerPromptScreen(); + } + return SafeArea(child: MyHomePage()); + }, + ), + '/home': (context) => SafeArea(child: MyHomePage()), + }, ), ); } From 2b9333f40e5e405899907833645f9a320adac665 Mon Sep 17 00:00:00 2001 From: Florian Griffon Date: Wed, 14 May 2025 09:58:49 +0000 Subject: [PATCH 13/13] feat: DTMF dialpad (#55) Reviewed-on: https://git.gmoker.com/icing/monorepo/pulls/55 Co-authored-by: Florian Griffon Co-committed-by: Florian Griffon --- .../icing/dialer/activities/MainActivity.kt | 18 + .../icing/dialer/services/MyInCallService.kt | 18 +- dialer/lib/features/call/call_page.dart | 608 ++++++++++-------- 3 files changed, 359 insertions(+), 285 deletions(-) diff --git a/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt b/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt index fd1b904..f58cdab 100644 --- a/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt +++ b/dialer/android/app/src/main/kotlin/com/icing/dialer/activities/MainActivity.kt @@ -199,6 +199,24 @@ class MainActivity : FlutterActivity() { checkAndRequestDefaultDialer() result.success(true) } + "sendDtmfTone" -> { + val digit = call.argument("digit") + if (digit != null) { + val success = MyInCallService.sendDtmfTone(digit) + result.success(success) + } else { + result.error("INVALID_ARGUMENT", "Digit is null", null) + } + } + "isDefaultDialer" -> { + val isDefault = isDefaultDialer() + Log.d(TAG, "isDefaultDialer called, returning: $isDefault") + result.success(isDefault) + } + "requestDefaultDialer" -> { + checkAndRequestDefaultDialer() + result.success(true) + } else -> result.notImplemented() } } diff --git a/dialer/android/app/src/main/kotlin/com/icing/dialer/services/MyInCallService.kt b/dialer/android/app/src/main/kotlin/com/icing/dialer/services/MyInCallService.kt index 5469c6d..2acb1f3 100644 --- a/dialer/android/app/src/main/kotlin/com/icing/dialer/services/MyInCallService.kt +++ b/dialer/android/app/src/main/kotlin/com/icing/dialer/services/MyInCallService.kt @@ -52,6 +52,22 @@ class MyInCallService : InCallService() { } } ?: false } + + fun sendDtmfTone(digit: String): Boolean { + return instance?.let { service -> + try { + currentCall?.let { call -> + call.playDtmfTone(digit[0]) + call.stopDtmfTone() + Log.d(TAG, "Sent DTMF tone: $digit") + true + } ?: false + } catch (e: Exception) { + Log.e(TAG, "Failed to send DTMF tone: $e") + false + } + } ?: false + } } private val callCallback = object : Call.Callback() { @@ -111,7 +127,7 @@ class MyInCallService : InCallService() { } call.registerCallback(callCallback) if (callAudioState != null) { - val audioState = callAudioState + val audioState = callAudioState channel?.invokeMethod("audioStateChanged", mapOf( "route" to audioState.route, "muted" to audioState.isMuted, diff --git a/dialer/lib/features/call/call_page.dart b/dialer/lib/features/call/call_page.dart index 0d7cf48..95f48a5 100644 --- a/dialer/lib/features/call/call_page.dart +++ b/dialer/lib/features/call/call_page.dart @@ -1,10 +1,10 @@ import 'dart:async'; -import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_contacts/flutter_contacts.dart'; import 'package:dialer/services/call_service.dart'; import 'package:dialer/services/obfuscate_service.dart'; import 'package:dialer/widgets/username_color_generator.dart'; +import 'package:flutter/services.dart'; class CallPage extends StatefulWidget { final String displayName; @@ -124,10 +124,32 @@ class _CallPageState extends State { }); } - void _addDigit(String digit) { + void _addDigit(String digit) async { + print('CallPage: Tapped digit: $digit'); setState(() { _typedDigits += digit; }); + // Send DTMF tone + const channel = MethodChannel('call_service'); + try { + final success = + await channel.invokeMethod('sendDtmfTone', {'digit': digit}); + if (success != true) { + print('CallPage: Failed to send DTMF tone for $digit'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to send DTMF tone')), + ); + } + } + } catch (e) { + print('CallPage: Error sending DTMF tone: $e'); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error sending DTMF tone: $e')), + ); + } + } } void _toggleMute() async { @@ -195,7 +217,7 @@ class _CallPageState extends State { print('CallPage: Error hanging up: $e'); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text("Error hanging up: $e")), + SnackBar(content: Text('Error hanging up: $e')), ); } } @@ -227,298 +249,316 @@ class _CallPageState extends State { print( 'CallPage: Building UI, _callStatus: $_callStatus, route: ${ModalRoute.of(context)?.settings.name ?? "unknown"}'); return PopScope( - canPop: - _callStatus == "Call Ended", // Allow navigation only if call ended - child: Scaffold( - body: Container( - color: Colors.black, - child: SafeArea( - child: Column( - children: [ - Container( - padding: const EdgeInsets.symmetric(vertical: 8.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 35), - ObfuscatedAvatar( - imageBytes: widget.thumbnail, - radius: avatarRadius, - backgroundColor: - generateColorFromName(widget.displayName), - fallbackInitial: widget.displayName, - ), - const SizedBox(height: 4), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - icingProtocolOk ? Icons.lock : Icons.lock_open, + canPop: _callStatus == "Call Ended", + onPopInvoked: (didPop) { + if (!didPop) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Cannot leave during an active call')), + ); + } + }, + child: Scaffold( + body: Container( + color: Colors.black, + child: SafeArea( + child: Column( + children: [ + Container( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 35), + ObfuscatedAvatar( + imageBytes: widget.thumbnail, + radius: avatarRadius, + backgroundColor: + generateColorFromName(widget.displayName), + fallbackInitial: widget.displayName, + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icingProtocolOk ? Icons.lock : Icons.lock_open, + color: icingProtocolOk ? Colors.green : Colors.red, + size: 16, + ), + const SizedBox(width: 4), + Text( + 'Icing protocol: ${icingProtocolOk ? "ok" : "ko"}', + style: TextStyle( color: icingProtocolOk ? Colors.green : Colors.red, - size: 16, - ), - const SizedBox(width: 4), - Text( - 'Icing protocol: ${icingProtocolOk ? "ok" : "ko"}', - style: TextStyle( - color: - icingProtocolOk ? Colors.green : Colors.red, - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - const SizedBox(height: 4), - Text( - _obfuscateService.obfuscateData(widget.displayName), - style: TextStyle( - fontSize: nameFontSize, - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - Text( - widget.phoneNumber, - style: TextStyle( - fontSize: statusFontSize, color: Colors.white70), - ), - Text( - _callStatus, - style: TextStyle( - fontSize: statusFontSize, color: Colors.white70), - ), - ], - ), - ), - Expanded( - child: Column( - children: [ - if (isKeypadVisible) ...[ - const Spacer(flex: 2), - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 20.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - _typedDigits, - maxLines: 1, - textAlign: TextAlign.right, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 24, - color: Colors.white, - fontWeight: FontWeight.bold, - ), - ), - ), - IconButton( - padding: EdgeInsets.zero, - onPressed: _toggleKeypad, - icon: const Icon(Icons.close, - color: Colors.white), - ), - ], + fontSize: 12, + fontWeight: FontWeight.bold, ), ), - Container( - height: MediaQuery.of(context).size.height * 0.35, - margin: const EdgeInsets.symmetric(horizontal: 20), - child: GridView.count( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - crossAxisCount: 3, - childAspectRatio: 1.3, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - children: List.generate(12, (index) { - String label; - if (index < 9) { - label = '${index + 1}'; - } else if (index == 9) { - label = '*'; - } else if (index == 10) { - label = '0'; - } else { - label = '#'; - } - return GestureDetector( - onTap: () => _addDigit(label), - child: Container( - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Colors.transparent, - ), - child: Center( - child: Text( - label, - style: const TextStyle( - fontSize: 32, color: Colors.white), - ), - ), - ), - ); - }), - ), - ), - const Spacer(flex: 1), - ] else ...[ - const Spacer(), - Padding( - padding: - const EdgeInsets.symmetric(horizontal: 32.0), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisAlignment: - MainAxisAlignment.spaceEvenly, - children: [ - Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: _toggleMute, - icon: Icon( - isMuted ? Icons.mic_off : Icons.mic, - color: isMuted - ? Colors.amber - : Colors.white, - size: 32, - ), - ), - Text( - isMuted ? 'Unmute' : 'Mute', - style: const TextStyle( - color: Colors.white, - fontSize: 14), - ), - ], - ), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: _toggleKeypad, - icon: const Icon( - Icons.dialpad, - color: Colors.white, - size: 32, - ), - ), - const Text( - 'Keypad', - style: TextStyle( - color: Colors.white, - fontSize: 14), - ), - ], - ), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: _toggleSpeaker, - icon: Icon( - isSpeaker - ? Icons.volume_up - : Icons.volume_off, - color: isSpeaker - ? Colors.amber - : Colors.white, - size: 32, - ), - ), - const Text( - 'Speaker', - style: TextStyle( - color: Colors.white, - fontSize: 14), - ), - ], - ), - ], - ), - const SizedBox(height: 20), - Row( - mainAxisAlignment: - MainAxisAlignment.spaceEvenly, - children: [ - if (isNumberUnknown) - Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: _addContact, - icon: const Icon( - Icons.person_add, - color: Colors.white, - size: 32, - ), - ), - const Text( - 'Add Contact', - style: TextStyle( - color: Colors.white, - fontSize: 14), - ), - ], - ), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - onPressed: () {}, - icon: const Icon( - Icons.sim_card, - color: Colors.white, - size: 32, - ), - ), - const Text( - 'Change SIM', - style: TextStyle( - color: Colors.white, - fontSize: 14), - ), - ], - ), - ], - ), - ], - ), - ), - const Spacer(flex: 3), ], - ], - ), - ), - Padding( - padding: const EdgeInsets.only(bottom: 16.0), - child: GestureDetector( - onTap: _hangUp, - child: Container( - padding: const EdgeInsets.all(12), - decoration: const BoxDecoration( - color: Colors.red, - shape: BoxShape.circle, - ), - child: const Icon( - Icons.call_end, + ), + const SizedBox(height: 4), + Text( + _obfuscateService.obfuscateData(widget.displayName), + style: TextStyle( + fontSize: nameFontSize, color: Colors.white, - size: 32, + fontWeight: FontWeight.bold, ), ), + Text( + widget.phoneNumber, + style: TextStyle( + fontSize: statusFontSize, + color: Colors.white70, + ), + ), + Text( + _callStatus, + style: TextStyle( + fontSize: statusFontSize, + color: Colors.white70, + ), + ), + ], + ), + ), + Expanded( + child: Column( + children: [ + if (isKeypadVisible) ...[ + const Spacer(flex: 2), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + _typedDigits, + maxLines: 1, + textAlign: TextAlign.right, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 24, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + padding: EdgeInsets.zero, + onPressed: _toggleKeypad, + icon: const Icon( + Icons.close, + color: Colors.white, + ), + ), + ], + ), + ), + Container( + height: MediaQuery.of(context).size.height * 0.4, + margin: const EdgeInsets.symmetric(horizontal: 20), + padding: const EdgeInsets.all(8), + child: GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 3, + childAspectRatio: 1.5, + mainAxisSpacing: 8, + crossAxisSpacing: 8, + children: List.generate(12, (index) { + String label; + if (index < 9) { + label = '${index + 1}'; + } else if (index == 9) { + label = '*'; + } else if (index == 10) { + label = '0'; + } else { + label = '#'; + } + return GestureDetector( + onTap: () => _addDigit(label), + child: Container( + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Colors.transparent, + ), + child: Center( + child: Text( + label, + style: const TextStyle( + fontSize: 32, + color: Colors.white, + ), + ), + ), + ), + ); + }), + ), + ), + const Spacer(flex: 1), + ] else ...[ + const Spacer(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: + MainAxisAlignment.spaceEvenly, + children: [ + Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: _toggleMute, + icon: Icon( + isMuted ? Icons.mic_off : Icons.mic, + color: isMuted + ? Colors.amber + : Colors.white, + size: 32, + ), + ), + Text( + isMuted ? 'Unmute' : 'Mute', + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + ], + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: _toggleKeypad, + icon: const Icon( + Icons.dialpad, + color: Colors.white, + size: 32, + ), + ), + const Text( + 'Keypad', + style: TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + ], + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: _toggleSpeaker, + icon: Icon( + isSpeaker + ? Icons.volume_up + : Icons.volume_off, + color: isSpeaker + ? Colors.amber + : Colors.white, + size: 32, + ), + ), + const Text( + 'Speaker', + style: TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + ], + ), + ], + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: + MainAxisAlignment.spaceEvenly, + children: [ + if (isNumberUnknown) + Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: _addContact, + icon: const Icon( + Icons.person_add, + color: Colors.white, + size: 32, + ), + ), + const Text( + 'Add Contact', + style: TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + ], + ), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: () {}, + icon: const Icon( + Icons.sim_card, + color: Colors.white, + size: 32, + ), + ), + const Text( + 'Change SIM', + style: TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + ], + ), + ], + ), + ], + ), + ), + const Spacer(flex: 3), + ], + ], + ), + ), + Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: GestureDetector( + onTap: _hangUp, + child: Container( + padding: const EdgeInsets.all(12), + decoration: const BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.call_end, + color: Colors.white, + size: 32, + ), ), ), - ], - ), + ), + ], ), ), - )); + ), + ), + ); } }