Compare commits

...

5 Commits

Author SHA1 Message Date
f491fb61e0 fix: no screen stacking when making calls
All checks were successful
/ mirror (push) Successful in 4s
2025-04-17 15:55:49 +03:00
acbccaac74 feat: give parameters to callPage to avoid fetching when possible 2025-04-17 15:55:49 +03:00
20d8c9643f feat: fetch contact in makeGsmCall to show it in call 2025-04-17 15:55:49 +03:00
22941f78d0 Argiliser exemples (#53)
All checks were successful
/ mirror (push) Successful in 4s
Reviewed-on: #53
2025-04-15 12:54:41 +00:00
b9dd156eca fix: search bar is non case sensitive and don't have delay (contact page) (#50)
All checks were successful
/ mirror (push) Successful in 8s
/ build (push) Successful in 14m22s
/ build-stealth (push) Successful in 6m28s
Co-authored-by: stcb <21@stcb.cc>
Reviewed-on: #50
Co-authored-by: Florian Griffon <florian.griffon@epitech.eu>
Co-committed-by: Florian Griffon <florian.griffon@epitech.eu>
2025-04-09 10:43:31 +00:00
5 changed files with 193 additions and 104 deletions

View File

@ -267,8 +267,12 @@ class _ContactModalState extends State<ContactModal> {
), ),
onTap: () async { onTap: () async {
if (widget.contact.phones.isNotEmpty) { if (widget.contact.phones.isNotEmpty) {
await _callService.makeGsmCall(context, await _callService.makeGsmCall(
phoneNumber: phoneNumber); context,
phoneNumber: phoneNumber,
displayName: widget.contact.displayName,
thumbnail: widget.contact.thumbnail,
);
} }
}, },
), ),

View File

