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/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..95b4a91 100644 --- a/dialer/android/app/src/main/AndroidManifest.xml +++ b/dialer/android/app/src/main/AndroidManifest.xml @@ -1,13 +1,17 @@ - - + + + + - - - + + + + + - + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme" /> - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + - + \ No newline at end of file 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 a2397d6..2fe4eb9 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 @@ -1,54 +1,91 @@ package com.icing.dialer.activities +import android.Manifest +import android.app.role.RoleManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager import android.database.Cursor +import android.net.Uri +import android.os.Build import android.os.Bundle import android.provider.CallLog -import io.flutter.embedding.android.FlutterActivity -import io.flutter.embedding.engine.FlutterEngine -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel +import android.telecom.TelecomManager +import android.util.Log +import androidx.core.content.ContextCompat import com.icing.dialer.KeystoreHelper import com.icing.dialer.services.CallService +import com.icing.dialer.services.MyInCallService +import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel -class MainActivity: FlutterActivity() { - // Existing channel for keystore operations. +class MainActivity : FlutterActivity() { private val KEYSTORE_CHANNEL = "com.example.keystore" - // New channel for call log access. private val CALLLOG_CHANNEL = "com.example.calllog" - private val CALL_CHANNEL = "call_service" + private val TAG = "MainActivity" + private val REQUEST_CODE_SET_DEFAULT_DIALER = 1001 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + Log.d(TAG, "onCreate started") + Log.d(TAG, "Waiting for Flutter to signal permissions") + } override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) + Log.d(TAG, "Configuring Flutter engine") - // Call service channel - MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALL_CHANNEL).setMethodCallHandler { call, result -> - 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/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/lib/features/call/call_page.dart b/dialer/lib/features/call/call_page.dart new file mode 100644 index 0000000..1416a98 --- /dev/null +++ b/dialer/lib/features/call/call_page.dart @@ -0,0 +1,324 @@ +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, + required this.phoneNumber, + this.thumbnail, + }); + + @override + _CallPageState createState() => _CallPageState(); +} + +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 = ""; + + 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() 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; + 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, // Uses thumbnail if provided + 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( + 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( + 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: 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( + 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), + 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, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } +} \ 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..b3e33bd 100644 --- a/dialer/lib/features/composition/composition.dart +++ b/dialer/lib/features/composition/composition.dart @@ -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 9f641ad..198f614 100644 --- a/dialer/lib/features/contacts/widgets/contact_modal.dart +++ b/dialer/lib/features/contacts/widgets/contact_modal.dart @@ -5,7 +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 '../../../services/call_service.dart'; // Import CallService +import '../../../services/call_service.dart'; class ContactModal extends StatefulWidget { final Contact contact; @@ -29,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() { @@ -126,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 @@ -134,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')), ); } } @@ -162,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, @@ -216,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', @@ -238,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, @@ -262,7 +267,8 @@ class _ContactModalState extends State { ), onTap: () async { if (widget.contact.phones.isNotEmpty) { - await _callService.makeGsmCall(phoneNumber); + await _callService.makeGsmCall(context, + phoneNumber: phoneNumber); } }, ), @@ -307,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 1108a2e..2ce20b8 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(context, phoneNumber: 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), ), ], ), 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(); 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 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