Compare commits

..

10 Commits

Author SHA1 Message Date
ec1779bb15 callNotifications and various fix related to calls (#49)
Some checks failed
/ mirror (push) Successful in 4s
/ build-stealth (push) Successful in 10m35s
/ build (push) Failing after 8m51s
Reviewed-on: #49
Co-authored-by: florian <florian.griffon@epitech.eu>
Co-committed-by: florian <florian.griffon@epitech.eu>
2025-04-17 12:26:32 +00:00
22941f78d0 Argiliser exemples (#53)
All checks were successful
/ mirror (push) Successful in 4s
Reviewed-on: #53
2025-04-15 12:54:41 +00:00
b9dd156eca fix: search bar is non case sensitive and don't have delay (contact page) (#50)
All checks were successful
/ mirror (push) Successful in 8s
/ build (push) Successful in 14m22s
/ build-stealth (push) Successful in 6m28s
Co-authored-by: stcb <21@stcb.cc>
Reviewed-on: #50
Co-authored-by: Florian Griffon <florian.griffon@epitech.eu>
Co-committed-by: Florian Griffon <florian.griffon@epitech.eu>
2025-04-09 10:43:31 +00:00
58ccd3a24c feat: improved composition page (#51)
All checks were successful
/ mirror (push) Successful in 8s
/ build (push) Successful in 14m29s
/ build-stealth (push) Successful in 14m42s
Add '+' on Long Press of 0
Add grey '+' below '0'
Green Call Button at the bottom
Add a contact button below contact list
Delete last character and not whole line
Delete whole line on long press

Reviewed-on: #51
Co-authored-by: Florian Griffon <florian.griffon@epitech.eu>
Co-committed-by: Florian Griffon <florian.griffon@epitech.eu>
2025-04-05 08:36:52 +00:00
608218175a fix: improve history page performance and state management (#47)
Some checks failed
/ build-stealth (push) Waiting to run
/ mirror (push) Waiting to run
/ build (push) Has been cancelled
smooth switch to history page

Co-authored-by: AlexisDanlos <91090088+AlexisDanlos@users.noreply.github.com>
Co-authored-by: stcb <21@stcb.cc>
Reviewed-on: #47
Co-authored-by: alexis <alexis.danlos@epitech.eu>
Co-committed-by: alexis <alexis.danlos@epitech.eu>
2025-04-05 08:27:20 +00:00
37349fdc13 feat: app is now default dialer app | callpage UI | incoming call UI | receive and call from our app (#48)
All checks were successful
/ mirror (push) Successful in 5s
/ build (push) Successful in 8m54s
/ build-stealth (push) Successful in 8m56s
Reviewed-on: #48
Co-authored-by: Florian Griffon <florian.griffon@epitech.eu>
Co-committed-by: Florian Griffon <florian.griffon@epitech.eu>
2025-03-24 11:11:11 +00:00
fb5f155430 Add CallPage for initiating calls with contact details (#37)
All checks were successful
/ mirror (push) Successful in 5s
/ build-stealth (push) Successful in 7m41s
/ build (push) Successful in 10m18s
Demo call page avec les features de base:
- Haut parleur
- Couper/activer micro
- keypad
- raccrocher
- Display Icing state (toucher pour switch l'état)

S'active en faisant un appui long sur le bouton d'appel depuis les détails du contact.
Compatible avec l'obfuscation des contacts.

Co-authored-by: AlexisDanlos <91090088+AlexisDanlos@users.noreply.github.com>
Reviewed-on: #37
Co-authored-by: alexis <alexis.danlos@epitech.eu>
Co-committed-by: alexis <alexis.danlos@epitech.eu>
2025-03-07 22:40:16 +00:00
2ea2c679b2 fix: search bar upgrade (#42)
All checks were successful
/ mirror (push) Successful in 4s
/ build-stealth (push) Successful in 8m28s
/ build (push) Successful in 8m30s
Reviewed-on: #42
Co-authored-by: Florian Griffon <florian.griffon@epitech.eu>
Co-committed-by: Florian Griffon <florian.griffon@epitech.eu>
2025-03-04 13:10:42 +00:00
2d3519592a cicd-stealth (#40)
Some checks failed
/ mirror (push) Successful in 4s
/ build (push) Has been cancelled
/ build-stealth (push) Has been cancelled
Reviewed-on: #40
Co-authored-by: ange <ange@yw5n.com>
Co-committed-by: ange <ange@yw5n.com>
2025-03-04 13:06:08 +00:00
3a7c9718dd fix: call correctly in history page (#41)
Some checks failed
/ build (push) Failing after 5s
/ mirror (push) Successful in 4s
Reviewed-on: #41
Co-authored-by: Florian Griffon <florian.griffon@epitech.eu>
Co-committed-by: Florian Griffon <florian.griffon@epitech.eu>
2025-03-04 13:05:42 +00:00
9 changed files with 611 additions and 263 deletions

View File

@ -1,17 +1,19 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.icing.dialer">
<uses-feature android:name="android.hardware.telephony" android:required="true" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
<uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.READ_BLOCKED_NUMBERS" />
<uses-permission android:name="android.permission.WRITE_BLOCKED_NUMBERS" />
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.READ_CALL_LOG"/>
<uses-permission android:name="android.permission.READ_PHONE_STATE"/>
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<uses-permission android:name="android.permission.PROCESS_OUTGOING_CALLS" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<application
android:label="Icing Dialer"
@ -27,12 +29,17 @@
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<!-- Dialer intent filters (required for default dialer eligibility) -->
<intent-filter>
@ -75,17 +82,22 @@
<action android:name="android.telecom.ConnectionService" />
</intent-filter>
</service> -->
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT" />
<data android:mimeType="text/plain" />
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
</manifest>

View File

@ -6,7 +6,6 @@ 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
@ -26,23 +25,76 @@ class MainActivity : FlutterActivity() {
private val CALL_CHANNEL = "call_service"
private val TAG = "MainActivity"
private val REQUEST_CODE_SET_DEFAULT_DIALER = 1001
private val REQUEST_CODE_CALL_LOG_PERMISSION = 1002
private var pendingIncomingCall: Pair<String?, Boolean>? = null
private var wasPhoneLocked: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d(TAG, "onCreate started")
Log.d(TAG, "Waiting for Flutter to signal permissions")
wasPhoneLocked = intent.getBooleanExtra("wasPhoneLocked", false)
Log.d(TAG, "Was phone locked at start: $wasPhoneLocked")
updateLockScreenFlags(intent)
handleIncomingCallIntent(intent)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
wasPhoneLocked = intent.getBooleanExtra("wasPhoneLocked", false)
Log.d(TAG, "onNewIntent, wasPhoneLocked: $wasPhoneLocked")
updateLockScreenFlags(intent)
handleIncomingCallIntent(intent)
}
private fun updateLockScreenFlags(intent: Intent?) {
val isIncomingCall = intent?.getBooleanExtra("isIncomingCall", false) ?: false
if (isIncomingCall && wasPhoneLocked) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(true)
setTurnScreenOn(true)
} else {
@Suppress("DEPRECATION")
window.addFlags(
android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
}
Log.d(TAG, "Enabled showWhenLocked and turnScreenOn for incoming call")
} else {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setShowWhenLocked(false)
setTurnScreenOn(false)
} else {
@Suppress("DEPRECATION")
window.clearFlags(
android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
)
}
Log.d(TAG, "Disabled showWhenLocked and turnScreenOn for normal usage")
}
}
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
Log.d(TAG, "Configuring Flutter engine")
MyInCallService.channel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALL_CHANNEL)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALL_CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
"permissionsGranted" -> {
Log.d(TAG, "Received permissionsGranted from Flutter")
pendingIncomingCall?.let { (phoneNumber, showScreen) ->
if (showScreen) {
MyInCallService.channel?.invokeMethod("incomingCallFromNotification", mapOf(
"phoneNumber" to phoneNumber,
"wasPhoneLocked" to wasPhoneLocked
))
pendingIncomingCall = null
}
}
checkAndRequestDefaultDialer()
result.success(true)
}
@ -60,37 +112,66 @@ class MainActivity : FlutterActivity() {
}
}
"hangUpCall" -> {
val success = CallService.hangUpCall(this)
val success = MyInCallService.currentCall?.let {
it.disconnect()
Log.d(TAG, "Call disconnected")
MyInCallService.channel?.invokeMethod("callEnded", mapOf(
"callId" to it.details.handle.toString(),
"wasPhoneLocked" to wasPhoneLocked
))
true
} ?: false
if (success) {
result.success(mapOf("status" to "ended"))
if (wasPhoneLocked) {
Log.d(TAG, "Finishing and removing task after hangup, phone was locked")
finishAndRemoveTask()
}
} else {
result.error("HANGUP_FAILED", "Failed to end call", null)
Log.w(TAG, "No active call to hang up")
result.error("HANGUP_FAILED", "No active call to hang up", null)
}
}
"answerCall" -> {
val success = MyInCallService.currentCall?.let {
it.answer(0) // 0 for default video state (audio-only)
it.answer(0)
Log.d(TAG, "Answered call")
true
} ?: false
if (success) {
result.success(mapOf("status" to "answered"))
} else {
Log.w(TAG, "No active call to answer")
result.error("ANSWER_FAILED", "No active call to answer", null)
}
}
"callEndedFromFlutter" -> {
Log.d(TAG, "Call ended from Flutter, wasPhoneLocked: $wasPhoneLocked")
if (wasPhoneLocked) {
finishAndRemoveTask()
Log.d(TAG, "Finishing and removing task after call ended, phone was locked")
}
result.success(true)
}
else -> result.notImplemented()
}
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, KEYSTORE_CHANNEL)
.setMethodCallHandler { call, result -> KeystoreHelper(call, result).handleMethodCall() }
.setMethodCallHandler { call, result ->
KeystoreHelper(call, result).handleMethodCall()
}
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALLLOG_CHANNEL)
.setMethodCallHandler { call, result ->
if (call.method == "getCallLogs") {
val callLogs = getCallLogs()
result.success(callLogs)
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALL_LOG) == PackageManager.PERMISSION_GRANTED) {
val callLogs = getCallLogs()
result.success(callLogs)
} else {
requestPermissions(arrayOf(Manifest.permission.READ_CALL_LOG), REQUEST_CODE_CALL_LOG_PERMISSION)
result.error("PERMISSION_DENIED", "Call log permission not granted", null)
}
} else {
result.notImplemented()
}
@ -109,8 +190,6 @@ class MainActivity : FlutterActivity() {
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)
@ -148,6 +227,18 @@ class MainActivity : FlutterActivity() {
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQUEST_CODE_CALL_LOG_PERMISSION) {
if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
Log.d(TAG, "Call log permission granted")
MyInCallService.channel?.invokeMethod("callLogPermissionGranted", null)
} else {
Log.w(TAG, "Call log permission denied")
}
}
}
private fun getCallLogs(): List<Map<String, Any?>> {
val logsList = mutableListOf<Map<String, Any?>>()
val cursor: Cursor? = contentResolver.query(
@ -171,4 +262,25 @@ class MainActivity : FlutterActivity() {
}
return logsList
}
private fun handleIncomingCallIntent(intent: Intent?) {
intent?.let {
if (it.getBooleanExtra("isIncomingCall", false)) {
val phoneNumber = it.getStringExtra("phoneNumber")
val showScreen = it.getBooleanExtra("showIncomingCallScreen", false)
Log.d(TAG, "Received incoming call intent for $phoneNumber, showScreen=$showScreen, wasPhoneLocked=$wasPhoneLocked")
if (showScreen) {
if (MyInCallService.channel != null) {
MyInCallService.channel?.invokeMethod("incomingCallFromNotification", mapOf(
"phoneNumber" to phoneNumber,
"wasPhoneLocked" to wasPhoneLocked
))
} else {
pendingIncomingCall = Pair(phoneNumber, true)
Log.d(TAG, "Flutter channel not ready, storing pending call: $phoneNumber")
}
}
}
}
}
}

