diff --git a/dialer/android/app/src/main/AndroidManifest.xml b/dialer/android/app/src/main/AndroidManifest.xml index db77a47..6e89d61 100644 --- a/dialer/android/app/src/main/AndroidManifest.xml +++ b/dialer/android/app/src/main/AndroidManifest.xml @@ -1,52 +1,49 @@ - - - - - - - - + + + + + + + + + + + + + - - + + + + + + - - + + + + + + + - + In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. --> - + \ No newline at end of file diff --git a/dialer/android/app/src/main/kotlin/com/example/dialer/MainActivity.kt b/dialer/android/app/src/main/kotlin/com/example/dialer/MainActivity.kt index 1ad0677..cc34665 100644 --- a/dialer/android/app/src/main/kotlin/com/example/dialer/MainActivity.kt +++ b/dialer/android/app/src/main/kotlin/com/example/dialer/MainActivity.kt @@ -1,5 +1,160 @@ +// File: android/app/src/main/kotlin/com/example/dialer/MainActivity.kt package com.example.dialer +import android.app.role.RoleManager +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import android.os.Bundle +import android.os.Handler +import android.telecom.PhoneAccount +import android.telecom.PhoneAccountHandle +import android.telecom.TelecomManager +import android.util.Log import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel -class MainActivity: FlutterActivity() +class MainActivity : FlutterActivity() { + private val CHANNEL = "com.example.dialer/call_events" + private lateinit var methodChannel: MethodChannel + + // Request code for the default-dialer request. + private val DEFAULT_DIALER_REQUEST_CODE = 1001 + + // BroadcastReceiver to catch incoming call events from our ConnectionService. + private val incomingCallReceiver = object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + val phoneNumber = intent?.getStringExtra("phoneNumber") ?: "Unknown" + notifyIncomingCall(phoneNumber) + } + } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL) + methodChannel.setMethodCallHandler { call, result -> + when (call.method) { + "requestDefaultDialer" -> { + requestDefaultDialer() + result.success(null) + } + else -> result.notImplemented() + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Create the intent filter for incoming calls. + val filter = IntentFilter("com.example.dialer.INCOMING_CALL") + // Register the receiver. (For Android 13+ we must specify not exported.) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(incomingCallReceiver, filter, Context.RECEIVER_NOT_EXPORTED) + } else { + registerReceiver(incomingCallReceiver, filter) + } + } + + override fun onDestroy() { + super.onDestroy() + unregisterReceiver(incomingCallReceiver) + } + + override fun onResume() { + super.onResume() + // Check if we are now the default dialer and update the UI accordingly. + if (isDefaultDialer()) { + registerManagedPhoneAccount() + notifyDefaultDialerSet() + } + } + + private fun isDefaultDialer(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val roleManager = getSystemService(RoleManager::class.java) + roleManager.isRoleHeld(RoleManager.ROLE_DIALER) + } else { + val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager + telecomManager.defaultDialerPackage == packageName + } + } + + // Request to become the default dialer. + private fun requestDefaultDialer() { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // Use the RoleManager API for Android Q and above. + val roleManager = getSystemService(RoleManager::class.java) + if (!roleManager.isRoleHeld(RoleManager.ROLE_DIALER)) { + val intent = roleManager.createRequestRoleIntent(RoleManager.ROLE_DIALER) + startActivityForResult(intent, DEFAULT_DIALER_REQUEST_CODE) + Log.d("MainActivity", "Requested ROLE_DIALER via RoleManager") + } else { + Log.d("MainActivity", "Already default dialer (ROLE_DIALER held)") + registerManagedPhoneAccount() + notifyDefaultDialerSet() + } + } else { + // Fallback for older versions. + val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager + if (telecomManager.defaultDialerPackage != packageName) { + val intent = Intent(TelecomManager.ACTION_CHANGE_DEFAULT_DIALER) + intent.putExtra(TelecomManager.EXTRA_CHANGE_DEFAULT_DIALER_PACKAGE_NAME, packageName) + startActivityForResult(intent, DEFAULT_DIALER_REQUEST_CODE) + Log.d("MainActivity", "Requested default dialer via TelecomManager") + } else { + registerManagedPhoneAccount() + notifyDefaultDialerSet() + } + } + } catch (ex: Exception) { + Log.e("MainActivity", "Error requesting default dialer", ex) + } + } + + // Handle the result from the default-dialer role request. + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + if (requestCode == DEFAULT_DIALER_REQUEST_CODE) { + Log.d("MainActivity", "onActivityResult received: resultCode=$resultCode") + // Delay a short time before checking the role. + Handler(mainLooper).postDelayed({ + if (isDefaultDialer()) { + Log.d("MainActivity", "Default dialer successfully set") + registerManagedPhoneAccount() + notifyDefaultDialerSet() + } else { + Log.d("MainActivity", "Default dialer not set") + } + }, 500) + } + } + + // Register a managed PhoneAccount with the TelecomManager. + private fun registerManagedPhoneAccount() { + val telecomManager = getSystemService(TELECOM_SERVICE) as TelecomManager + val phoneAccountHandle = PhoneAccountHandle( + ComponentName(this, MyConnectionService::class.java), + "MyPhoneAccountID" + ) + val phoneAccount = PhoneAccount.builder(phoneAccountHandle, "Icing Dialer") + .setCapabilities(PhoneAccount.CAPABILITY_CALL_PROVIDER) + .setShortDescription("Icing Custom Dialer") + .build() + telecomManager.registerPhoneAccount(phoneAccount) + } + + // Notify Flutter that we are now the default dialer. + private fun notifyDefaultDialerSet() { + methodChannel.invokeMethod("onDefaultDialerSet", null) + } + + // Notify Flutter of an incoming call. + private fun notifyIncomingCall(phoneNumber: String) { + methodChannel.invokeMethod("onIncomingCall", phoneNumber) + } +} diff --git a/dialer/android/app/src/main/kotlin/com/example/dialer/MyConnection.kt b/dialer/android/app/src/main/kotlin/com/example/dialer/MyConnection.kt new file mode 100644 index 0000000..731e5b6 --- /dev/null +++ b/dialer/android/app/src/main/kotlin/com/example/dialer/MyConnection.kt @@ -0,0 +1,30 @@ +// File: android/app/src/main/kotlin/com/example/dialer/MyConnection.kt +package com.example.dialer + +import android.telecom.Connection +import android.telecom.DisconnectCause +import android.util.Log + +class MyConnection : Connection() { + + override fun onAnswer(videoState: Int) { + super.onAnswer(videoState) + Log.d("MyConnection", "onAnswer called") + setActive() + // (You can later add notifications back to Flutter here if needed.) + } + + override fun onReject() { + super.onReject() + Log.d("MyConnection", "onReject called") + setDisconnected(DisconnectCause(DisconnectCause.REJECTED)) + destroy() + } + + override fun onDisconnect() { + super.onDisconnect() + Log.d("MyConnection", "onDisconnect called") + setDisconnected(DisconnectCause(DisconnectCause.LOCAL)) + destroy() + } +} diff --git a/dialer/android/app/src/main/kotlin/com/example/dialer/MyConnectionService.kt b/dialer/android/app/src/main/kotlin/com/example/dialer/MyConnectionService.kt new file mode 100644 index 0000000..68e0805 --- /dev/null +++ b/dialer/android/app/src/main/kotlin/com/example/dialer/MyConnectionService.kt @@ -0,0 +1,44 @@ +// File: android/app/src/main/kotlin/com/example/dialer/MyConnectionService.kt +package com.example.dialer + +import android.content.Intent +import android.net.Uri +import android.telecom.Connection +import android.telecom.ConnectionRequest +import android.telecom.ConnectionService +import android.telecom.PhoneAccountHandle +import android.util.Log + +class MyConnectionService : ConnectionService() { + + override fun onCreateIncomingConnection( + connectionManagerPhoneAccount: PhoneAccountHandle, + request: ConnectionRequest + ): Connection? { + Log.d("MyConnectionService", "onCreateIncomingConnection") + val myConnection = MyConnection() + + // Try to extract the incoming phone number from the request. + val phoneUri: Uri? = request.address + val phoneNumber = phoneUri?.schemeSpecificPart ?: "Unknown" + + // Broadcast an intent so MainActivity can notify Flutter. + val intent = Intent("com.example.dialer.INCOMING_CALL") + intent.putExtra("phoneNumber", phoneNumber) + applicationContext.sendBroadcast(intent) + + // Mark the connection as ringing. + myConnection.setRinging() + return myConnection + } + + override fun onCreateOutgoingConnection( + connectionManagerPhoneAccount: PhoneAccountHandle, + request: ConnectionRequest + ): Connection? { + Log.d("MyConnectionService", "onCreateOutgoingConnection") + val myConnection = MyConnection() + myConnection.setDialing() + return myConnection + } +} diff --git a/dialer/android/build.gradle b/dialer/android/build.gradle index 457de89..8bc21dc 100644 --- a/dialer/android/build.gradle +++ b/dialer/android/build.gradle @@ -1,3 +1,21 @@ +// android/build.gradle (Project-level Gradle file) + +buildscript { + ext.kotlin_version = "1.8.10" + + repositories { + google() + mavenCentral() + } + + dependencies { + // Here is where the 'classpath' declarations go + classpath 'com.android.tools.build:gradle:8.1.2' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +// Then your allprojects block can be minimal in newer Gradle versions: allprojects { repositories { google() @@ -5,15 +23,18 @@ allprojects { } } +// If you want to customize build directory paths: rootProject.buildDir = "../build" subprojects { project.buildDir = "${rootProject.buildDir}/${project.name}" } + +// This is optional, depends on your workflow: subprojects { project.evaluationDependsOn(":app") } +// A custom clean task: tasks.register("clean", Delete) { delete rootProject.buildDir } - diff --git a/dialer/android/gradle.properties b/dialer/android/gradle.properties index 8371d42..d009fe7 100644 --- a/dialer/android/gradle.properties +++ b/dialer/android/gradle.properties @@ -2,3 +2,4 @@ org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryErro android.useAndroidX=true android.enableJetifier=true dev.steenbakker.mobile_scanner.useUnbundled=true +org.gradle.java.home=/usr/lib/jvm/java-17-openjdk-17.0.13.0.11-3.fc41.x86_64 diff --git a/dialer/lib/features/home/home_page.dart b/dialer/lib/features/home/home_page.dart index dbfc84f..629c448 100644 --- a/dialer/lib/features/home/home_page.dart +++ b/dialer/lib/features/home/home_page.dart @@ -1,24 +1,41 @@ import 'package:flutter/material.dart'; +import 'package:flutter_contacts/flutter_contacts.dart'; +import 'package:dialer/features/settings/settings.dart'; import 'package:dialer/features/contacts/contact_page.dart'; import 'package:dialer/features/favorites/favorites_page.dart'; import 'package:dialer/features/history/history_page.dart'; import 'package:dialer/features/composition/composition.dart'; -import 'package:flutter_contacts/flutter_contacts.dart'; -import 'package:dialer/features/settings/settings.dart'; import '../../widgets/contact_service.dart'; +/// This MyHomePage now expects two callbacks. +/// - onMakeCall: used to start calls +/// - onRequestDialer: used to become default dialer +class MyHomePage extends StatefulWidget { + final Function(String) onMakeCall; + final Function onRequestDialer; + + const MyHomePage({ + Key? key, + required this.onMakeCall, + required this.onRequestDialer, + }) : super(key: key); + + @override + _MyHomePageState createState() => _MyHomePageState(); +} + class _MyHomePageState extends State with SingleTickerProviderStateMixin { late TabController _tabController; List _allContacts = []; List _contactSuggestions = []; - final ContactService _contactService = ContactService(); + final ContactService _contactService = ContactService(); @override void initState() { super.initState(); - // Set the TabController length to 3 + // We have 3 tabs: Favorites, History, Contacts _tabController = TabController(length: 3, vsync: this, initialIndex: 1); _tabController.addListener(_handleTabIndex); _fetchContacts(); @@ -30,19 +47,14 @@ class _MyHomePageState extends State } void _onSearchChanged(String query) { - print("Search query: $query"); - - setState(() { - if (query.isEmpty) { - _contactSuggestions = List.from(_allContacts); - } else { - _contactSuggestions = _allContacts.where((contact) { - return contact.displayName - .toLowerCase() - .contains(query.toLowerCase()); - }).toList(); - } - }); + if (query.isEmpty) { + _contactSuggestions = List.from(_allContacts); + } else { + _contactSuggestions = _allContacts.where((contact) { + return contact.displayName.toLowerCase().contains(query.toLowerCase()); + }).toList(); + } + setState(() {}); } @override @@ -81,17 +93,16 @@ class _MyHomePageState extends State 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), + bottom: BorderSide(color: Colors.grey.shade800, width: 2), ), ), + // Using Flutter 3.10+ SearchAnchor / SearchBar API: child: SearchAnchor( builder: (BuildContext context, SearchController controller) { return SearchBar( controller: controller, - padding: - MaterialStateProperty.all( + padding: MaterialStateProperty.all( const EdgeInsets.only( top: 6.0, bottom: 6.0, @@ -104,7 +115,8 @@ class _MyHomePageState extends State _onSearchChanged(''); }, backgroundColor: MaterialStateProperty.all( - const Color.fromARGB(255, 30, 30, 30)), + const Color.fromARGB(255, 30, 30, 30), + ), hintText: 'Search contacts', hintStyle: MaterialStateProperty.all( const TextStyle(color: Colors.grey, fontSize: 16.0), @@ -114,8 +126,7 @@ class _MyHomePageState extends State color: Colors.grey, size: 24.0, ), - shape: - MaterialStateProperty.all( + shape: MaterialStateProperty.all( RoundedRectangleBorder( borderRadius: BorderRadius.circular(12.0), ), @@ -130,9 +141,12 @@ class _MyHomePageState extends State return _contactSuggestions.map((contact) { return ListTile( key: ValueKey(contact.id), - title: Text(contact.displayName, - style: const TextStyle(color: Colors.white)), + title: Text( + contact.displayName, + style: const TextStyle(color: Colors.white), + ), onTap: () { + // For example, you might call widget.onMakeCall(contact.phoneNumber) controller.closeView(contact.displayName); }, ); @@ -149,14 +163,20 @@ class _MyHomePageState extends State value: 'settings', child: Text('Settings'), ), + const PopupMenuItem( + value: 'becomeDialer', + child: Text('Become Default Dialer'), + ), ], onSelected: (String value) { if (value == 'settings') { Navigator.push( context, - MaterialPageRoute( - builder: (context) => const SettingsPage()), + MaterialPageRoute(builder: (context) => const SettingsPage()), ); + } else if (value == 'becomeDialer') { + // Use the callback passed in from main.dart: + widget.onRequestDialer(); } }, ), @@ -175,11 +195,14 @@ class _MyHomePageState extends State ContactPage(), ], ), + // Floating action button for manual composition/dialpad Positioned( right: 20, bottom: 20, child: FloatingActionButton( onPressed: () { + // Here you could do widget.onMakeCall('123456') or + // push to CompositionPage, etc. Navigator.push( context, MaterialPageRoute( @@ -205,17 +228,20 @@ class _MyHomePageState extends State controller: _tabController, tabs: [ Tab( - icon: Icon(_tabController.index == 0 - ? Icons.star - : Icons.star_border)), + icon: Icon(_tabController.index == 0 + ? Icons.star + : Icons.star_border), + ), Tab( - icon: Icon(_tabController.index == 1 - ? Icons.access_time_filled - : Icons.access_time_outlined)), + icon: Icon(_tabController.index == 1 + ? Icons.access_time_filled + : Icons.access_time_outlined), + ), Tab( - icon: Icon(_tabController.index == 2 - ? Icons.contacts - : Icons.contacts_outlined)), + icon: Icon(_tabController.index == 2 + ? Icons.contacts + : Icons.contacts_outlined), + ), ], labelColor: Colors.white, unselectedLabelColor: const Color.fromARGB(255, 158, 158, 158), @@ -226,10 +252,3 @@ class _MyHomePageState extends State ); } } - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key}); - - @override - _MyHomePageState createState() => _MyHomePageState(); -} diff --git a/dialer/lib/main.dart b/dialer/lib/main.dart index a9ca957..e3d6aa8 100644 --- a/dialer/lib/main.dart +++ b/dialer/lib/main.dart @@ -1,23 +1,138 @@ -import 'package:dialer/features/home/home_page.dart'; +// File: lib/main.dart import 'package:flutter/material.dart'; -import 'package:dialer/features/contacts/contact_state.dart'; +import 'package:flutter/services.dart'; void main() { - runApp(const MyApp()); + runApp(const IcingDialerApp()); } -class MyApp extends StatelessWidget { - const MyApp({super.key}); +class IcingDialerApp extends StatefulWidget { + const IcingDialerApp({Key? key}) : super(key: key); + + @override + State createState() => _IcingDialerAppState(); +} + +class _IcingDialerAppState extends State { + // This channel must match the one in MainActivity.kt. + static const platform = MethodChannel('com.example.dialer/call_events'); + bool isDefaultDialer = false; + + @override + void initState() { + super.initState(); + // Listen for method calls from the Android side. + platform.setMethodCallHandler(handleMethodCalls); + } + + Future handleMethodCalls(MethodCall call) async { + switch (call.method) { + case 'onDefaultDialerSet': + // Update our UI to show that we are now the default dialer. + setState(() { + isDefaultDialer = true; + }); + break; + case 'onIncomingCall': + final phoneNumber = call.arguments as String? ?? "Unknown"; + _showIncomingCallDialog(phoneNumber); + break; + default: + print("Unhandled method: ${call.method}"); + } + } + + // Show a dialog when an incoming call is intercepted. + void _showIncomingCallDialog(String phoneNumber) { + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => AlertDialog( + title: const Text('Incoming Call'), + content: Text('Call from: $phoneNumber'), + actions: [ + TextButton( + onPressed: () { + // (Here you could add code to reject the call.) + Navigator.of(context).pop(); + }, + child: const Text('Reject'), + ), + ElevatedButton( + onPressed: () { + // (Here you could add code to accept the call.) + Navigator.of(context).pop(); + }, + child: const Text('Accept'), + ), + ], + ), + ); + } + + // Invoke the native method to request to be the default dialer. + Future requestDefaultDialer() async { + try { + await platform.invokeMethod('requestDefaultDialer'); + } catch (e) { + print('Error requesting default dialer: $e'); + } + } @override Widget build(BuildContext context) { - return ContactState( - child: MaterialApp( - theme: ThemeData( - brightness: Brightness.dark - ), - home: SafeArea(child: MyHomePage()), - ) + return MaterialApp( + title: 'Icing Dialer', + theme: ThemeData.dark(), + home: isDefaultDialer + ? const CallScreen() + : DefaultDialerSetupScreen(onRequestDefaultDialer: requestDefaultDialer), + ); + } +} + +/// Shown when the app is not yet the default dialer. +class DefaultDialerSetupScreen extends StatelessWidget { + final VoidCallback onRequestDefaultDialer; + const DefaultDialerSetupScreen({Key? key, required this.onRequestDefaultDialer}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Set as Default Dialer')), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + 'This app is not set as your default dialer.', + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: onRequestDefaultDialer, + child: const Text('Set as Default Dialer'), + ), + ], + ), + ), + ); + } +} + +/// Shown when the app is the default dialer. +class CallScreen extends StatelessWidget { + const CallScreen({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + // A simple UI indicating that the app is waiting for incoming calls. + return Scaffold( + appBar: AppBar(title: const Text('Icing Dialer')), + body: const Center( + child: Text('Waiting for incoming calls...'), + ), ); } }