From 23c1839e7b578390c704330f886712ba5fb5f688 Mon Sep 17 00:00:00 2001 From: ange Date: Mon, 17 Feb 2025 09:29:24 +0000 Subject: [PATCH 1/2] CI/CD build apk (#5) Reviewed-on: https://git.gmoker.com/icing/monorepo/pulls/5 Co-authored-by: ange Co-committed-by: ange --- .gitea/workflows/apk.yaml | 17 +++++++++++++++++ .gitea/workflows/website.yaml | 7 ++----- website/open.sh | 2 +- 3 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 .gitea/workflows/apk.yaml diff --git a/.gitea/workflows/apk.yaml b/.gitea/workflows/apk.yaml new file mode 100644 index 0000000..db63776 --- /dev/null +++ b/.gitea/workflows/apk.yaml @@ -0,0 +1,17 @@ +on: + push: + paths: + - dialer/** + +jobs: + build: + runs-on: debian + steps: + - uses: actions/checkout@v1 + with: + subpath: dialer/ + - uses: 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 diff --git a/.gitea/workflows/website.yaml b/.gitea/workflows/website.yaml index 56d3687..9a4c6bd 100644 --- a/.gitea/workflows/website.yaml +++ b/.gitea/workflows/website.yaml @@ -6,11 +6,10 @@ on: jobs: deploy: runs-on: debian - defaults: - run: - working-directory: website steps: - uses: actions/checkout@v1 + with: + subpath: website/ - name: setup env run: | . ./.env || true @@ -29,10 +28,8 @@ jobs: - uses: actions/kaniko@v1 with: password: "${{ secrets.PKGRW }}" - dockerfile: website/Dockerfile - uses: actions/k8sdeploy@v1 with: kubeconfig: "${{ secrets.K8S }}" registry_password: "${{ secrets.PKGRW }}" - workdir: website diff --git a/website/open.sh b/website/open.sh index f39b04f..743e671 100755 --- a/website/open.sh +++ b/website/open.sh @@ -2,4 +2,4 @@ branch="$(git describe --contains --all HEAD)" -xdg-open "https://$branch.g-eip-700-tls-7-1-eip-stephane.corbiere.icing.k8s.gmoker.com" +xdg-open "https://$branch.monorepo.icing.k8s.gmoker.com" From 6ada4f6e5c1fcd7079cf48b22daecd8b0afec7e7 Mon Sep 17 00:00:00 2001 From: Bartosz Date: Mon, 17 Feb 2025 09:49:50 +0000 Subject: [PATCH 2/2] Ready: Fix History (#35) Co-authored-by: stcb <21@stcb.cc> Reviewed-on: https://git.gmoker.com/icing/monorepo/pulls/35 Co-authored-by: Bartosz Co-committed-by: Bartosz --- dialer/android/.gitignore | 1 + .../android/app/src/main/AndroidManifest.xml | 4 + .../kotlin/com/icing/dialer/MainActivity.kt | 59 +++- dialer/lib/features/history/history_page.dart | 288 +++++++++++------- dialer/pubspec.yaml | 2 + 5 files changed, 241 insertions(+), 113 deletions(-) diff --git a/dialer/android/.gitignore b/dialer/android/.gitignore index e409267..e6d71b3 100644 --- a/dialer/android/.gitignore +++ b/dialer/android/.gitignore @@ -4,6 +4,7 @@ gradle-wrapper.jar /gradlew /gradlew.bat /local.properties +/gradle.properties GeneratedPluginRegistrant.java gradle.properties diff --git a/dialer/android/app/src/main/AndroidManifest.xml b/dialer/android/app/src/main/AndroidManifest.xml index cb4976e..138e514 100644 --- a/dialer/android/app/src/main/AndroidManifest.xml +++ b/dialer/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,10 @@ + + + + - // Delegate method calls to KeystoreHelper - KeystoreHelper(call, result).handleMethodCall() + + // Set up the keystore channel. + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, KEYSTORE_CHANNEL) + .setMethodCallHandler { call, result -> + // Delegate method calls to KeystoreHelper. + KeystoreHelper(call, result).handleMethodCall() + } + + // Set up the call log channel. + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALLLOG_CHANNEL) + .setMethodCallHandler { call, result -> + if (call.method == "getCallLogs") { + val callLogs = getCallLogs() + result.success(callLogs) + } else { + result.notImplemented() + } + } + } + + /** + * Queries the Android call log and returns a list of maps. + * Each map contains keys: "number", "type", "date", and "duration". + */ + private fun getCallLogs(): List> { + val logsList = mutableListOf>() + val cursor: Cursor? = contentResolver.query( + 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)) + + 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 + logsList.add(map) + } + cursor.close() } + return logsList } } diff --git a/dialer/lib/features/history/history_page.dart b/dialer/lib/features/history/history_page.dart index 6054173..1108a2e 100644 --- a/dialer/lib/features/history/history_page.dart +++ b/dialer/lib/features/history/history_page.dart @@ -1,8 +1,11 @@ +import 'dart:async'; import 'package:dialer/services/obfuscate_service.dart'; import 'package:dialer/widgets/color_darkener.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_contacts/flutter_contacts.dart'; import 'package:intl/intl.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:dialer/features/contacts/contact_state.dart'; import 'package:dialer/widgets/username_color_generator.dart'; @@ -17,12 +20,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 { @@ -37,9 +40,11 @@ class _HistoryPageState extends State List histories = []; bool loading = true; int? _expandedIndex; - final ObfuscateService _obfuscateService = ObfuscateService(); + // Create a MethodChannel instance. + static const MethodChannel _channel = MethodChannel('com.example.calllog'); + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -51,19 +56,16 @@ class _HistoryPageState extends State Future _refreshContacts() async { final contactState = ContactState.of(context); try { - // Refresh contacts or fetch them again await contactState.fetchContacts(); } catch (e) { print('Error refreshing contacts: $e'); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Failed to refresh contacts')), - ); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Failed to refresh contacts'))); } } void _toggleFavorite(Contact contact) async { try { - // Ensure you have the necessary permissions to fetch contact details if (await FlutterContacts.requestPermission()) { Contact? fullContact = await FlutterContacts.getContact(contact.id, withProperties: true, @@ -75,22 +77,68 @@ class _HistoryPageState extends State fullContact.isStarred = !fullContact.isStarred; await FlutterContacts.updateContact(fullContact); } - await _refreshContacts(); // Refresh the contact list + await _refreshContacts(); } 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')), - ); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Failed to update favorite status'))); } } + /// Helper: Remove all non-digit characters for simple matching. + String sanitizeNumber(String number) { + return number.replaceAll(RegExp(r'\D'), ''); + } + + /// Helper: Find a contact from our list by matching phone numbers. + Contact? findContactForNumber(String number, List contacts) { + final sanitized = sanitizeNumber(number); + for (var contact in contacts) { + for (var phone in contact.phones) { + if (sanitizeNumber(phone.number) == sanitized) { + return contact; + } + } + } + return null; + } + + /// Request permission for reading call logs. + Future _requestCallLogPermission() async { + var status = await Permission.phone.status; + if (!status.isGranted) { + status = await Permission.phone.request(); + } + return status.isGranted; + } + + /// Build histories from the native call log using the method channel. Future _buildHistories() async { + // Request permission. + bool hasPermission = await _requestCallLogPermission(); + if (!hasPermission) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Call log permission not granted'))); + setState(() { + loading = false; + }); + return; + } + + // Retrieve call logs from native code. + List nativeLogs = []; + try { + nativeLogs = await _channel.invokeMethod('getCallLogs'); + } on PlatformException catch (e) { + print("Error fetching call logs: ${e.message}"); + } + + // Ensure contacts are loaded. final contactState = ContactState.of(context); if (contactState.loading) { - // Wait for contacts to be loaded await Future.doWhile(() async { await Future.delayed(const Duration(milliseconds: 100)); return contactState.loading; @@ -98,30 +146,66 @@ class _HistoryPageState extends State } List contacts = contactState.contacts; - if (contacts.isEmpty) { - setState(() { - loading = false; - }); - return; + List callHistories = []; + // Process each log entry. + for (var entry in nativeLogs) { + // Each entry is a Map with keys: number, type, date, duration. + final String number = entry['number'] ?? ''; + if (number.isEmpty) continue; + + // Convert timestamp to DateTime. + DateTime callDate = + DateTime.fromMillisecondsSinceEpoch(entry['date'] ?? 0); + + int typeInt = entry['type'] ?? 0; + int duration = entry['duration'] ?? 0; + String callType; + String callStatus; + + // Map integer values to call type/status. + // Commonly: 1 = incoming, 2 = outgoing, 3 = missed. + switch (typeInt) { + case 1: + callType = "incoming"; + callStatus = (duration == 0) ? "missed" : "answered"; + break; + case 2: + callType = "outgoing"; + callStatus = "answered"; + break; + case 3: + callType = "incoming"; + callStatus = "missed"; + break; + default: + callType = "unknown"; + callStatus = "unknown"; + } + + // Try to find a matching contact. + Contact? matchedContact = findContactForNumber(number, contacts); + if (matchedContact == null) { + // Create a dummy contact if not found. + matchedContact = Contact( + id: "dummy-$number", + displayName: number, + phones: [Phone(number)], + ); + } + + callHistories.add(History(matchedContact, callDate, callType, callStatus, 1)); } + // Sort histories by most recent. + callHistories.sort((a, b) => b.date.compareTo(a.date)); + setState(() { - histories = List.generate( - contacts.length >= 10 ? 10 : contacts.length, - (index) => History( - contacts[index], - DateTime.now().subtract(Duration(hours: (index + 1) * 2)), - index % 2 == 0 ? 'outgoing' : 'incoming', - index % 3 == 0 ? 'missed' : 'answered', - index % 3 + 1, - ), - ); + histories = callHistories; loading = false; }); } List _buildGroupedList(List historyList) { - // Sort histories by date (most recent first) historyList.sort((a, b) => b.date.compareTo(a.date)); final now = DateTime.now(); @@ -134,7 +218,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) { @@ -144,7 +228,6 @@ class _HistoryPageState extends State } } - // Combine them with headers final items = []; if (todayHistories.isNotEmpty) { items.add('Today'); @@ -162,6 +245,28 @@ class _HistoryPageState extends State return items; } + /// Returns an icon reflecting call type and status. + Icon _getCallIcon(History history) { + IconData iconData; + Color iconColor; + if (history.callType == 'incoming') { + if (history.callStatus == 'missed') { + iconData = Icons.call_missed; + iconColor = Colors.red; + } else { + iconData = Icons.call_received; + iconColor = Colors.green; + } + } else if (history.callType == 'outgoing') { + iconData = Icons.call_made; + iconColor = Colors.green; + } else { + iconData = Icons.phone; + iconColor = Colors.white; + } + return Icon(iconData, color: iconColor); + } + @override Widget build(BuildContext context) { final contactState = ContactState.of(context); @@ -169,9 +274,7 @@ class _HistoryPageState extends State if (loading || contactState.loading) { return Scaffold( backgroundColor: Colors.black, - body: const Center( - child: CircularProgressIndicator(), - ), + body: const Center(child: CircularProgressIndicator()), ); } @@ -187,9 +290,8 @@ class _HistoryPageState extends State ); } - // Filter missed calls List missedCalls = - histories.where((h) => h.callStatus == 'missed').toList(); + histories.where((h) => h.callStatus == 'missed').toList(); final allItems = _buildGroupedList(histories); final missedItems = _buildGroupedList(missedCalls); @@ -213,9 +315,7 @@ class _HistoryPageState extends State ), body: TabBarView( children: [ - // All Calls _buildListView(allItems), - // Missed Calls _buildListView(missedItems), ], ), @@ -228,9 +328,7 @@ class _HistoryPageState extends State itemCount: items.length, itemBuilder: (context, index) { final item = items[index]; - if (item is String) { - // This is a header item return Container( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), color: Colors.grey[900], @@ -246,16 +344,12 @@ class _HistoryPageState extends State final history = item; final contact = history.contact; final isExpanded = _expandedIndex == index; - - // Generate the avatar color Color avatarColor = generateColorFromName(contact.displayName); - return Column( children: [ ListTile( leading: GestureDetector( onTap: () { - // When the profile picture is tapped, show the ContactModal showModalBottomSheet( context: context, isScrollControlled: true, @@ -266,8 +360,7 @@ 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(); @@ -306,12 +399,14 @@ class _HistoryPageState extends State style: const TextStyle(color: Colors.white), ), subtitle: Text( - '${history.callType} - ${history.callStatus} - ${DateFormat('MMM dd, hh:mm a').format(history.date)}', + DateFormat('MMM dd, hh:mm a').format(history.date), style: const TextStyle(color: Colors.grey), ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ + _getCallIcon(history), + const SizedBox(width: 8), Text( '${history.attempts}x', style: const TextStyle(color: Colors.white), @@ -320,20 +415,18 @@ 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); + 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')), + const SnackBar(content: Text('Could not launch call')), ); } } else { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Contact has no phone number')), + const SnackBar(content: Text('Contact has no phone number')), ); } }, @@ -351,9 +444,7 @@ 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( @@ -369,37 +460,29 @@ 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 { @@ -408,30 +491,24 @@ 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(() {}); // Refresh the button state + 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)), @@ -444,7 +521,6 @@ class _HistoryPageState extends State ], ); } - return const SizedBox.shrink(); }, ); @@ -473,27 +549,26 @@ class CallDetailsPage extends StatelessWidget { padding: const EdgeInsets.all(16.0), child: Column( children: [ - // Display Contact Name and Thumbnail + // Display Contact Name and Thumbnail. Row( 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( @@ -504,8 +579,7 @@ class CallDetailsPage extends StatelessWidget { ], ), const SizedBox(height: 24), - - // Display call type, status, date, attempts + // Display call details. DetailRow( label: 'Call Type:', value: history.callType, @@ -522,15 +596,11 @@ class CallDetailsPage extends StatelessWidget { label: 'Attempts:', value: '${history.attempts}', ), - const SizedBox(height: 24), - - // If you have more details like duration, contact number, etc. if (contact.phones.isNotEmpty) DetailRow( label: 'Number:', - value: _obfuscateService - .obfuscateData(contact.phones.first.number), + value: _obfuscateService.obfuscateData(contact.phones.first.number), ), ], ), diff --git a/dialer/pubspec.yaml b/dialer/pubspec.yaml index b3bbda7..244050a 100644 --- a/dialer/pubspec.yaml +++ b/dialer/pubspec.yaml @@ -56,6 +56,8 @@ dependencies: uuid: ^4.5.1 provider: ^6.1.2 + intl: any + dev_dependencies: flutter_test: sdk: flutter