View File

@ -1,8 +1,17 @@
package com.icing.dialer.services
import android.app.KeyguardManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.telecom.Call
import android.telecom.InCallService
import android.util.Log
import androidx.core.app.NotificationCompat
import com.icing.dialer.activities.MainActivity
import io.flutter.plugin.common.MethodChannel
class MyInCallService : InCallService() {
@ -10,6 +19,9 @@ class MyInCallService : InCallService() {
var channel: MethodChannel? = null
var currentCall: Call? = null
private const val TAG = "MyInCallService"
private const val NOTIFICATION_CHANNEL_ID = "incoming_call_channel"
private const val NOTIFICATION_ID = 1
var wasPhoneLocked: Boolean = false
}
private val callCallback = object : Call.Callback() {
@ -26,12 +38,22 @@ class MyInCallService : InCallService() {
Log.d(TAG, "State changed: $stateStr for call ${call.details.handle}")
channel?.invokeMethod("callStateChanged", mapOf(
"callId" to call.details.handle.toString(),
"state" to stateStr
"state" to stateStr,
"wasPhoneLocked" to wasPhoneLocked
))
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()))
if (state == Call.STATE_RINGING) {
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
wasPhoneLocked = keyguardManager.isKeyguardLocked
Log.d(TAG, "Phone locked at ringing: $wasPhoneLocked")
showIncomingCallScreen(call.details.handle.toString().replace("tel:", ""))
} else if (state == Call.STATE_DISCONNECTED || state == Call.STATE_DISCONNECTING) {
Log.d(TAG, "Call ended: ${call.details.handle}, wasPhoneLocked: $wasPhoneLocked")
channel?.invokeMethod("callEnded", mapOf(
"callId" to call.details.handle.toString(),
"wasPhoneLocked" to wasPhoneLocked
))
currentCall = null
cancelNotification()
}
}
}
@ -43,22 +65,32 @@ class MyInCallService : InCallService() {
Call.STATE_DIALING -> "dialing"
Call.STATE_ACTIVE -> "active"
Call.STATE_RINGING -> "ringing"
else -> "dialing" // Default for outgoing
else -> "dialing"
}
Log.d(TAG, "Call added: ${call.details.handle}, state: $stateStr")
channel?.invokeMethod("callAdded", mapOf(
"callId" to call.details.handle.toString(),
"state" to stateStr
))
if (stateStr == "ringing") {
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
wasPhoneLocked = keyguardManager.isKeyguardLocked
Log.d(TAG, "Phone locked at call added: $wasPhoneLocked")
showIncomingCallScreen(call.details.handle.toString().replace("tel:", ""))
}
call.registerCallback(callCallback)
}
override fun onCallRemoved(call: Call) {
super.onCallRemoved(call)
Log.d(TAG, "Call removed: ${call.details.handle}")
Log.d(TAG, "Call removed: ${call.details.handle}, wasPhoneLocked: $wasPhoneLocked")
call.unregisterCallback(callCallback)
channel?.invokeMethod("callRemoved", mapOf("callId" to call.details.handle.toString()))
channel?.invokeMethod("callRemoved", mapOf(
"callId" to call.details.handle.toString(),
"wasPhoneLocked" to wasPhoneLocked
))
currentCall = null
cancelNotification()
}
override fun onCallAudioStateChanged(state: android.telecom.CallAudioState) {
@ -66,4 +98,59 @@ class MyInCallService : InCallService() {
Log.d(TAG, "Audio state changed: route=${state.route}")
channel?.invokeMethod("audioStateChanged", mapOf("route" to state.route))
}
private fun showIncomingCallScreen(phoneNumber: String) {
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
putExtra("phoneNumber", phoneNumber)
putExtra("isIncomingCall", true)
putExtra("showIncomingCallScreen", true)
putExtra("wasPhoneLocked", wasPhoneLocked)
}
val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
if (keyguardManager.isKeyguardLocked) {
startActivity(intent)
Log.d(TAG, "Launched MainActivity directly for locked screen, phoneNumber: $phoneNumber")
} else {
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
NOTIFICATION_CHANNEL_ID,
"Incoming Calls",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Notifications for incoming calls"
enableVibration(true)
setShowBadge(true)
}
notificationManager.createNotificationChannel(channel)
}
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0)
)
val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_dialog_alert)
.setContentTitle("Incoming Call")
.setContentText("Call from $phoneNumber")
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setFullScreenIntent(pendingIntent, true)
.setAutoCancel(true)
.setOngoing(true)
.build()
startActivity(intent)
notificationManager.notify(NOTIFICATION_ID, notification)
Log.d(TAG, "Launched MainActivity with notification for unlocked screen, phoneNumber: $phoneNumber")
}
}
private fun cancelNotification() {
val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.cancel(NOTIFICATION_ID)
Log.d(TAG, "Notification canceled")
}
}