@ -425,7 +425,12 @@ class _HistoryPageState extends State<HistoryPage>
icon: const Icon(Icons.phone, color: Colors.green), icon: const Icon(Icons.phone, color: Colors.green),
onPressed: () async { onPressed: () async {
if (contact.phones.isNotEmpty) { 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 { } else {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(

View File

@ -10,53 +10,82 @@ import '../../services/contact_service.dart';
import 'package:dialer/features/voicemail/voicemail_page.dart'; import 'package:dialer/features/voicemail/voicemail_page.dart';
import '../contacts/widgets/contact_modal.dart'; import '../contacts/widgets/contact_modal.dart';
class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {
class _MyHomePageState extends State<MyHomePage>
with SingleTickerProviderStateMixin {
late TabController _tabController; late TabController _tabController;
List<Contact> _allContacts = []; List<Contact> _allContacts = [];
List<Contact> _contactSuggestions = []; List<Contact> _contactSuggestions = [];
final ContactService _contactService = ContactService(); final ContactService _contactService = ContactService();
final ObfuscateService _obfuscateService = ObfuscateService(); final ObfuscateService _obfuscateService = ObfuscateService();
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
late SearchController _searchBarController;
String _rawSearchInput = '';
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Set the TabController length to 4
_tabController = TabController(length: 4, vsync: this, initialIndex: 2); _tabController = TabController(length: 4, vsync: this, initialIndex: 2);
_tabController.addListener(_handleTabIndex); _tabController.addListener(_handleTabIndex);
_searchBarController = SearchController();
_searchBarController.addListener(() {
if (_searchController.text != _searchBarController.text) {
_rawSearchInput = _searchBarController.text;
_searchController.text = _rawSearchInput;
_onSearchChanged(_searchBarController.text);
}
});
_fetchContacts(); _fetchContacts();
} }
void _fetchContacts() async { void _fetchContacts() async {
_allContacts = await _contactService.fetchContacts(); _allContacts = await _contactService.fetchContacts();
setState(() {}); _contactSuggestions = List.from(_allContacts);
if (mounted) setState(() {});
} }
void _clearSearch() { void _clearSearch() {
_searchController.clear(); _searchController.clear();
_searchBarController.clear();
_rawSearchInput = '';
_onSearchChanged(''); _onSearchChanged('');
} }
void _onSearchChanged(String query) { void _onSearchChanged(String query) {
setState(() { setState(() {
if (query.isEmpty) { if (query.isEmpty) {
_contactSuggestions = List.from(_allContacts); // Reset suggestions _contactSuggestions = List.from(_allContacts);
} else { } else {
final normalizedQuery = _normalizeString(query.toLowerCase());
_contactSuggestions = _allContacts.where((contact) { _contactSuggestions = _allContacts.where((contact) {
return contact.displayName final normalizedName = _normalizeString(contact.displayName.toLowerCase());
.toLowerCase() return normalizedName.contains(normalizedQuery);
.contains(query.toLowerCase());
}).toList(); }).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 @override
void dispose() { void dispose() {
_searchController.dispose(); _searchController.dispose();
_searchBarController.dispose();
_tabController.removeListener(_handleTabIndex); _tabController.removeListener(_handleTabIndex);
_tabController.dispose(); _tabController.dispose();
super.dispose(); super.dispose();
@ -69,19 +98,18 @@ class _MyHomePageState extends State<MyHomePage>
void _toggleFavorite(Contact contact) async { void _toggleFavorite(Contact contact) async {
try { try {
if (await FlutterContacts.requestPermission()) { if (await FlutterContacts.requestPermission()) {
Contact? fullContact = await FlutterContacts.getContact(contact.id, Contact? fullContact = await FlutterContacts.getContact(
withProperties: true, contact.id,
withAccounts: true, withProperties: true,
withPhoto: true, withAccounts: true,
withThumbnail: true); withPhoto: true,
withThumbnail: true,
);
if (fullContact != null) { if (fullContact != null) {
fullContact.isStarred = !fullContact.isStarred; fullContact.isStarred = !fullContact.isStarred;
await FlutterContacts.updateContact(fullContact); await FlutterContacts.updateContact(fullContact);
setState(() { _fetchContacts();
// Updating the contact list after toggling the favorite
_fetchContacts();
});
} }
} else { } else {
print("Could not fetch contact details"); print("Could not fetch contact details");
@ -100,7 +128,6 @@ class _MyHomePageState extends State<MyHomePage>
backgroundColor: Colors.black, backgroundColor: Colors.black,
body: Column( body: Column(
children: [ children: [
// Persistent Search Bar
Padding( Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
top: 24.0, top: 24.0,
@ -118,35 +145,33 @@ class _MyHomePageState extends State<MyHomePage>
border: Border.all(color: Colors.grey.shade800, width: 1), border: Border.all(color: Colors.grey.shade800, width: 1),
), ),
child: SearchAnchor( child: SearchAnchor(
builder: searchController: _searchBarController,
(BuildContext context, SearchController controller) { builder: (BuildContext context, SearchController controller) {
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
controller.openView(); // Open the search view controller.openView();
}, },
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color.fromARGB(255, 30, 30, 30), color: const Color.fromARGB(255, 30, 30, 30),
borderRadius: BorderRadius.circular(12.0), borderRadius: BorderRadius.circular(12.0),
border: Border.all( border: Border.all(color: Colors.grey.shade800, width: 1),
color: Colors.grey.shade800, width: 1),
), ),
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 16.0),
vertical: 12.0, horizontal: 16.0),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.search, const Icon(Icons.search, color: Colors.grey, size: 24.0),
color: Colors.grey, size: 24.0),
const SizedBox(width: 8.0), const SizedBox(width: 8.0),
Text( Expanded(
_searchController.text.isEmpty child: Text(
? 'Search contacts' _rawSearchInput.isEmpty
: _searchController.text, ? 'Search contacts'
style: const TextStyle( : _rawSearchInput,
color: Colors.grey, fontSize: 16.0), style: const TextStyle(color: Colors.grey, fontSize: 16.0),
overflow: TextOverflow.ellipsis,
),
), ),
const Spacer(), if (_rawSearchInput.isNotEmpty)
if (_searchController.text.isNotEmpty)
GestureDetector( GestureDetector(
onTap: _clearSearch, onTap: _clearSearch,
child: const Icon( child: const Icon(
@ -161,23 +186,24 @@ class _MyHomePageState extends State<MyHomePage>
); );
}, },
viewOnChanged: (query) { viewOnChanged: (query) {
_onSearchChanged(query); // Update immediately
if (_searchBarController.text != query) {
_rawSearchInput = query;
_searchBarController.text = query;
_searchController.text = query;
}
_onSearchChanged(query);
}, },
suggestionsBuilder: suggestionsBuilder: (BuildContext context, SearchController controller) {
(BuildContext context, SearchController controller) {
return _contactSuggestions.map((contact) { return _contactSuggestions.map((contact) {
return ListTile( return ListTile(
key: ValueKey(contact.id), key: ValueKey(contact.id),
title: Text(_obfuscateService.obfuscateData(contact.displayName), title: Text(
style: const TextStyle(color: Colors.white)), _obfuscateService.obfuscateData(contact.displayName),
style: const TextStyle(color: Colors.white),
),
onTap: () { onTap: () {
// Clear the search text input
controller.text = '';
// Close the search view
controller.closeView(contact.displayName); controller.closeView(contact.displayName);
// Show the ContactModal when a contact is tapped
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
@ -186,34 +212,28 @@ class _MyHomePageState extends State<MyHomePage>
return ContactModal( return ContactModal(
contact: contact, contact: contact,
onEdit: () async { onEdit: () async {
if (await FlutterContacts if (await FlutterContacts.requestPermission()) {
.requestPermission()) { final updatedContact = await FlutterContacts
final updatedContact = .openExternalEdit(contact.id);
await FlutterContacts
.openExternalEdit(contact.id);
if (updatedContact != null) { if (updatedContact != null) {
_fetchContacts(); _fetchContacts();
Navigator.of(context).pop(); Navigator.of(context).pop();
ScaffoldMessenger.of(context) ScaffoldMessenger.of(context).showSnackBar(
.showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text(
'${contact.displayName} updated successfully!'), '${contact.displayName} updated successfully!'),
), ),
); );
} else { } else {
ScaffoldMessenger.of(context) ScaffoldMessenger.of(context).showSnackBar(
.showSnackBar(
SnackBar( SnackBar(
content: Text( content: Text('Edit canceled or failed.'),
'Edit canceled or failed.'),
), ),
); );
} }
} }
}, },
onToggleFavorite: () => onToggleFavorite: () => _toggleFavorite(contact),
_toggleFavorite(contact),
isFavorite: contact.isStarred, isFavorite: contact.isStarred,
); );
}, },
@ -225,7 +245,6 @@ class _MyHomePageState extends State<MyHomePage>
), ),
), ),
), ),
// 3-dot menu
PopupMenuButton<String>( PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, color: Colors.white), icon: const Icon(Icons.more_vert, color: Colors.white),
itemBuilder: (BuildContext context) => [ itemBuilder: (BuildContext context) => [
@ -238,8 +257,7 @@ class _MyHomePageState extends State<MyHomePage>
if (value == 'settings') { if (value == 'settings') {
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(builder: (context) => const SettingsPage()),
builder: (context) => const SettingsPage()),
); );
} }
}, },
@ -247,7 +265,6 @@ class _MyHomePageState extends State<MyHomePage>
], ],
), ),
), ),
// Main content with TabBarView
Expanded( Expanded(
child: Stack( child: Stack(
children: [ children: [
@ -322,4 +339,4 @@ class MyHomePage extends StatefulWidget {
@override @override
_MyHomePageState createState() => _MyHomePageState(); _MyHomePageState createState() => _MyHomePageState();
} }

View File

@ -1,12 +1,17 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../features/call/call_page.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';
import '../services/contact_service.dart';
class CallService { class CallService {
static const MethodChannel _channel = MethodChannel('call_service'); static const MethodChannel _channel = MethodChannel('call_service');
static String? currentPhoneNumber; static String? currentPhoneNumber;
static String? currentDisplayName;
static Uint8List? currentThumbnail;
static bool _isCallPageVisible = false; static bool _isCallPageVisible = false;
static String? _currentCallState;
final ContactService _contactService = ContactService();
static final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); static final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
@ -24,87 +29,138 @@ class CallService {
final phoneNumber = call.arguments["callId"] as String; final phoneNumber = call.arguments["callId"] as String;
final state = call.arguments["state"] as String; final state = call.arguments["state"] as String;
currentPhoneNumber = phoneNumber.replaceFirst('tel:', ''); currentPhoneNumber = phoneNumber.replaceFirst('tel:', '');
await _fetchContactInfo(currentPhoneNumber!);
print('CallService: Call added, number: $currentPhoneNumber, state: $state'); print('CallService: Call added, number: $currentPhoneNumber, state: $state');
if (state == "ringing") { _handleCallState(context, state);
_navigateToIncomingCallPage(context);
} else {
_navigateToCallPage(context);
}
break; break;
case "callStateChanged": case "callStateChanged":
final state = call.arguments["state"] as String; final state = call.arguments["state"] as String;
print('CallService: State changed to $state'); print('CallService: State changed to $state');
if (state == "disconnected" || state == "disconnecting") { _handleCallState(context, state);
_closeCallPage(context);
} else if (state == "active" || state == "dialing") {
_navigateToCallPage(context);
} else if (state == "ringing") {
_navigateToIncomingCallPage(context);
}
break; break;
case "callEnded": case "callEnded":
case "callRemoved": case "callRemoved":
print('CallService: Call ended/removed'); print('CallService: Call ended/removed');
_closeCallPage(context); _closeCallPage(context);
currentPhoneNumber = null; currentPhoneNumber = null;
currentDisplayName = null;
currentThumbnail = null;
_currentCallState = null;
break; break;
} }
}); });
} }
Future<void> _fetchContactInfo(String phoneNumber) async {
try {
final contacts = await _contactService.fetchContacts();
final normalizedPhoneNumber = _normalizePhoneNumber(phoneNumber);
for (var contact in contacts) {
for (var phone in contact.phones) {
if (_normalizePhoneNumber(phone.number) == normalizedPhoneNumber) {
currentDisplayName = contact.displayName;
currentThumbnail = contact.thumbnail;
return;
}
}
}
currentDisplayName = phoneNumber;
currentThumbnail = null;
} catch (e) {
print('CallService: Error fetching contact info: $e');
currentDisplayName = phoneNumber;
currentThumbnail = null;
}
}
String _normalizePhoneNumber(String number) {
return number.replaceAll(RegExp(r'[\s\-\(\)]'), '');
}
void _handleCallState(BuildContext context, String state) {
if (_currentCallState == state) {
print('CallService: State $state already handled, skipping');
return;
}
_currentCallState = state;
if (state == "disconnected" || state == "disconnecting") {
_closeCallPage(context);
} else if (state == "active" || state == "dialing") {
_navigateToCallPage(context);
} else if (state == "ringing") {
_navigateToIncomingCallPage(context);
}
}
void _navigateToCallPage(BuildContext context) { void _navigateToCallPage(BuildContext context) {
if (_isCallPageVisible && ModalRoute.of(context)?.settings.name == '/call') { final currentRoute = ModalRoute.of(context)?.settings.name;
print('CallService: Navigating to CallPage. Visible: $_isCallPageVisible, Current Route: $currentRoute');
if (_isCallPageVisible && currentRoute == '/call') {
print('CallService: CallPage already visible, skipping navigation'); print('CallService: CallPage already visible, skipping navigation');
return; return;
} }
print('CallService: Navigating to CallPage'); if (_isCallPageVisible && currentRoute == '/incoming_call') {
print('CallService: Replacing IncomingCallPage with CallPage');
Navigator.pop(context);
}
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,
MaterialPageRoute( MaterialPageRoute(
settings: const RouteSettings(name: '/call'), settings: const RouteSettings(name: '/call'),
builder: (context) => CallPage( builder: (context) => CallPage(
displayName: currentPhoneNumber!, displayName: currentDisplayName ?? currentPhoneNumber!,
phoneNumber: currentPhoneNumber!, phoneNumber: currentPhoneNumber!,
thumbnail: null, thumbnail: currentThumbnail,
), ),
), ),
).then((_) { ).then((_) {
print('CallService: CallPage popped');
_isCallPageVisible = false; _isCallPageVisible = false;
}); });
_isCallPageVisible = true; _isCallPageVisible = true;
} }
void _navigateToIncomingCallPage(BuildContext context) { void _navigateToIncomingCallPage(BuildContext context) {
if (_isCallPageVisible && ModalRoute.of(context)?.settings.name == '/incoming_call') { final currentRoute = ModalRoute.of(context)?.settings.name;
print('CallService: Navigating to IncomingCallPage. Visible: $_isCallPageVisible, Current Route: $currentRoute');
if (_isCallPageVisible && currentRoute == '/incoming_call') {
print('CallService: IncomingCallPage already visible, skipping navigation'); print('CallService: IncomingCallPage already visible, skipping navigation');
return; return;
} }
print('CallService: Navigating to IncomingCallPage'); if (_isCallPageVisible && currentRoute == '/call') {
print('CallService: CallPage visible, not showing IncomingCallPage');
return;
}
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
settings: const RouteSettings(name: '/incoming_call'), settings: const RouteSettings(name: '/incoming_call'),
builder: (context) => IncomingCallPage( builder: (context) => IncomingCallPage(
displayName: currentPhoneNumber!, displayName: currentDisplayName ?? currentPhoneNumber!,
phoneNumber: currentPhoneNumber!, phoneNumber: currentPhoneNumber!,
thumbnail: null, thumbnail: currentThumbnail,
), ),
), ),
).then((_) { ).then((_) {
print('CallService: IncomingCallPage popped');
_isCallPageVisible = false; _isCallPageVisible = false;
}); });
_isCallPageVisible = true; _isCallPageVisible = true;
} }
void _closeCallPage(BuildContext context) { void _closeCallPage(BuildContext context) {
print('CallService: Attempting to close call page. Visible: $_isCallPageVisible');
if (!_isCallPageVisible) { if (!_isCallPageVisible) {
print('CallService: CallPage not visible, skipping pop'); print('CallService: CallPage not visible, skipping pop');
return; return;
} }
if (Navigator.canPop(context)) { if (Navigator.canPop(context)) {
print('CallService: Popping CallPage'); print('CallService: Popping CallPage. Current Route: ${ModalRoute.of(context)?.settings.name}');
Navigator.pop(context); Navigator.pop(context);
_isCallPageVisible = false; _isCallPageVisible = false;
} else {
print('CallService: Cannot pop, no routes to pop');
} }
} }
@ -116,6 +172,11 @@ class CallService {
}) async { }) async {
try { try {
currentPhoneNumber = phoneNumber; currentPhoneNumber = phoneNumber;
currentDisplayName = displayName ?? phoneNumber;
currentThumbnail = thumbnail;
if (displayName == null || thumbnail == null) {
await _fetchContactInfo(phoneNumber);
}
print('CallService: Making GSM call to $phoneNumber'); print('CallService: Making GSM call to $phoneNumber');
final result = await _channel.invokeMethod('makeGsmCall', {"phoneNumber": phoneNumber}); final result = await _channel.invokeMethod('makeGsmCall', {"phoneNumber": phoneNumber});
print('CallService: makeGsmCall result: $result'); print('CallService: makeGsmCall result: $result');
@ -123,7 +184,9 @@ class CallService {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Failed to initiate call")), SnackBar(content: Text("Failed to initiate call")),
); );
return;
} }
_handleCallState(context, "dialing");
} catch (e) { } catch (e) {
print("CallService: Error making call: $e"); print("CallService: Error making call: $e");
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -142,6 +205,8 @@ class CallService {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Failed to end call")), SnackBar(content: Text("Failed to end call")),
); );
} else {
_closeCallPage(context);
} }
} catch (e) { } catch (e) {
print("CallService: Error hanging up call: $e"); print("CallService: Error hanging up call: $e");

View File

@ -27,14 +27,13 @@ The protocol definition will include as completed:
- Handshakes - Handshakes
- Real-time data-stream encryption (and decryption) - Real-time data-stream encryption (and decryption)
- Encrypted stream compression - Encrypted stream compression
- Transmission over audio stream - Transmission over audio stream (at least one modulation type)
- Minimal error correction in audio-based transmission - First steps in FEC (Forward Error Correction): detecting half of transmission errors
- Error handling and user prevention
And should include prototype or scratches functionalities, among which: 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) - 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) #### 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, 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. but also that the call is coming from Jeff's phone and not an impersonator.
Elise is a 42 years-old extreme reporter. Elise, 42 years-old, is a journalist covering sensitive topics.
After interviewing Russians opposition's leader, the FSB is looking to interview her. Her work draws attention from people who want to know what she's saying - and to whom.
She tries to stay discreet and hidden, but those measures constrains her to barely receive cellular network. Forced to stay discreet, with unreliable signal and a likely monitored phone line,
She suspects her phone line to be monitored, so the best she can do to call safely, is to use her Icing dialer. 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 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. 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 With Icing dialer, he can call his collegues and help fix the problem, completely safe.
problem, safe from potential Chinese spies.
## Evaluation Criteria ## Evaluation Criteria
### Protocol and lib ### Protocol and lib