Compare commits
5 Commits
e6e05f2686
...
f491fb61e0
Author | SHA1 | Date | |
---|---|---|---|
f491fb61e0 | |||
acbccaac74 | |||
20d8c9643f | |||
22941f78d0 | |||
b9dd156eca |
@ -267,8 +267,12 @@ class _ContactModalState extends State<ContactModal> {
|
||||
),
|
||||
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,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
|
@ -425,7 +425,12 @@ class _HistoryPageState extends State<HistoryPage>
|
||||
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(
|
||||
|
@ -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<MyHomePage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
List<Contact> _allContacts = [];
|
||||
List<Contact> _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<MyHomePage>
|
||||
void _toggleFavorite(Contact contact) async {
|
||||
try {
|
||||
if (await FlutterContacts.requestPermission()) {
|
||||
Contact? fullContact = await FlutterContacts.getContact(contact.id,
|
||||
Contact? fullContact = await FlutterContacts.getContact(
|
||||
contact.id,
|
||||
withProperties: true,
|
||||
withAccounts: true,
|
||||
withPhoto: true,
|
||||
withThumbnail: 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");
|
||||
@ -100,7 +128,6 @@ class _MyHomePageState extends State<MyHomePage>
|
||||
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<MyHomePage>
|
||||
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
|
||||
Expanded(
|
||||
child: Text(
|
||||
_rawSearchInput.isEmpty
|
||||
? 'Search contacts'
|
||||
: _searchController.text,
|
||||
style: const TextStyle(
|
||||
color: Colors.grey, fontSize: 16.0),
|
||||
: _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<MyHomePage>
|
||||
);
|
||||
},
|
||||
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<MyHomePage>
|
||||
return ContactModal(
|
||||
contact: contact,
|
||||
onEdit: () async {
|
||||
if (await FlutterContacts
|
||||
.requestPermission()) {
|
||||
final updatedContact =
|
||||
await FlutterContacts
|
||||
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<MyHomePage>
|
||||
),
|
||||
),
|
||||
),
|
||||
// 3-dot menu
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert, color: Colors.white),
|
||||
itemBuilder: (BuildContext context) => [
|
||||
@ -238,8 +257,7 @@ class _MyHomePageState extends State<MyHomePage>
|
||||
if (value == 'settings') {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SettingsPage()),
|
||||
MaterialPageRoute(builder: (context) => const SettingsPage()),
|
||||
);
|
||||
}
|
||||
},
|
||||
@ -247,7 +265,6 @@ class _MyHomePageState extends State<MyHomePage>
|
||||
],
|
||||
),
|
||||
),
|
||||
// Main content with TabBarView
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
|
@ -1,12 +1,17 @@
|
||||
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';
|
||||
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 String? _currentCallState;
|
||||
final ContactService _contactService = ContactService();
|
||||
|
||||
static final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
@ -24,16 +29,61 @@ class CallService {
|
||||
final phoneNumber = call.arguments["callId"] as String;
|
||||
final state = call.arguments["state"] as String;
|
||||
currentPhoneNumber = phoneNumber.replaceFirst('tel:', '');
|
||||
await _fetchContactInfo(currentPhoneNumber!);
|
||||
print('CallService: Call added, number: $currentPhoneNumber, state: $state');
|
||||
if (state == "ringing") {
|
||||
_navigateToIncomingCallPage(context);
|
||||
} else {
|
||||
_navigateToCallPage(context);
|
||||
}
|
||||
_handleCallState(context, state);
|
||||
break;
|
||||
case "callStateChanged":
|
||||
final state = call.arguments["state"] as String;
|
||||
print('CallService: State changed to $state');
|
||||
_handleCallState(context, state);
|
||||
break;
|
||||
case "callEnded":
|
||||
case "callRemoved":
|
||||
print('CallService: Call ended/removed');
|
||||
_closeCallPage(context);
|
||||
currentPhoneNumber = null;
|
||||
currentDisplayName = null;
|
||||
currentThumbnail = null;
|
||||
_currentCallState = null;
|
||||
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") {
|
||||
@ -41,70 +91,76 @@ class CallService {
|
||||
} 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') {
|
||||
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');
|
||||
return;
|
||||
}
|
||||
print('CallService: Navigating to CallPage');
|
||||
if (_isCallPageVisible && currentRoute == '/incoming_call') {
|
||||
print('CallService: Replacing IncomingCallPage with CallPage');
|
||||
Navigator.pop(context);
|
||||
}
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: '/call'),
|
||||
builder: (context) => CallPage(
|
||||
displayName: currentPhoneNumber!,
|
||||
displayName: currentDisplayName ?? currentPhoneNumber!,
|
||||
phoneNumber: currentPhoneNumber!,
|
||||
thumbnail: null,
|
||||
thumbnail: currentThumbnail,
|
||||
),
|
||||
),
|
||||
).then((_) {
|
||||
print('CallService: CallPage popped');
|
||||
_isCallPageVisible = false;
|
||||
});
|
||||
_isCallPageVisible = true;
|
||||
}
|
||||
|
||||
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');
|
||||
return;
|
||||
}
|
||||
print('CallService: Navigating to IncomingCallPage');
|
||||
if (_isCallPageVisible && currentRoute == '/call') {
|
||||
print('CallService: CallPage visible, not showing IncomingCallPage');
|
||||
return;
|
||||
}
|
||||
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((_) {
|
||||
print('CallService: IncomingCallPage popped');
|
||||
_isCallPageVisible = false;
|
||||
});
|
||||
_isCallPageVisible = true;
|
||||
}
|
||||
|
||||
void _closeCallPage(BuildContext context) {
|
||||
print('CallService: Attempting to close call page. Visible: $_isCallPageVisible');
|
||||
if (!_isCallPageVisible) {
|
||||
print('CallService: CallPage not visible, skipping pop');
|
||||
return;
|
||||
}
|
||||
if (Navigator.canPop(context)) {
|
||||
print('CallService: Popping CallPage');
|
||||
print('CallService: Popping CallPage. Current Route: ${ModalRoute.of(context)?.settings.name}');
|
||||
Navigator.pop(context);
|
||||
_isCallPageVisible = false;
|
||||
} else {
|
||||
print('CallService: Cannot pop, no routes to pop');
|
||||
}
|
||||
}
|
||||
|
||||
@ -116,6 +172,11 @@ class CallService {
|
||||
}) async {
|
||||
try {
|
||||
currentPhoneNumber = phoneNumber;
|
||||
currentDisplayName = displayName ?? phoneNumber;
|
||||
currentThumbnail = thumbnail;
|
||||
if (displayName == null || thumbnail == null) {
|
||||
await _fetchContactInfo(phoneNumber);
|
||||
}
|
||||
print('CallService: Making GSM call to $phoneNumber');
|
||||
final result = await _channel.invokeMethod('makeGsmCall', {"phoneNumber": phoneNumber});
|
||||
print('CallService: makeGsmCall result: $result');
|
||||
@ -123,7 +184,9 @@ class CallService {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("Failed to initiate call")),
|
||||
);
|
||||
return;
|
||||
}
|
||||
_handleCallState(context, "dialing");
|
||||
} catch (e) {
|
||||
print("CallService: Error making call: $e");
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@ -142,6 +205,8 @@ class CallService {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("Failed to end call")),
|
||||
);
|
||||
} else {
|
||||
_closeCallPage(context);
|
||||
}
|
||||
} catch (e) {
|
||||
print("CallService: Error hanging up call: $e");
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user