View File

@ -61,9 +61,16 @@ class _CallPageState extends State<CallPage> {
void _hangUp() async {
try {
await _callService.hangUpCall(context);
final result = await _callService.hangUpCall(context);
print('CallPage: Hang up result: $result');
if (result["status"] == "ended" && mounted && Navigator.canPop(context)) {
Navigator.pop(context);
}
} catch (e) {
print("Error hanging up: $e");
print("CallPage: Error hanging up: $e");
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Error hanging up: $e")),
);
}
}
@ -86,9 +93,10 @@ class _CallPageState extends State<CallPage> {
children: [
SizedBox(height: 35),
ObfuscatedAvatar(
imageBytes: widget.thumbnail, // Uses thumbnail if provided
imageBytes: widget.thumbnail,
radius: avatarRadius,
backgroundColor: generateColorFromName(widget.displayName),
backgroundColor:
generateColorFromName(widget.displayName),
fallbackInitial: widget.displayName,
),
const SizedBox(height: 4),
@ -122,11 +130,13 @@ class _CallPageState extends State<CallPage> {
),
Text(
widget.phoneNumber,
style: TextStyle(fontSize: statusFontSize, color: Colors.white70),
style: TextStyle(
fontSize: statusFontSize, color: Colors.white70),
),
Text(
'Calling...',
style: TextStyle(fontSize: statusFontSize, color: Colors.white70),
style: TextStyle(
fontSize: statusFontSize, color: Colors.white70),
),
],
),
@ -157,7 +167,8 @@ class _CallPageState extends State<CallPage> {
IconButton(
padding: EdgeInsets.zero,
onPressed: _toggleKeypad,
icon: const Icon(Icons.close, color: Colors.white),
icon:
const Icon(Icons.close, color: Colors.white),
),
],
),
@ -193,7 +204,8 @@ class _CallPageState extends State<CallPage> {
child: Center(
child: Text(
label,
style: const TextStyle(fontSize: 32, color: Colors.white),
style: const TextStyle(
fontSize: 32, color: Colors.white),
),
),
),
@ -225,7 +237,8 @@ class _CallPageState extends State<CallPage> {
),
Text(
isMuted ? 'Unmute' : 'Mute',
style: const TextStyle(color: Colors.white, fontSize: 14),
style: const TextStyle(
color: Colors.white, fontSize: 14),
),
],
),
@ -234,11 +247,13 @@ class _CallPageState extends State<CallPage> {
children: [
IconButton(
onPressed: _toggleKeypad,
icon: const Icon(Icons.dialpad, color: Colors.white, size: 32),
icon: const Icon(Icons.dialpad,
color: Colors.white, size: 32),
),
const Text(
'Keypad',
style: TextStyle(color: Colors.white, fontSize: 14),
style: TextStyle(
color: Colors.white, fontSize: 14),
),
],
),
@ -248,14 +263,19 @@ class _CallPageState extends State<CallPage> {
IconButton(
onPressed: _toggleSpeaker,
icon: Icon(
isSpeakerOn ? Icons.volume_up : Icons.volume_off,
color: isSpeakerOn ? Colors.amber : Colors.white,
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),
style: TextStyle(
color: Colors.white, fontSize: 14),
),
],
),
@ -270,10 +290,12 @@ class _CallPageState extends State<CallPage> {
children: [
IconButton(
onPressed: () {},
icon: const Icon(Icons.person_add, color: Colors.white, size: 32),
icon: const Icon(Icons.person_add,
color: Colors.white, size: 32),
),
const Text('Add Contact',
style: TextStyle(color: Colors.white, fontSize: 14)),
style: TextStyle(
color: Colors.white, fontSize: 14)),
],
),
Column(
@ -281,10 +303,12 @@ class _CallPageState extends State<CallPage> {
children: [
IconButton(
onPressed: () {},
icon: const Icon(Icons.sim_card, color: Colors.white, size: 32),
icon: const Icon(Icons.sim_card,
color: Colors.white, size: 32),
),
const Text('Change SIM',
style: TextStyle(color: Colors.white, fontSize: 14)),
style: TextStyle(
color: Colors.white, fontSize: 14)),
],
),
],
@ -321,4 +345,4 @@ class _CallPageState extends State<CallPage> {
),
);
}
}
}

View File

@ -2,9 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../services/contact_service.dart';
import '../../services/obfuscate_service.dart'; // Import ObfuscateService
import '../../services/call_service.dart'; // Import the CallService
import '../contacts/widgets/add_contact_button.dart';
import '../../services/obfuscate_service.dart';
import '../../services/call_service.dart';
class CompositionPage extends StatefulWidget {
const CompositionPage({super.key});
@ -18,11 +17,7 @@ class _CompositionPageState extends State<CompositionPage> {
List<Contact> _allContacts = [];
List<Contact> _filteredContacts = [];
final ContactService _contactService = ContactService();
// Instantiate the ObfuscateService
final ObfuscateService _obfuscateService = ObfuscateService();
// Instantiate the CallService
final CallService _callService = CallService();
@override
@ -40,8 +35,13 @@ class _CompositionPageState extends State<CompositionPage> {
void _filterContacts() {
setState(() {
_filteredContacts = _allContacts.where((contact) {
final phoneMatch = contact.phones.any((phone) =>
phone.number.replaceAll(RegExp(r'\D'), '').contains(dialedNumber));
bool phoneMatch = contact.phones.any((phone) {
final rawPhoneNumber = phone.number;
final strippedPhoneNumber = rawPhoneNumber.replaceAll(RegExp(r'\D'), '');
final strippedDialedNumber = dialedNumber.replaceAll(RegExp(r'\D'), '');
return rawPhoneNumber.contains(dialedNumber) ||
strippedPhoneNumber.contains(strippedDialedNumber);
});
final nameMatch = contact.displayName
.toLowerCase()
.contains(dialedNumber.toLowerCase());
@ -57,6 +57,13 @@ class _CompositionPageState extends State<CompositionPage> {
});
}
void _onPlusPress() {
setState(() {
dialedNumber += '+';
_filterContacts();
});
}
void _onDeletePress() {
setState(() {
if (dialedNumber.isNotEmpty) {
@ -73,7 +80,6 @@ class _CompositionPageState extends State<CompositionPage> {
});
}
// Function to call a contact's number using the CallService
void _makeCall(String phoneNumber) async {
try {
await _callService.makeGsmCall(context, phoneNumber: phoneNumber);
@ -82,10 +88,12 @@ class _CompositionPageState extends State<CompositionPage> {
});
} catch (e) {
debugPrint("Error making call: $e");
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to make call: $e')),
);
}
}
// Function to send an SMS to a contact's number
void _launchSms(String phoneNumber) async {
final uri = Uri(scheme: 'sms', path: phoneNumber);
if (await canLaunchUrl(uri)) {
@ -95,6 +103,20 @@ class _CompositionPageState extends State<CompositionPage> {
}
}
void _addContact() async {
if (await FlutterContacts.requestPermission()) {
final newContact = Contact()
..phones = [Phone(dialedNumber.isNotEmpty ? dialedNumber : '')];
final updatedContact = await FlutterContacts.openExternalInsert(newContact);
if (updatedContact != null) {
_fetchContacts();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Contact added successfully!')),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -103,7 +125,6 @@ class _CompositionPageState extends State<CompositionPage> {
children: [
Column(
children: [
// Top half: Display contacts matching dialed number
Expanded(
flex: 2,
child: Container(
@ -115,57 +136,51 @@ class _CompositionPageState extends State<CompositionPage> {
children: [
Expanded(
child: ListView(
children: _filteredContacts.isNotEmpty
? _filteredContacts.map((contact) {
final phoneNumber = contact.phones.isNotEmpty
? contact.phones.first.number
: 'No phone number';
return ListTile(
title: Text(
_obfuscateService.obfuscateData(contact.displayName),
style: const TextStyle(color: Colors.white),
children: [
..._filteredContacts.map((contact) {
final phoneNumber = contact.phones.isNotEmpty
? contact.phones.first.number
: 'No phone number';
return ListTile(
title: Text(
_obfuscateService.obfuscateData(contact.displayName),
style: const TextStyle(color: Colors.white),
),
subtitle: Text(
_obfuscateService.obfuscateData(phoneNumber),
style: const TextStyle(color: Colors.grey),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(Icons.phone, color: Colors.green[300], size: 20),
onPressed: () => _makeCall(phoneNumber),
),
IconButton(
icon: Icon(Icons.message, color: Colors.blue[300], size: 20),
onPressed: () => _launchSms(phoneNumber),
),
],
),
onTap: () {},
);
}).toList(),
ListTile(
title: const Text(
'Add a contact',
style: TextStyle(color: Colors.white),
),
subtitle: Text(
_obfuscateService.obfuscateData(phoneNumber),
style: const TextStyle(color: Colors.grey),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Call button (Now using CallService)
IconButton(
icon: Icon(Icons.phone,
color: Colors.green[300],
size: 20),
onPressed: () {
_makeCall(phoneNumber); // Make a call using CallService
},
),
// Message button
IconButton(
icon: Icon(Icons.message,
color: Colors.blue[300],
size: 20),
onPressed: () {
_launchSms(phoneNumber);
},
),
],
),
onTap: () {
// Handle contact selection if needed
},
);
}).toList()
: [],
trailing: Icon(Icons.add, color: Colors.grey[600]),
onTap: _addContact,
),
],
),
),
],
),
),
),
// Bottom half: Dialpad and Dialed number display with erase button
Expanded(
flex: 2,
child: Container(
@ -173,7 +188,6 @@ class _CompositionPageState extends State<CompositionPage> {
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
// Display dialed number with erase button
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@ -182,61 +196,57 @@ class _CompositionPageState extends State<CompositionPage> {
alignment: Alignment.center,
child: Text(
dialedNumber,
style: const TextStyle(
fontSize: 24, color: Colors.white),
style: const TextStyle(fontSize: 24, color: Colors.white),
overflow: TextOverflow.ellipsis,
),
),
),
IconButton(
onPressed: _onClearPress,
icon: const Icon(Icons.backspace,
color: Colors.white),
GestureDetector(
onTap: _onDeletePress,
onLongPress: _onClearPress,
child: const Padding(
padding: EdgeInsets.all(8.0),
child: Icon(Icons.backspace, color: Colors.white),
),
),
],
),
const SizedBox(height: 10),
// Dialpad
Expanded(
child: SingleChildScrollView(
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceEvenly,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildDialButton('1'),
_buildDialButton('2'),
_buildDialButton('3'),
_buildDialButton('1', Colors.white),
_buildDialButton('2', Colors.white),
_buildDialButton('3', Colors.white),
],
),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceEvenly,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildDialButton('4'),
_buildDialButton('5'),
_buildDialButton('6'),
_buildDialButton('4', Colors.white),
_buildDialButton('5', Colors.white),
_buildDialButton('6', Colors.white),
],
),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceEvenly,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildDialButton('7'),
_buildDialButton('8'),
_buildDialButton('9'),
_buildDialButton('7', Colors.white),
_buildDialButton('8', Colors.white),
_buildDialButton('9', Colors.white),
],
),
Row(
mainAxisAlignment:
MainAxisAlignment.spaceEvenly,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildDialButton('*'),
_buildDialButton('0'),
_buildDialButton('#'),
_buildDialButton('*', const Color.fromRGBO(117, 117, 117, 1)),
_buildDialButtonWithPlus('0'),
_buildDialButton('#', const Color.fromRGBO(117, 117, 117, 1)),
],
),
],
@ -249,26 +259,28 @@ class _CompositionPageState extends State<CompositionPage> {
),
],
),
// Add Contact Button
Positioned(
bottom: 20.0,
left: 0,
right: 0,
child: Center(
child: AddContactButton(),
child: ElevatedButton(
onPressed: dialedNumber.isNotEmpty ? () => _makeCall(dialedNumber) : null,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green[700],
shape: const CircleBorder(),
padding: const EdgeInsets.all(20),
),
child: const Icon(Icons.phone, color: Colors.white, size: 30),
),
),
),
// Top Row with Back Arrow
Positioned(
top: 40.0,
left: 16.0,
child: IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () {
Navigator.pop(context);
},
onPressed: () => Navigator.pop(context),
),
),
],
@ -276,7 +288,7 @@ class _CompositionPageState extends State<CompositionPage> {
);
}
Widget _buildDialButton(String number) {
Widget _buildDialButton(String number, Color textColor) {
return ElevatedButton(
onPressed: () => _onNumberPress(number),
style: ElevatedButton.styleFrom(
@ -286,11 +298,38 @@ class _CompositionPageState extends State<CompositionPage> {
),
child: Text(
number,
style: const TextStyle(
fontSize: 24,
color: Colors.white,
),
style: TextStyle(fontSize: 24, color: textColor),
),
);
}
}
Widget _buildDialButtonWithPlus(String number) {
return Stack(
alignment: Alignment.center,
children: [
GestureDetector(
onLongPress: _onPlusPress,
child: ElevatedButton(
onPressed: () => _onNumberPress(number),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.black,
shape: const CircleBorder(),
padding: const EdgeInsets.all(16),
),
child: Text(
number,
style: const TextStyle(fontSize: 24, color: Colors.white),
),
),
),
Positioned(
bottom: 8,
child: Text(
'+',
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
),
],
);
}
}

View File

@ -37,7 +37,7 @@ class HistoryPage extends StatefulWidget {
}
class _HistoryPageState extends State<HistoryPage>
with SingleTickerProviderStateMixin {
with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
List<History> histories = [];
bool loading = true;
int? _expandedIndex;
@ -47,10 +47,13 @@ class _HistoryPageState extends State<HistoryPage>
// Create a MethodChannel instance.
static const MethodChannel _channel = MethodChannel('com.example.calllog');
@override
bool get wantKeepAlive => true; // Preserve state when switching pages
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (loading) {
if (loading && histories.isEmpty) {
_buildHistories();
}
}
@ -149,9 +152,9 @@ class _HistoryPageState extends State<HistoryPage>
List<Contact> contacts = contactState.contacts;
List<History> callHistories = [];
// Process each log entry.
for (var entry in nativeLogs) {
// Each entry is a Map with keys: number, type, date, duration.
// Process each log entry with intermittent yields to avoid freezing.
for (int i = 0; i < nativeLogs.length; i++) {
final entry = nativeLogs[i];
final String number = entry['number'] ?? '';
if (number.isEmpty) continue;
@ -197,6 +200,8 @@ class _HistoryPageState extends State<HistoryPage>
callHistories
.add(History(matchedContact, callDate, callType, callStatus, 1));
// Yield every 10 iterations to avoid blocking the UI.
if (i % 10 == 0) await Future.delayed(Duration(milliseconds: 1));
}
// Sort histories by most recent.
@ -272,6 +277,7 @@ class _HistoryPageState extends State<HistoryPage>
@override
Widget build(BuildContext context) {
super.build(context); // required due to AutomaticKeepAliveClientMixin
final contactState = ContactState.of(context);
if (loading || contactState.loading) {

View File

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

View File

@ -1,24 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../features/call/call_page.dart';
import '../features/call/incoming_call_page.dart'; // Import the new page
import '../features/call/incoming_call_page.dart';
class CallService {
static const MethodChannel _channel = MethodChannel('call_service');
static String? currentPhoneNumber;
static bool _isCallPageVisible = false;
static Map<String, dynamic>? _pendingCall;
static bool wasPhoneLocked = false;
static final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
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;
}
print('CallService: Handling method call: ${call.method}');
switch (call.method) {
case "callAdded":
final phoneNumber = call.arguments["callId"] as String;
@ -26,39 +22,86 @@ class CallService {
currentPhoneNumber = phoneNumber.replaceFirst('tel:', '');
print('CallService: Call added, number: $currentPhoneNumber, state: $state');
if (state == "ringing") {
_navigateToIncomingCallPage(context);
_handleIncomingCall(phoneNumber);
} else {
_navigateToCallPage(context);
_navigateToCallPage();
}
break;
case "callStateChanged":
final state = call.arguments["state"] as String;
print('CallService: State changed to $state');
wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false;
print('CallService: State changed to $state, wasPhoneLocked: $wasPhoneLocked');
if (state == "disconnected" || state == "disconnecting") {
_closeCallPage(context);
_closeCallPage();
if (wasPhoneLocked) {
_channel.invokeMethod("callEndedFromFlutter");
}
} else if (state == "active" || state == "dialing") {
_navigateToCallPage(context);
_navigateToCallPage();
} else if (state == "ringing") {
_navigateToIncomingCallPage(context);
final phoneNumber = call.arguments["callId"] as String;
_handleIncomingCall(phoneNumber.replaceFirst('tel:', ''));
}
break;
case "callEnded":
case "callRemoved":
print('CallService: Call ended/removed');
_closeCallPage(context);
wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false;
print('CallService: Call ended/removed, wasPhoneLocked: $wasPhoneLocked');
_closeCallPage();
if (wasPhoneLocked) {
_channel.invokeMethod("callEndedFromFlutter");
}
currentPhoneNumber = null;
break;
case "incomingCallFromNotification":
final phoneNumber = call.arguments["phoneNumber"] as String;
wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false;
currentPhoneNumber = phoneNumber;
print('CallService: Incoming call from notification: $phoneNumber, wasPhoneLocked: $wasPhoneLocked');
_handleIncomingCall(phoneNumber);
break;
}
});
}
void _navigateToCallPage(BuildContext context) {
if (_isCallPageVisible && ModalRoute.of(context)?.settings.name == '/call') {
void _handleIncomingCall(String phoneNumber) {
final context = navigatorKey.currentContext;
if (context == null) {
print('CallService: Context is null, queuing incoming call: $phoneNumber');
_pendingCall = {"phoneNumber": phoneNumber};
Future.delayed(Duration(milliseconds: 500), () => _checkPendingCall());
} else {
_navigateToIncomingCallPage(context);
}
}
void _checkPendingCall() {
if (_pendingCall != null) {
final context = navigatorKey.currentContext;
if (context != null) {
print('CallService: Processing queued call: ${_pendingCall!["phoneNumber"]}');
currentPhoneNumber = _pendingCall!["phoneNumber"];
_navigateToIncomingCallPage(context);
_pendingCall = null;
} else {
print('CallService: Context still null, retrying...');
Future.delayed(Duration(milliseconds: 500), () => _checkPendingCall());
}
}
}
void _navigateToCallPage() {
final context = navigatorKey.currentContext;
if (context == null) {
print('CallService: Cannot navigate to CallPage, context is null');
return;
}
if (_isCallPageVisible) {
print('CallService: CallPage already visible, skipping navigation');
return;
}
print('CallService: Navigating to CallPage');
Navigator.pushReplacement(
Navigator.push(
context,
MaterialPageRoute(
settings: const RouteSettings(name: '/call'),
@ -70,12 +113,13 @@ class CallService {
),
).then((_) {
_isCallPageVisible = false;
print('CallService: CallPage popped, _isCallPageVisible set to false');
});
_isCallPageVisible = true;
}
void _navigateToIncomingCallPage(BuildContext context) {
if (_isCallPageVisible && ModalRoute.of(context)?.settings.name == '/incoming_call') {
if (_isCallPageVisible) {
print('CallService: IncomingCallPage already visible, skipping navigation');
return;
}
@ -92,23 +136,28 @@ class CallService {
),
).then((_) {
_isCallPageVisible = false;
print('CallService: IncomingCallPage popped, _isCallPageVisible set to false');
});
_isCallPageVisible = true;
}
void _closeCallPage(BuildContext context) {
if (!_isCallPageVisible) {
print('CallService: CallPage not visible, skipping pop');
void _closeCallPage() {
final context = navigatorKey.currentContext;
if (context == null) {
print('CallService: Cannot close page, context is null');
return;
}
print('CallService: Closing call page, _isCallPageVisible: $_isCallPageVisible');
if (Navigator.canPop(context)) {
print('CallService: Popping CallPage');
print('CallService: Popping call page');
Navigator.pop(context);
_isCallPageVisible = false;
} else {
print('CallService: No page to pop');
}
}
Future<void> makeGsmCall(
Future<Map<String, dynamic>> makeGsmCall(
BuildContext context, {
required String phoneNumber,
String? displayName,
@ -119,36 +168,40 @@ class CallService {
print('CallService: Making GSM call to $phoneNumber');
final result = await _channel.invokeMethod('makeGsmCall', {"phoneNumber": phoneNumber});
print('CallService: makeGsmCall result: $result');
if (result["status"] != "calling") {
final resultMap = Map<String, dynamic>.from(result as Map);
if (resultMap["status"] != "calling") {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Failed to initiate call")),
);
}
return resultMap;
} catch (e) {
print("CallService: Error making call: $e");
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Error making call: $e")),
);
rethrow;
return {"status": "error", "message": e.toString()};
}
}
Future<void> hangUpCall(BuildContext context) async {
Future<Map<String, dynamic>> hangUpCall(BuildContext context) async {
try {
print('CallService: Hanging up call');
final result = await _channel.invokeMethod('hangUpCall');
print('CallService: hangUpCall result: $result');
if (result["status"] != "ended") {
final resultMap = Map<String, dynamic>.from(result as Map);
if (resultMap["status"] != "ended") {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Failed to end call")),
);
}
return resultMap;
} catch (e) {
print("CallService: Error hanging up call: $e");
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("Error hanging up call: $e")),
);
rethrow;
return {"status": "error", "message": e.toString()};
}
}
}

View File

@ -27,14 +27,13 @@ The protocol definition will include as completed:
- Handshakes
- Real-time data-stream encryption (and decryption)
- Encrypted stream compression
- Transmission over audio stream
- Minimal error correction in audio-based transmission
- Error handling and user prevention
- Transmission over audio stream (at least one modulation type)
- First steps in FEC (Forward Error Correction): detecting half of transmission errors
And should include prototype or scratches functionalities, among which:
- Embedded silent data transmission (silently transmit light data during an encrypted phone call)
- Embedded silent data transmission (such as DTMF)
- On-the-fly key exchange (does not require prior key exchange, sacrifying some security)
- Strong error correction
- Stronger FEC: detecting >80%, correcting 20% of transmission errors
#### The Icing dialer (based on Icing kotlin library, an Icing protocol implementation)
@ -128,16 +127,15 @@ The remote bank advisor asks him to authenticate, making him type his password o
By using the Icing protocol, not only would Jeff and the bank be assured that the informations are transmitted safely,
but also that the call is coming from Jeff's phone and not an impersonator.
Elise is a 42 years-old extreme reporter.
After interviewing Russians opposition's leader, the FSB is looking to interview her.
She tries to stay discreet and hidden, but those measures constrains her to barely receive cellular network.
She suspects her phone line to be monitored, so the best she can do to call safely, is to use her Icing dialer.
Elise, 42 years-old, is a journalist covering sensitive topics.
Her work draws attention from people who want to know what she's saying - and to whom.
Forced to stay discreet, with unreliable signal and a likely monitored phone line,
she uses Icing dialer to make secure calls without exposing herself.
Paul, a 22 years-old developer working for a big company, decides to go to China for vacations.
Paul, a 22 years-old developer, is enjoying its vacations abroad.
But everything goes wrong! The company's product he works on, is failling in the middle of the day and no one is
qualified to fix it. Paul doesn't have WiFi and his phone plan only covers voice calls in China.
With Icing dialer, he can call his collegues and help fix the
problem, safe from potential Chinese spies.
qualified to fix it. Paul doesn't have WiFi and his phone plan only covers voice calls in his country.
With Icing dialer, he can call his collegues and help fix the problem, completely safe.
## Evaluation Criteria
### Protocol and lib