Compare commits
4 Commits
Author | SHA1 | Date | |
---|---|---|---|
ad14ea8e06 | |||
|
07361b8448 | ||
f8a7aa0147 | |||
8adf9c6205 |
80
README.md
80
README.md
@ -1,72 +1,26 @@
|
|||||||
---
|
# Icing
|
||||||
gitea: none
|
|
||||||
include_toc: true
|
|
||||||
---
|
|
||||||
|
|
||||||
# Icing – end-to-end-encrypted phone calls without data
|
## Encrypting phone calls on an analog audio level
|
||||||
|
|
||||||
|
|
||||||
Experimental α-stage • Apache-2.0 • Built at Epitech
|
|
||||||
|
|
||||||
 
|
|
||||||
|
|
||||||
> **Icing** runs a Noise-XK handshake, Codec2 and 4-FSK modulation over the plain voice channel, so any GSM/VoLTE call can be upgraded to private, authenticated audio - **no servers and no IP stack required.**
|
|
||||||
|
|
||||||
|
An Epitech Innovation Project
|
||||||
|
|
||||||
|
*By*
|
||||||
|
**Bartosz Michalak - Alexis Danlos - Florian Griffon - Ange Duhayon - Stéphane Corbière**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
The **docs** folder contains documentation about:
|
||||||
|
|
||||||
|
#### Epitech
|
||||||
|
- The Beta Test Plan
|
||||||
|
- The Delivrables
|
||||||
|
|
||||||
|
#### Icing
|
||||||
|
- The project
|
||||||
|
- A user manual
|
||||||
|
- Our automations
|
||||||
|
|
||||||
|
|
||||||
## 📖 Detailed design
|
If you experienced a bug or have any suggestion, a form is available
|
||||||
|
|
||||||
See [`docs/Icing.md`](docs/Icing.md) for protocol goals, threat model, and technical architecture.
|
[](https://cryptpad.fr/form/#/2/form/view/Tgm5vQ7aRgR6TKxy8LMJcJ-nu9CVC32IbqYOyOG4iUs/)
|
||||||
|
|
||||||
## 🔨 Quick start (developer preview, un-protocoled dialer)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# on an Android phone (Android 12+)
|
|
||||||
git clone https://git.gmoker.com/icing/monorepo
|
|
||||||
cd dialer
|
|
||||||
# Requires Flutter and ADB or a virtual device
|
|
||||||
flutter run
|
|
||||||
```
|
|
||||||
|
|
||||||
> ⚠️ This is an **alpha prototype**: expect crashes, missing UX, and incomplete FEC.
|
|
||||||
|
|
||||||
You can join us in Telegram or Reddit !
|
|
||||||
https://t.me/icingdialer
|
|
||||||
https://www.reddit.com/r/IcingDialer/
|
|
||||||
|
|
||||||
## ✨ Features (α1 snapshot)
|
|
||||||
|
|
||||||
- [DryBox Only] Noise *XK* handshake (X25519, AES-GCM, SHA-256)
|
|
||||||
- Static keys = Ed25519 (QR share)
|
|
||||||
- [DryBox Only] Voice path: Codec2 → encrypted bit-stream → 4-FSK → analog channel
|
|
||||||
- GSM simulation in DryBox for off-device testing
|
|
||||||
|
|
||||||
## 🗺️ Project status
|
|
||||||
|
|
||||||
| Stage (roadmap) | Dialer app | Protocol | DryBox | Docs |
|
|
||||||
| --------------------- | -------------------------- | ------------------ | ----------------- | ----------------- |
|
|
||||||
| **Alpha 1 (Q3-2025)** | 🚧 UI stub, call hook | Key gestion | ⚠️ Qt demo, Alpha 1 Working | 📝 Draft complete |
|
|
||||||
| Alpha 2 (Q4-2025) | 🛠️ Magisk flow | 🔄 Adaptive FEC | 🔄 Stress tests | 🔄 Expanded |
|
|
||||||
| **Beta 1 (Feb 2026)** | 🎉 Public release | 🔐 Audit pass | ✅ CI | ✅ |
|
|
||||||
|
|
||||||
## 🤝 How to help
|
|
||||||
|
|
||||||
|
|
||||||
- **Crypto researchers** – Poke holes in the protocol draft.
|
|
||||||
- **Android security hackers** - Review our Kotlin integrations.
|
|
||||||
- **ROM maintainers** - Let's talk about an integration !
|
|
||||||
|
|
||||||
Open an issue or report here [](https://cryptpad.fr/form/#/2/form/view/Tgm5vQ7aRgR6TKxy8LMJcJ-nu9CVC32IbqYOyOG4iUs/)
|
|
||||||
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Apache License 2.0 - see [`LICENSE`](LICENSE).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Made with ☕ by four Epitech students.
|
|
@ -24,7 +24,7 @@ android {
|
|||||||
applicationId = "com.icing.dialer"
|
applicationId = "com.icing.dialer"
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = 23
|
minSdk = flutter.minSdkVersion
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
|
@ -1,49 +0,0 @@
|
|||||||
package com.example.dialer;
|
|
||||||
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.net.Uri;
|
|
||||||
import android.content.Context;
|
|
||||||
import io.flutter.embedding.android.FlutterActivity;
|
|
||||||
import io.flutter.embedding.engine.FlutterEngine;
|
|
||||||
import io.flutter.plugin.common.MethodChannel;
|
|
||||||
import io.flutter.plugin.common.MethodChannel.MethodCallHandler;
|
|
||||||
import io.flutter.plugin.common.MethodChannel.Result;
|
|
||||||
import io.flutter.plugin.common.MethodCall;
|
|
||||||
import android.telecom.TelecomManager;
|
|
||||||
import android.telecom.PhoneAccountHandle;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Collections;
|
|
||||||
|
|
||||||
public class MainActivity extends FlutterActivity {
|
|
||||||
private static final String CHANNEL = "call_service";
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void configureFlutterEngine(FlutterEngine flutterEngine) {
|
|
||||||
super.configureFlutterEngine(flutterEngine);
|
|
||||||
new MethodChannel(flutterEngine.getDartExecutor().getBinaryMessenger(), CHANNEL)
|
|
||||||
.setMethodCallHandler(
|
|
||||||
new MethodCallHandler() {
|
|
||||||
@Override
|
|
||||||
public void onMethodCall(MethodCall call, Result result) {
|
|
||||||
if (call.method.equals("makeGsmCall")) {
|
|
||||||
String phoneNumber = call.argument("phoneNumber");
|
|
||||||
int simSlot = call.argument("simSlot");
|
|
||||||
TelecomManager telecomManager = (TelecomManager) getSystemService(Context.TELECOM_SERVICE);
|
|
||||||
List<PhoneAccountHandle> accounts = telecomManager.getCallCapablePhoneAccounts();
|
|
||||||
PhoneAccountHandle selectedAccount = accounts.get(simSlot < accounts.size() ? simSlot : 0);
|
|
||||||
Bundle extras = new Bundle();
|
|
||||||
extras.putParcelable(TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, selectedAccount);
|
|
||||||
Uri uri = Uri.fromParts("tel", phoneNumber, null);
|
|
||||||
telecomManager.placeCall(uri, extras);
|
|
||||||
result.success(Collections.singletonMap("status", "calling"));
|
|
||||||
} else if (call.method.equals("hangUpCall")) {
|
|
||||||
// TODO: implement hangUpCall if needed
|
|
||||||
result.success(Collections.singletonMap("status", "ended"));
|
|
||||||
} else {
|
|
||||||
result.notImplemented();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -10,8 +10,6 @@ import android.os.Build
|
|||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.CallLog
|
import android.provider.CallLog
|
||||||
import android.telecom.TelecomManager
|
import android.telecom.TelecomManager
|
||||||
import android.telephony.SubscriptionManager
|
|
||||||
import android.telephony.SubscriptionInfo
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.icing.dialer.KeystoreHelper
|
import com.icing.dialer.KeystoreHelper
|
||||||
@ -98,11 +96,11 @@ class MainActivity : FlutterActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.success(true)
|
result.success(true)
|
||||||
} "makeGsmCall" -> {
|
}
|
||||||
|
"makeGsmCall" -> {
|
||||||
val phoneNumber = call.argument<String>("phoneNumber")
|
val phoneNumber = call.argument<String>("phoneNumber")
|
||||||
val simSlot = call.argument<Int>("simSlot") ?: 0
|
|
||||||
if (phoneNumber != null) {
|
if (phoneNumber != null) {
|
||||||
val success = CallService.makeGsmCall(this, phoneNumber, simSlot)
|
val success = CallService.makeGsmCall(this, phoneNumber)
|
||||||
if (success) {
|
if (success) {
|
||||||
result.success(mapOf("status" to "calling", "phoneNumber" to phoneNumber))
|
result.success(mapOf("status" to "calling", "phoneNumber" to phoneNumber))
|
||||||
} else {
|
} else {
|
||||||
@ -230,25 +228,16 @@ class MainActivity : FlutterActivity() {
|
|||||||
|
|
||||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALLLOG_CHANNEL)
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALLLOG_CHANNEL)
|
||||||
.setMethodCallHandler { call, result ->
|
.setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
if (call.method == "getCallLogs") {
|
||||||
"getCallLogs" -> {
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALL_LOG) == PackageManager.PERMISSION_GRANTED) {
|
||||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALL_LOG) == PackageManager.PERMISSION_GRANTED) {
|
val callLogs = getCallLogs()
|
||||||
val callLogs = getCallLogs()
|
result.success(callLogs)
|
||||||
result.success(callLogs)
|
} else {
|
||||||
} else {
|
requestPermissions(arrayOf(Manifest.permission.READ_CALL_LOG), REQUEST_CODE_CALL_LOG_PERMISSION)
|
||||||
requestPermissions(arrayOf(Manifest.permission.READ_CALL_LOG), REQUEST_CODE_CALL_LOG_PERMISSION)
|
result.error("PERMISSION_DENIED", "Call log permission not granted", null)
|
||||||
result.error("PERMISSION_DENIED", "Call log permission not granted", null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
"getLatestCallLog" -> {
|
} else {
|
||||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALL_LOG) == PackageManager.PERMISSION_GRANTED) {
|
result.notImplemented()
|
||||||
val latestCallLog = getLatestCallLog()
|
|
||||||
result.success(latestCallLog)
|
|
||||||
} else {
|
|
||||||
result.error("PERMISSION_DENIED", "Call log permission not granted", null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> result.notImplemented()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -332,30 +321,12 @@ class MainActivity : FlutterActivity() {
|
|||||||
val type = it.getInt(it.getColumnIndexOrThrow(CallLog.Calls.TYPE))
|
val type = it.getInt(it.getColumnIndexOrThrow(CallLog.Calls.TYPE))
|
||||||
val date = it.getLong(it.getColumnIndexOrThrow(CallLog.Calls.DATE))
|
val date = it.getLong(it.getColumnIndexOrThrow(CallLog.Calls.DATE))
|
||||||
val duration = it.getLong(it.getColumnIndexOrThrow(CallLog.Calls.DURATION))
|
val duration = it.getLong(it.getColumnIndexOrThrow(CallLog.Calls.DURATION))
|
||||||
|
|
||||||
// Extract subscription ID (SIM card info) if available
|
|
||||||
var subscriptionId: Int? = null
|
|
||||||
var simName: String? = null
|
|
||||||
try {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
|
|
||||||
val subIdColumnIndex = it.getColumnIndex("subscription_id")
|
|
||||||
if (subIdColumnIndex >= 0) {
|
|
||||||
subscriptionId = it.getInt(subIdColumnIndex)
|
|
||||||
// Get the actual SIM name
|
|
||||||
simName = getSimNameFromSubscriptionId(subscriptionId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Failed to get subscription_id: ${e.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
val map = mutableMapOf<String, Any?>(
|
val map = mutableMapOf<String, Any?>(
|
||||||
"number" to number,
|
"number" to number,
|
||||||
"type" to type,
|
"type" to type,
|
||||||
"date" to date,
|
"date" to date,
|
||||||
"duration" to duration,
|
"duration" to duration
|
||||||
"subscription_id" to subscriptionId,
|
|
||||||
"sim_name" to simName
|
|
||||||
)
|
)
|
||||||
logsList.add(map)
|
logsList.add(map)
|
||||||
}
|
}
|
||||||
@ -363,79 +334,6 @@ class MainActivity : FlutterActivity() {
|
|||||||
return logsList
|
return logsList
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getLatestCallLog(): Map<String, Any?>? {
|
|
||||||
val cursor: Cursor? = contentResolver.query(
|
|
||||||
CallLog.Calls.CONTENT_URI,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
CallLog.Calls.DATE + " DESC"
|
|
||||||
)
|
|
||||||
cursor?.use {
|
|
||||||
if (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))
|
|
||||||
|
|
||||||
// Extract subscription ID (SIM card info) if available
|
|
||||||
var subscriptionId: Int? = null
|
|
||||||
var simName: String? = null
|
|
||||||
try {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
|
|
||||||
val subIdColumnIndex = it.getColumnIndex("subscription_id")
|
|
||||||
if (subIdColumnIndex >= 0) {
|
|
||||||
subscriptionId = it.getInt(subIdColumnIndex)
|
|
||||||
// Get the actual SIM name
|
|
||||||
simName = getSimNameFromSubscriptionId(subscriptionId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Failed to get subscription_id: ${e.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
return mapOf(
|
|
||||||
"number" to number,
|
|
||||||
"type" to type,
|
|
||||||
"date" to date,
|
|
||||||
"duration" to duration,
|
|
||||||
"subscription_id" to subscriptionId,
|
|
||||||
"sim_name" to simName
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getSimNameFromSubscriptionId(subscriptionId: Int?): String? {
|
|
||||||
if (subscriptionId == null) return null
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
|
|
||||||
val subscriptionManager = getSystemService(Context.TELEPHONY_SUBSCRIPTION_SERVICE) as SubscriptionManager
|
|
||||||
val subscriptionInfo: SubscriptionInfo? = subscriptionManager.getActiveSubscriptionInfo(subscriptionId)
|
|
||||||
|
|
||||||
return subscriptionInfo?.let { info ->
|
|
||||||
// Try to get display name first, fallback to carrier name, then generic name
|
|
||||||
when {
|
|
||||||
!info.displayName.isNullOrBlank() && info.displayName.toString() != info.subscriptionId.toString() -> {
|
|
||||||
info.displayName.toString()
|
|
||||||
}
|
|
||||||
!info.carrierName.isNullOrBlank() -> {
|
|
||||||
info.carrierName.toString()
|
|
||||||
}
|
|
||||||
else -> "SIM ${info.simSlotIndex + 1}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Log.w(TAG, "Failed to get SIM name for subscription $subscriptionId: ${e.message}")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to generic name
|
|
||||||
return "SIM ${subscriptionId + 1}"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleIncomingCallIntent(intent: Intent?) {
|
private fun handleIncomingCallIntent(intent: Intent?) {
|
||||||
intent?.let {
|
intent?.let {
|
||||||
if (it.getBooleanExtra("isIncomingCall", false)) {
|
if (it.getBooleanExtra("isIncomingCall", false)) {
|
||||||
|
@ -13,35 +13,14 @@ import android.Manifest
|
|||||||
object CallService {
|
object CallService {
|
||||||
private val TAG = "CallService"
|
private val TAG = "CallService"
|
||||||
|
|
||||||
fun makeGsmCall(context: Context, phoneNumber: String, simSlot: Int = 0): Boolean {
|
fun makeGsmCall(context: Context, phoneNumber: String): Boolean {
|
||||||
return try {
|
return try {
|
||||||
val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
|
val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
|
||||||
val uri = Uri.parse("tel:$phoneNumber")
|
val uri = Uri.parse("tel:$phoneNumber")
|
||||||
|
|
||||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CALL_PHONE) == PackageManager.PERMISSION_GRANTED) {
|
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CALL_PHONE) == PackageManager.PERMISSION_GRANTED) {
|
||||||
// Get available phone accounts (SIM cards)
|
telecomManager.placeCall(uri, Bundle())
|
||||||
val phoneAccounts = telecomManager.callCapablePhoneAccounts
|
Log.d(TAG, "Initiated call to $phoneNumber")
|
||||||
|
|
||||||
if (phoneAccounts.isNotEmpty()) {
|
|
||||||
// Select the appropriate SIM slot
|
|
||||||
val selectedAccount = if (simSlot < phoneAccounts.size) {
|
|
||||||
phoneAccounts[simSlot]
|
|
||||||
} else {
|
|
||||||
// Fallback to first available SIM if requested slot doesn't exist
|
|
||||||
phoneAccounts[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
val extras = Bundle().apply {
|
|
||||||
putParcelable(TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, selectedAccount)
|
|
||||||
}
|
|
||||||
|
|
||||||
telecomManager.placeCall(uri, extras)
|
|
||||||
Log.d(TAG, "Initiated call to $phoneNumber using SIM slot $simSlot")
|
|
||||||
} else {
|
|
||||||
// No SIM cards available, make call without specifying SIM
|
|
||||||
telecomManager.placeCall(uri, Bundle())
|
|
||||||
Log.d(TAG, "Initiated call to $phoneNumber without SIM selection (no SIMs available)")
|
|
||||||
}
|
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
Log.e(TAG, "CALL_PHONE permission not granted")
|
Log.e(TAG, "CALL_PHONE permission not granted")
|
||||||
|
@ -2,4 +2,4 @@ org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryErro
|
|||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
dev.steenbakker.mobile_scanner.useUnbundled=true
|
dev.steenbakker.mobile_scanner.useUnbundled=true
|
||||||
# org.gradle.java.home=/usr/lib/jvm/java-17-openjdk-amd64
|
org.gradle.java.home=/usr/lib/jvm/java-17-openjdk-amd64
|
||||||
|
@ -1,19 +1,15 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import '../../presentation/features/call/call_page.dart';
|
import '../../presentation/features/call/call_page.dart';
|
||||||
import '../../presentation/features/call/incoming_call_page.dart'; // Import the new page
|
import '../../presentation/features/call/incoming_call_page.dart'; // Import the new page
|
||||||
import 'contact_service.dart';
|
import 'contact_service.dart';
|
||||||
// Import for history update callback
|
|
||||||
import '../../presentation/features/history/history_page.dart';
|
|
||||||
|
|
||||||
class CallService {
|
class CallService {
|
||||||
static const MethodChannel _channel = MethodChannel('call_service');
|
static const MethodChannel _channel = MethodChannel('call_service');
|
||||||
static String? currentPhoneNumber;
|
static String? currentPhoneNumber;
|
||||||
static String? currentDisplayName;
|
static String? currentDisplayName;
|
||||||
static Uint8List? currentThumbnail;
|
static Uint8List? currentThumbnail;
|
||||||
static int? currentSimSlot; // Track which SIM slot is being used
|
|
||||||
static bool _isCallPageVisible = false;
|
static bool _isCallPageVisible = false;
|
||||||
static Map<String, dynamic>? _pendingCall;
|
static Map<String, dynamic>? _pendingCall;
|
||||||
static bool wasPhoneLocked = false;
|
static bool wasPhoneLocked = false;
|
||||||
@ -21,43 +17,18 @@ class CallService {
|
|||||||
static bool _isNavigating = false;
|
static bool _isNavigating = false;
|
||||||
final ContactService _contactService = ContactService();
|
final ContactService _contactService = ContactService();
|
||||||
final _callStateController = StreamController<String>.broadcast();
|
final _callStateController = StreamController<String>.broadcast();
|
||||||
final _audioStateController =
|
final _audioStateController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
StreamController<Map<String, dynamic>>.broadcast();
|
|
||||||
final _simStateController = StreamController<int?>.broadcast();
|
|
||||||
Map<String, dynamic>? _currentAudioState;
|
Map<String, dynamic>? _currentAudioState;
|
||||||
|
|
||||||
static final GlobalKey<NavigatorState> navigatorKey =
|
static final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||||
GlobalKey<NavigatorState>();
|
|
||||||
Stream<String> get callStateStream => _callStateController.stream;
|
Stream<String> get callStateStream => _callStateController.stream;
|
||||||
Stream<Map<String, dynamic>> get audioStateStream =>
|
Stream<Map<String, dynamic>> get audioStateStream => _audioStateController.stream;
|
||||||
_audioStateController.stream;
|
|
||||||
Stream<int?> get simStateStream => _simStateController.stream;
|
|
||||||
Map<String, dynamic>? get currentAudioState => _currentAudioState;
|
Map<String, dynamic>? get currentAudioState => _currentAudioState;
|
||||||
// Getter for current SIM slot
|
|
||||||
static int? get getCurrentSimSlot => currentSimSlot;
|
|
||||||
// Get SIM display name for the current call
|
|
||||||
static String? getCurrentSimDisplayName() {
|
|
||||||
if (currentSimSlot == null) return null;
|
|
||||||
return "SIM ${currentSimSlot! + 1}";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cancel pending SIM switch (used when user manually hangs up)
|
|
||||||
void cancelPendingSimSwitch() {
|
|
||||||
if (_pendingSimSwitch != null) {
|
|
||||||
print('CallService: Canceling pending SIM switch due to manual hangup');
|
|
||||||
_pendingSimSwitch = null;
|
|
||||||
_manualHangupFlag = true; // Mark that hangup was manual
|
|
||||||
print('CallService: Manual hangup flag set to $_manualHangupFlag');
|
|
||||||
} else {
|
|
||||||
print('CallService: No pending SIM switch to cancel');
|
|
||||||
// Don't set manual hangup flag if there's no SIM switch to cancel
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CallService() {
|
CallService() {
|
||||||
_channel.setMethodCallHandler((call) async {
|
_channel.setMethodCallHandler((call) async {
|
||||||
print(
|
print('CallService: Handling method call: ${call.method}, with args: ${call.arguments}');
|
||||||
'CallService: Handling method call: ${call.method}, with args: ${call.arguments}');
|
|
||||||
switch (call.method) {
|
switch (call.method) {
|
||||||
case "callAdded":
|
case "callAdded":
|
||||||
final phoneNumber = call.arguments["callId"] as String?;
|
final phoneNumber = call.arguments["callId"] as String?;
|
||||||
@ -66,18 +37,15 @@ class CallService {
|
|||||||
print('CallService: Invalid callAdded args: $call.arguments');
|
print('CallService: Invalid callAdded args: $call.arguments');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final decodedPhoneNumber =
|
final decodedPhoneNumber = Uri.decodeComponent(phoneNumber.replaceFirst('tel:', ''));
|
||||||
Uri.decodeComponent(phoneNumber.replaceFirst('tel:', ''));
|
|
||||||
print('CallService: Decoded phone number: $decodedPhoneNumber');
|
print('CallService: Decoded phone number: $decodedPhoneNumber');
|
||||||
if (_activeCallNumber != decodedPhoneNumber) {
|
if (_activeCallNumber != decodedPhoneNumber) {
|
||||||
currentPhoneNumber = decodedPhoneNumber;
|
currentPhoneNumber = decodedPhoneNumber;
|
||||||
if (currentDisplayName == null ||
|
if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) {
|
||||||
currentDisplayName == currentPhoneNumber) {
|
|
||||||
await _fetchContactInfo(decodedPhoneNumber);
|
await _fetchContactInfo(decodedPhoneNumber);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
print(
|
print('CallService: Call added, number: $currentPhoneNumber, displayName: $currentDisplayName, state: $state');
|
||||||
'CallService: Call added, number: $currentPhoneNumber, displayName: $currentDisplayName, state: $state');
|
|
||||||
_callStateController.add(state);
|
_callStateController.add(state);
|
||||||
if (state == "ringing") {
|
if (state == "ringing") {
|
||||||
_handleIncomingCall(decodedPhoneNumber);
|
_handleIncomingCall(decodedPhoneNumber);
|
||||||
@ -89,63 +57,30 @@ class CallService {
|
|||||||
final state = call.arguments["state"] as String?;
|
final state = call.arguments["state"] as String?;
|
||||||
wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false;
|
wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false;
|
||||||
if (state == null) {
|
if (state == null) {
|
||||||
print(
|
print('CallService: Invalid callStateChanged args: $call.arguments');
|
||||||
'CallService: Invalid callStateChanged args: $call.arguments');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
print(
|
print('CallService: State changed to $state, wasPhoneLocked: $wasPhoneLocked');
|
||||||
'CallService: State changed to $state, wasPhoneLocked: $wasPhoneLocked');
|
|
||||||
_callStateController.add(state);
|
_callStateController.add(state);
|
||||||
if (state == "disconnected" || state == "disconnecting") {
|
if (state == "disconnected" || state == "disconnecting") {
|
||||||
print('CallService: ========== CALL DISCONNECTED ==========');
|
|
||||||
print(
|
|
||||||
'CallService: _pendingSimSwitch: ${_pendingSimSwitch != null}');
|
|
||||||
print('CallService: _manualHangupFlag: $_manualHangupFlag');
|
|
||||||
print('CallService: _isCallPageVisible: $_isCallPageVisible');
|
|
||||||
|
|
||||||
// Always close call page on disconnection - SIM switching should not prevent this
|
|
||||||
print('CallService: Calling _closeCallPage() on call disconnection');
|
|
||||||
_closeCallPage();
|
_closeCallPage();
|
||||||
|
|
||||||
// Reset manual hangup flag after successful page close
|
|
||||||
if (_manualHangupFlag) {
|
|
||||||
print(
|
|
||||||
'CallService: Resetting manual hangup flag after page close');
|
|
||||||
_manualHangupFlag = false;
|
|
||||||
}
|
|
||||||
if (wasPhoneLocked) {
|
if (wasPhoneLocked) {
|
||||||
await _channel.invokeMethod("callEndedFromFlutter");
|
await _channel.invokeMethod("callEndedFromFlutter");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify history page to add the latest call
|
|
||||||
// Add a small delay to ensure call log is updated by the system
|
|
||||||
Timer(const Duration(milliseconds: 500), () {
|
|
||||||
HistoryPageState.addNewCallToHistory();
|
|
||||||
});
|
|
||||||
|
|
||||||
_activeCallNumber = null;
|
_activeCallNumber = null;
|
||||||
// Handle pending SIM switch after call is disconnected
|
|
||||||
_handlePendingSimSwitch();
|
|
||||||
} else if (state == "active" || state == "dialing") {
|
} else if (state == "active" || state == "dialing") {
|
||||||
final phoneNumber = call.arguments["callId"] as String?;
|
final phoneNumber = call.arguments["callId"] as String?;
|
||||||
if (phoneNumber != null &&
|
if (phoneNumber != null && _activeCallNumber != Uri.decodeComponent(phoneNumber.replaceFirst('tel:', ''))) {
|
||||||
_activeCallNumber !=
|
currentPhoneNumber = Uri.decodeComponent(phoneNumber.replaceFirst('tel:', ''));
|
||||||
Uri.decodeComponent(phoneNumber.replaceFirst('tel:', ''))) {
|
if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) {
|
||||||
currentPhoneNumber =
|
|
||||||
Uri.decodeComponent(phoneNumber.replaceFirst('tel:', ''));
|
|
||||||
if (currentDisplayName == null ||
|
|
||||||
currentDisplayName == currentPhoneNumber) {
|
|
||||||
await _fetchContactInfo(currentPhoneNumber!);
|
await _fetchContactInfo(currentPhoneNumber!);
|
||||||
}
|
}
|
||||||
} else if (currentPhoneNumber != null &&
|
} else if (currentPhoneNumber != null && _activeCallNumber != currentPhoneNumber) {
|
||||||
_activeCallNumber != currentPhoneNumber) {
|
if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) {
|
||||||
if (currentDisplayName == null ||
|
|
||||||
currentDisplayName == currentPhoneNumber) {
|
|
||||||
await _fetchContactInfo(currentPhoneNumber!);
|
await _fetchContactInfo(currentPhoneNumber!);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
print(
|
print('CallService: Skipping fetch, active call: $_activeCallNumber, current: $currentPhoneNumber, displayName: $currentDisplayName');
|
||||||
'CallService: Skipping fetch, active call: $_activeCallNumber, current: $currentPhoneNumber, displayName: $currentDisplayName');
|
|
||||||
}
|
}
|
||||||
_navigateToCallPage();
|
_navigateToCallPage();
|
||||||
} else if (state == "ringing") {
|
} else if (state == "ringing") {
|
||||||
@ -154,12 +89,10 @@ class CallService {
|
|||||||
print('CallService: Invalid ringing callId: $call.arguments');
|
print('CallService: Invalid ringing callId: $call.arguments');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final decodedPhoneNumber =
|
final decodedPhoneNumber = Uri.decodeComponent(phoneNumber.replaceFirst('tel:', ''));
|
||||||
Uri.decodeComponent(phoneNumber.replaceFirst('tel:', ''));
|
|
||||||
if (_activeCallNumber != decodedPhoneNumber) {
|
if (_activeCallNumber != decodedPhoneNumber) {
|
||||||
currentPhoneNumber = decodedPhoneNumber;
|
currentPhoneNumber = decodedPhoneNumber;
|
||||||
if (currentDisplayName == null ||
|
if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) {
|
||||||
currentDisplayName == currentPhoneNumber) {
|
|
||||||
await _fetchContactInfo(decodedPhoneNumber);
|
await _fetchContactInfo(decodedPhoneNumber);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -169,65 +102,38 @@ class CallService {
|
|||||||
case "callEnded":
|
case "callEnded":
|
||||||
case "callRemoved":
|
case "callRemoved":
|
||||||
wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false;
|
wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false;
|
||||||
print('CallService: ========== CALL ENDED/REMOVED ==========');
|
print('CallService: Call ended/removed, wasPhoneLocked: $wasPhoneLocked');
|
||||||
print('CallService: wasPhoneLocked: $wasPhoneLocked');
|
|
||||||
print('CallService: _pendingSimSwitch: ${_pendingSimSwitch != null}');
|
|
||||||
print('CallService: _manualHangupFlag: $_manualHangupFlag');
|
|
||||||
print('CallService: _isCallPageVisible: $_isCallPageVisible');
|
|
||||||
|
|
||||||
// Always close call page when call ends - SIM switching should not prevent this
|
|
||||||
print('CallService: Calling _closeCallPage() on call ended/removed');
|
|
||||||
_closeCallPage();
|
_closeCallPage();
|
||||||
|
|
||||||
// Reset manual hangup flag after closing page
|
|
||||||
if (_manualHangupFlag) {
|
|
||||||
print(
|
|
||||||
'CallService: Resetting manual hangup flag after callEnded');
|
|
||||||
_manualHangupFlag = false;
|
|
||||||
}
|
|
||||||
if (wasPhoneLocked) {
|
if (wasPhoneLocked) {
|
||||||
await _channel.invokeMethod("callEndedFromFlutter");
|
await _channel.invokeMethod("callEndedFromFlutter");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify history page to add the latest call
|
|
||||||
// Add a small delay to ensure call log is updated by the system
|
|
||||||
Timer(const Duration(milliseconds: 500), () {
|
|
||||||
HistoryPageState.addNewCallToHistory();
|
|
||||||
});
|
|
||||||
|
|
||||||
currentPhoneNumber = null;
|
currentPhoneNumber = null;
|
||||||
currentDisplayName = null;
|
currentDisplayName = null;
|
||||||
currentThumbnail = null;
|
currentThumbnail = null;
|
||||||
currentSimSlot = null; // Reset SIM slot when call ends
|
|
||||||
_simStateController.add(null); // Notify UI that SIM is cleared
|
|
||||||
_activeCallNumber = null;
|
_activeCallNumber = null;
|
||||||
break;
|
break;
|
||||||
case "incomingCallFromNotification":
|
case "incomingCallFromNotification":
|
||||||
final phoneNumber = call.arguments["phoneNumber"] as String?;
|
final phoneNumber = call.arguments["phoneNumber"] as String?;
|
||||||
wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false;
|
wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false;
|
||||||
if (phoneNumber == null) {
|
if (phoneNumber == null) {
|
||||||
print(
|
print('CallService: Invalid incomingCallFromNotification args: $call.arguments');
|
||||||
'CallService: Invalid incomingCallFromNotification args: $call.arguments');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final decodedPhoneNumber = Uri.decodeComponent(phoneNumber);
|
final decodedPhoneNumber = Uri.decodeComponent(phoneNumber);
|
||||||
if (_activeCallNumber != decodedPhoneNumber) {
|
if (_activeCallNumber != decodedPhoneNumber) {
|
||||||
currentPhoneNumber = decodedPhoneNumber;
|
currentPhoneNumber = decodedPhoneNumber;
|
||||||
if (currentDisplayName == null ||
|
if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) {
|
||||||
currentDisplayName == currentPhoneNumber) {
|
|
||||||
await _fetchContactInfo(decodedPhoneNumber);
|
await _fetchContactInfo(decodedPhoneNumber);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
print(
|
print('CallService: Incoming call from notification: $decodedPhoneNumber, displayName: $currentDisplayName, wasPhoneLocked: $wasPhoneLocked');
|
||||||
'CallService: Incoming call from notification: $decodedPhoneNumber, displayName: $currentDisplayName, wasPhoneLocked: $wasPhoneLocked');
|
|
||||||
_handleIncomingCall(decodedPhoneNumber);
|
_handleIncomingCall(decodedPhoneNumber);
|
||||||
break;
|
break;
|
||||||
case "audioStateChanged":
|
case "audioStateChanged":
|
||||||
final route = call.arguments["route"] as int?;
|
final route = call.arguments["route"] as int?;
|
||||||
final muted = call.arguments["muted"] as bool?;
|
final muted = call.arguments["muted"] as bool?;
|
||||||
final speaker = call.arguments["speaker"] as bool?;
|
final speaker = call.arguments["speaker"] as bool?;
|
||||||
print(
|
print('CallService: Audio state changed, route: $route, muted: $muted, speaker: $speaker');
|
||||||
'CallService: Audio state changed, route: $route, muted: $muted, speaker: $speaker');
|
|
||||||
final audioState = {
|
final audioState = {
|
||||||
"route": route,
|
"route": route,
|
||||||
"muted": muted,
|
"muted": muted,
|
||||||
@ -251,8 +157,7 @@ class CallService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> muteCall(BuildContext context,
|
Future<Map<String, dynamic>> muteCall(BuildContext context, {required bool mute}) async {
|
||||||
{required bool mute}) async {
|
|
||||||
try {
|
try {
|
||||||
print('CallService: Toggling mute to $mute');
|
print('CallService: Toggling mute to $mute');
|
||||||
final result = await _channel.invokeMethod('muteCall', {'mute': mute});
|
final result = await _channel.invokeMethod('muteCall', {'mute': mute});
|
||||||
@ -273,12 +178,10 @@ class CallService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>> speakerCall(BuildContext context,
|
Future<Map<String, dynamic>> speakerCall(BuildContext context, {required bool speaker}) async {
|
||||||
{required bool speaker}) async {
|
|
||||||
try {
|
try {
|
||||||
print('CallService: Toggling speaker to $speaker');
|
print('CallService: Toggling speaker to $speaker');
|
||||||
final result =
|
final result = await _channel.invokeMethod('speakerCall', {'speaker': speaker});
|
||||||
await _channel.invokeMethod('speakerCall', {'speaker': speaker});
|
|
||||||
print('CallService: speakerCall result: $result');
|
print('CallService: speakerCall result: $result');
|
||||||
return Map<String, dynamic>.from(result);
|
return Map<String, dynamic>.from(result);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -305,21 +208,18 @@ class CallService {
|
|||||||
for (var contact in contacts) {
|
for (var contact in contacts) {
|
||||||
for (var phone in contact.phones) {
|
for (var phone in contact.phones) {
|
||||||
final normalizedContactNumber = _normalizePhoneNumber(phone.number);
|
final normalizedContactNumber = _normalizePhoneNumber(phone.number);
|
||||||
print(
|
print('CallService: Comparing $normalizedPhoneNumber with contact number $normalizedContactNumber');
|
||||||
'CallService: Comparing $normalizedPhoneNumber with contact number $normalizedContactNumber');
|
|
||||||
if (normalizedContactNumber == normalizedPhoneNumber) {
|
if (normalizedContactNumber == normalizedPhoneNumber) {
|
||||||
currentDisplayName = contact.displayName;
|
currentDisplayName = contact.displayName;
|
||||||
currentThumbnail = contact.thumbnail;
|
currentThumbnail = contact.thumbnail;
|
||||||
print(
|
print('CallService: Found contact - displayName: $currentDisplayName, thumbnail: ${currentThumbnail != null ? "present" : "null"}');
|
||||||
'CallService: Found contact - displayName: $currentDisplayName, thumbnail: ${currentThumbnail != null ? "present" : "null"}');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
currentDisplayName = phoneNumber;
|
currentDisplayName = phoneNumber;
|
||||||
currentThumbnail = null;
|
currentThumbnail = null;
|
||||||
print(
|
print('CallService: No contact match, using phoneNumber as displayName: $currentDisplayName');
|
||||||
'CallService: No contact match, using phoneNumber as displayName: $currentDisplayName');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('CallService: Error fetching contact info: $e');
|
print('CallService: Error fetching contact info: $e');
|
||||||
currentDisplayName = phoneNumber;
|
currentDisplayName = phoneNumber;
|
||||||
@ -328,23 +228,19 @@ class CallService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _normalizePhoneNumber(String number) {
|
String _normalizePhoneNumber(String number) {
|
||||||
return number
|
return number.replaceAll(RegExp(r'[\s\-\(\)]'), '').replaceFirst(RegExp(r'^\+'), '');
|
||||||
.replaceAll(RegExp(r'[\s\-\(\)]'), '')
|
|
||||||
.replaceFirst(RegExp(r'^\+'), '');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleIncomingCall(String phoneNumber) {
|
void _handleIncomingCall(String phoneNumber) {
|
||||||
if (_activeCallNumber == phoneNumber && _isCallPageVisible) {
|
if (_activeCallNumber == phoneNumber && _isCallPageVisible) {
|
||||||
print(
|
print('CallService: Incoming call for $phoneNumber already active, skipping');
|
||||||
'CallService: Incoming call for $phoneNumber already active, skipping');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_activeCallNumber = phoneNumber;
|
_activeCallNumber = phoneNumber;
|
||||||
|
|
||||||
final context = navigatorKey.currentContext;
|
final context = navigatorKey.currentContext;
|
||||||
if (context == null) {
|
if (context == null) {
|
||||||
print(
|
print('CallService: Context is null, queuing incoming call: $phoneNumber');
|
||||||
'CallService: Context is null, queuing incoming call: $phoneNumber');
|
|
||||||
_pendingCall = {"phoneNumber": phoneNumber};
|
_pendingCall = {"phoneNumber": phoneNumber};
|
||||||
Future.delayed(Duration(milliseconds: 500), () => _checkPendingCall());
|
Future.delayed(Duration(milliseconds: 500), () => _checkPendingCall());
|
||||||
} else {
|
} else {
|
||||||
@ -360,8 +256,7 @@ class CallService {
|
|||||||
|
|
||||||
final phoneNumber = _pendingCall!["phoneNumber"];
|
final phoneNumber = _pendingCall!["phoneNumber"];
|
||||||
if (_activeCallNumber == phoneNumber && _isCallPageVisible) {
|
if (_activeCallNumber == phoneNumber && _isCallPageVisible) {
|
||||||
print(
|
print('CallService: Pending call for $phoneNumber already active, clearing');
|
||||||
'CallService: Pending call for $phoneNumber already active, clearing');
|
|
||||||
_pendingCall = null;
|
_pendingCall = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -394,32 +289,24 @@ class CallService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final currentRoute = ModalRoute.of(context)?.settings.name ?? 'unknown';
|
final currentRoute = ModalRoute.of(context)?.settings.name ?? 'unknown';
|
||||||
print(
|
print('CallService: Navigating to CallPage, Visible: $_isCallPageVisible, Current Route: $currentRoute, Active Call: $_activeCallNumber, DisplayName: $currentDisplayName');
|
||||||
'CallService: Navigating to CallPage, Visible: $_isCallPageVisible, Current Route: $currentRoute, Active Call: $_activeCallNumber, DisplayName: $currentDisplayName');
|
if (_isCallPageVisible && currentRoute == '/call' && _activeCallNumber == currentPhoneNumber) {
|
||||||
if (_isCallPageVisible &&
|
print('CallService: CallPage already visible for $_activeCallNumber, skipping navigation');
|
||||||
currentRoute == '/call' &&
|
|
||||||
_activeCallNumber == currentPhoneNumber) {
|
|
||||||
print(
|
|
||||||
'CallService: CallPage already visible for $_activeCallNumber, skipping navigation');
|
|
||||||
_isNavigating = false;
|
_isNavigating = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_isCallPageVisible &&
|
if (_isCallPageVisible && currentRoute == '/incoming_call' && _activeCallNumber == currentPhoneNumber) {
|
||||||
currentRoute == '/incoming_call' &&
|
print('CallService: Popping IncomingCallPage before navigating to CallPage');
|
||||||
_activeCallNumber == currentPhoneNumber) {
|
|
||||||
print(
|
|
||||||
'CallService: Popping IncomingCallPage before navigating to CallPage');
|
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
_isCallPageVisible = false;
|
_isCallPageVisible = false;
|
||||||
}
|
}
|
||||||
if (currentPhoneNumber == null) {
|
if (currentPhoneNumber == null) {
|
||||||
print(
|
print('CallService: Cannot navigate to CallPage, currentPhoneNumber is null');
|
||||||
'CallService: Cannot navigate to CallPage, currentPhoneNumber is null');
|
|
||||||
_isNavigating = false;
|
_isNavigating = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_activeCallNumber = currentPhoneNumber;
|
_activeCallNumber = currentPhoneNumber;
|
||||||
Navigator.push(
|
Navigator.pushReplacement(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
settings: const RouteSettings(name: '/call'),
|
settings: const RouteSettings(name: '/call'),
|
||||||
@ -445,13 +332,9 @@ class CallService {
|
|||||||
_isNavigating = true;
|
_isNavigating = true;
|
||||||
|
|
||||||
final currentRoute = ModalRoute.of(context)?.settings.name ?? 'unknown';
|
final currentRoute = ModalRoute.of(context)?.settings.name ?? 'unknown';
|
||||||
print(
|
print('CallService: Navigating to IncomingCallPage, Visible: $_isCallPageVisible, Current Route: $currentRoute, Active Call: $_activeCallNumber, DisplayName: $currentDisplayName');
|
||||||
'CallService: Navigating to IncomingCallPage, Visible: $_isCallPageVisible, Current Route: $currentRoute, Active Call: $_activeCallNumber, DisplayName: $currentDisplayName');
|
if (_isCallPageVisible && currentRoute == '/incoming_call' && _activeCallNumber == currentPhoneNumber) {
|
||||||
if (_isCallPageVisible &&
|
print('CallService: IncomingCallPage already visible for $_activeCallNumber, skipping navigation');
|
||||||
currentRoute == '/incoming_call' &&
|
|
||||||
_activeCallNumber == currentPhoneNumber) {
|
|
||||||
print(
|
|
||||||
'CallService: IncomingCallPage already visible for $_activeCallNumber, skipping navigation');
|
|
||||||
_isNavigating = false;
|
_isNavigating = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -461,8 +344,7 @@ class CallService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentPhoneNumber == null) {
|
if (currentPhoneNumber == null) {
|
||||||
print(
|
print('CallService: Cannot navigate to IncomingCallPage, currentPhoneNumber is null');
|
||||||
'CallService: Cannot navigate to IncomingCallPage, currentPhoneNumber is null');
|
|
||||||
_isNavigating = false;
|
_isNavigating = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -479,8 +361,7 @@ class CallService {
|
|||||||
).then((_) {
|
).then((_) {
|
||||||
_isCallPageVisible = false;
|
_isCallPageVisible = false;
|
||||||
_isNavigating = false;
|
_isNavigating = false;
|
||||||
print(
|
print('CallService: IncomingCallPage popped, _isCallPageVisible set to false');
|
||||||
'CallService: IncomingCallPage popped, _isCallPageVisible set to false');
|
|
||||||
});
|
});
|
||||||
_isCallPageVisible = true;
|
_isCallPageVisible = true;
|
||||||
}
|
}
|
||||||
@ -491,31 +372,13 @@ class CallService {
|
|||||||
print('CallService: Cannot close page, context is null');
|
print('CallService: Cannot close page, context is null');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
print('CallService: Closing call page, _isCallPageVisible: $_isCallPageVisible');
|
||||||
// Only attempt to close if a call page is actually visible
|
if (Navigator.canPop(context)) {
|
||||||
if (!_isCallPageVisible) {
|
print('CallService: Popping call page');
|
||||||
print('CallService: Call page already closed');
|
Navigator.pop(context);
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
print(
|
|
||||||
'CallService: Closing call page, _isCallPageVisible: $_isCallPageVisible, _pendingSimSwitch: ${_pendingSimSwitch != null}, _manualHangupFlag: $_manualHangupFlag');
|
|
||||||
|
|
||||||
// Use popUntil to ensure we go back to the home page
|
|
||||||
try {
|
|
||||||
Navigator.popUntil(context, (route) => route.isFirst);
|
|
||||||
_isCallPageVisible = false;
|
_isCallPageVisible = false;
|
||||||
print('CallService: Used popUntil to return to home page');
|
} else {
|
||||||
} catch (e) {
|
print('CallService: No page to pop');
|
||||||
print('CallService: Error with popUntil, trying regular pop: $e');
|
|
||||||
if (Navigator.canPop(context)) {
|
|
||||||
Navigator.pop(context);
|
|
||||||
_isCallPageVisible = false;
|
|
||||||
print('CallService: Used regular pop as fallback');
|
|
||||||
} else {
|
|
||||||
print('CallService: No page to pop, setting _isCallPageVisible to false');
|
|
||||||
_isCallPageVisible = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
_activeCallNumber = null;
|
_activeCallNumber = null;
|
||||||
}
|
}
|
||||||
@ -525,54 +388,20 @@ class CallService {
|
|||||||
required String phoneNumber,
|
required String phoneNumber,
|
||||||
String? displayName,
|
String? displayName,
|
||||||
Uint8List? thumbnail,
|
Uint8List? thumbnail,
|
||||||
}) async {
|
|
||||||
try {
|
|
||||||
// Load default SIM slot from settings
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final simSlot = prefs.getInt('default_sim_slot') ?? 0;
|
|
||||||
return await makeGsmCallWithSim(
|
|
||||||
context,
|
|
||||||
phoneNumber: phoneNumber,
|
|
||||||
displayName: displayName,
|
|
||||||
thumbnail: thumbnail,
|
|
||||||
simSlot: simSlot,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
print("CallService: Error making call: $e");
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text("Error making call: $e")),
|
|
||||||
);
|
|
||||||
return {"status": "error", "message": e.toString()};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Map<String, dynamic>> makeGsmCallWithSim(
|
|
||||||
BuildContext context, {
|
|
||||||
required String phoneNumber,
|
|
||||||
String? displayName,
|
|
||||||
Uint8List? thumbnail,
|
|
||||||
required int simSlot,
|
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
if (_activeCallNumber == phoneNumber && _isCallPageVisible) {
|
if (_activeCallNumber == phoneNumber && _isCallPageVisible) {
|
||||||
print('CallService: Call already active for $phoneNumber, skipping');
|
print('CallService: Call already active for $phoneNumber, skipping');
|
||||||
return {
|
return {"status": "already_active", "message": "Call already in progress"};
|
||||||
"status": "already_active",
|
|
||||||
"message": "Call already in progress"
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
currentPhoneNumber = phoneNumber;
|
currentPhoneNumber = phoneNumber;
|
||||||
currentDisplayName = displayName ?? phoneNumber;
|
currentDisplayName = displayName ?? phoneNumber;
|
||||||
currentThumbnail = thumbnail;
|
currentThumbnail = thumbnail;
|
||||||
currentSimSlot = simSlot; // Track the SIM slot being used
|
|
||||||
_simStateController.add(simSlot); // Notify UI of SIM change
|
|
||||||
if (displayName == null || thumbnail == null) {
|
if (displayName == null || thumbnail == null) {
|
||||||
await _fetchContactInfo(phoneNumber);
|
await _fetchContactInfo(phoneNumber);
|
||||||
}
|
}
|
||||||
print(
|
print('CallService: Making GSM call to $phoneNumber, displayName: $currentDisplayName');
|
||||||
'CallService: Making GSM call to $phoneNumber, displayName: $currentDisplayName, simSlot: $simSlot');
|
final result = await _channel.invokeMethod('makeGsmCall', {"phoneNumber": phoneNumber});
|
||||||
final result = await _channel.invokeMethod(
|
|
||||||
'makeGsmCall', {"phoneNumber": phoneNumber, "simSlot": simSlot});
|
|
||||||
print('CallService: makeGsmCall result: $result');
|
print('CallService: makeGsmCall result: $result');
|
||||||
final resultMap = Map<String, dynamic>.from(result as Map);
|
final resultMap = Map<String, dynamic>.from(result as Map);
|
||||||
if (resultMap["status"] != "calling") {
|
if (resultMap["status"] != "calling") {
|
||||||
@ -590,40 +419,17 @@ class CallService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pending SIM switch data
|
|
||||||
static Map<String, dynamic>? _pendingSimSwitch;
|
|
||||||
static bool _manualHangupFlag = false; // Track if hangup was manual
|
|
||||||
|
|
||||||
// Getter to check if there's a pending SIM switch
|
|
||||||
static bool get hasPendingSimSwitch => _pendingSimSwitch != null;
|
|
||||||
Future<Map<String, dynamic>> hangUpCall(BuildContext context) async {
|
Future<Map<String, dynamic>> hangUpCall(BuildContext context) async {
|
||||||
try {
|
try {
|
||||||
print('CallService: ========== HANGUP INITIATED ==========');
|
print('CallService: Hanging up call');
|
||||||
print('CallService: _pendingSimSwitch: ${_pendingSimSwitch != null}');
|
|
||||||
print('CallService: _manualHangupFlag: $_manualHangupFlag');
|
|
||||||
print('CallService: _isCallPageVisible: $_isCallPageVisible');
|
|
||||||
|
|
||||||
final result = await _channel.invokeMethod('hangUpCall');
|
final result = await _channel.invokeMethod('hangUpCall');
|
||||||
print('CallService: hangUpCall result: $result');
|
print('CallService: hangUpCall result: $result');
|
||||||
final resultMap = Map<String, dynamic>.from(result as Map);
|
final resultMap = Map<String, dynamic>.from(result as Map);
|
||||||
|
|
||||||
if (resultMap["status"] != "ended") {
|
if (resultMap["status"] != "ended") {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text("Failed to end call")),
|
SnackBar(content: Text("Failed to end call")),
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
// If hangup was successful, ensure call page closes after a short delay
|
|
||||||
// This is a fallback in case the native call state events don't fire properly
|
|
||||||
Future.delayed(const Duration(milliseconds: 1500), () {
|
|
||||||
if (_isCallPageVisible) {
|
|
||||||
print(
|
|
||||||
'CallService: FALLBACK - Force closing call page after hangup');
|
|
||||||
_closeCallPage();
|
|
||||||
_manualHangupFlag = false; // Reset flag
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return resultMap;
|
return resultMap;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print("CallService: Error hanging up call: $e");
|
print("CallService: Error hanging up call: $e");
|
||||||
@ -633,88 +439,4 @@ class CallService {
|
|||||||
return {"status": "error", "message": e.toString()};
|
return {"status": "error", "message": e.toString()};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Future<void> switchSimAndRedial({
|
|
||||||
required String phoneNumber,
|
|
||||||
required String displayName,
|
|
||||||
required int simSlot,
|
|
||||||
Uint8List? thumbnail,
|
|
||||||
}) async {
|
|
||||||
try {
|
|
||||||
print(
|
|
||||||
'CallService: Starting SIM switch to slot $simSlot for $phoneNumber');
|
|
||||||
|
|
||||||
// Store the redial information for after hangup
|
|
||||||
_pendingSimSwitch = {
|
|
||||||
'phoneNumber': phoneNumber,
|
|
||||||
'displayName': displayName,
|
|
||||||
'simSlot': simSlot,
|
|
||||||
'thumbnail': thumbnail,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Hang up the current call - this will trigger the disconnected state
|
|
||||||
await _channel.invokeMethod('hangUpCall');
|
|
||||||
print(
|
|
||||||
'CallService: Hangup initiated, waiting for disconnection to complete redial');
|
|
||||||
} catch (e) {
|
|
||||||
print('CallService: Error during SIM switch: $e');
|
|
||||||
_pendingSimSwitch = null;
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handlePendingSimSwitch() async {
|
|
||||||
if (_pendingSimSwitch == null) return;
|
|
||||||
|
|
||||||
final switchData = _pendingSimSwitch!;
|
|
||||||
_pendingSimSwitch = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
print('CallService: Executing pending SIM switch redial');
|
|
||||||
|
|
||||||
// Wait a moment to ensure the previous call is fully disconnected
|
|
||||||
await Future.delayed(const Duration(
|
|
||||||
milliseconds: 1000)); // Store the new call info for the redial
|
|
||||||
currentPhoneNumber = switchData['phoneNumber'];
|
|
||||||
currentDisplayName = switchData['displayName'];
|
|
||||||
currentThumbnail = switchData['thumbnail'];
|
|
||||||
currentSimSlot = switchData['simSlot']; // Track the new SIM slot
|
|
||||||
_simStateController.add(switchData['simSlot']); // Notify UI of SIM change
|
|
||||||
|
|
||||||
// Make the new call with the selected SIM
|
|
||||||
final result = await _channel.invokeMethod('makeGsmCall', {
|
|
||||||
'phoneNumber': switchData['phoneNumber'],
|
|
||||||
'simSlot': switchData['simSlot'],
|
|
||||||
});
|
|
||||||
|
|
||||||
print('CallService: SIM switch redial result: $result');
|
|
||||||
|
|
||||||
// Show success feedback
|
|
||||||
final context = navigatorKey.currentContext;
|
|
||||||
if (context != null) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'Switched to SIM ${switchData['simSlot'] + 1} and redialing...'),
|
|
||||||
backgroundColor: Colors.green,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print('CallService: Error during SIM switch redial: $e');
|
|
||||||
|
|
||||||
// Show error feedback and close the call page
|
|
||||||
final context = navigatorKey.currentContext;
|
|
||||||
if (context != null) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Failed to redial with new SIM: $e'),
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
// Close the call page since redial failed
|
|
||||||
_closeCallPage();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,204 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:sim_data_new/sim_data.dart';
|
|
||||||
|
|
||||||
class SimSelectionDialog extends StatefulWidget {
|
|
||||||
final String phoneNumber;
|
|
||||||
final String displayName;
|
|
||||||
final Function(int simSlot) onSimSelected;
|
|
||||||
|
|
||||||
const SimSelectionDialog({
|
|
||||||
super.key,
|
|
||||||
required this.phoneNumber,
|
|
||||||
required this.displayName,
|
|
||||||
required this.onSimSelected,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
_SimSelectionDialogState createState() => _SimSelectionDialogState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SimSelectionDialogState extends State<SimSelectionDialog> {
|
|
||||||
SimData? _simData;
|
|
||||||
bool _isLoading = true;
|
|
||||||
String? _error;
|
|
||||||
int? _selectedSimSlot;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_loadSimCards();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _loadSimCards() async {
|
|
||||||
try {
|
|
||||||
final simData = await SimDataPlugin.getSimData();
|
|
||||||
setState(() {
|
|
||||||
_simData = simData;
|
|
||||||
_isLoading = false;
|
|
||||||
_error = null;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
setState(() {
|
|
||||||
_isLoading = false;
|
|
||||||
_error = e.toString();
|
|
||||||
});
|
|
||||||
print('Error loading SIM cards: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return AlertDialog(
|
|
||||||
backgroundColor: Colors.grey[900],
|
|
||||||
title: const Text(
|
|
||||||
'Select SIM for Call',
|
|
||||||
style: TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
content: _buildContent(),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
child: const Text(
|
|
||||||
'Cancel',
|
|
||||||
style: TextStyle(color: Colors.grey),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (_selectedSimSlot != null)
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
widget.onSimSelected(_selectedSimSlot!);
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
},
|
|
||||||
child: const Text(
|
|
||||||
'Switch SIM',
|
|
||||||
style: TextStyle(color: Colors.blue),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildContent() {
|
|
||||||
if (_isLoading) {
|
|
||||||
return const SizedBox(
|
|
||||||
height: 100,
|
|
||||||
child: Center(
|
|
||||||
child: CircularProgressIndicator(color: Colors.blue),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_error != null) {
|
|
||||||
return _buildErrorContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_simData?.cards.isEmpty ?? true) {
|
|
||||||
return _buildFallbackContent();
|
|
||||||
}
|
|
||||||
|
|
||||||
return _buildSimList();
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildErrorContent() {
|
|
||||||
return Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
Icons.error_outline,
|
|
||||||
color: Colors.red,
|
|
||||||
size: 48,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Error loading SIM cards',
|
|
||||||
style: const TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
_error!,
|
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: _loadSimCards,
|
|
||||||
child: const Text('Retry'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildFallbackContent() {
|
|
||||||
return Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
_buildSimTile('SIM 1', 'Slot 0', 0),
|
|
||||||
_buildSimTile('SIM 2', 'Slot 1', 1),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSimList() {
|
|
||||||
return Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: _simData!.cards.map((card) {
|
|
||||||
final index = _simData!.cards.indexOf(card);
|
|
||||||
return _buildSimTile(
|
|
||||||
_getSimDisplayName(card, index),
|
|
||||||
_getSimSubtitle(card),
|
|
||||||
card.slotIndex,
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSimTile(String title, String subtitle, int slotIndex) {
|
|
||||||
return RadioListTile<int>(
|
|
||||||
title: Text(
|
|
||||||
title,
|
|
||||||
style: const TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
subtitle,
|
|
||||||
style: const TextStyle(color: Colors.grey),
|
|
||||||
),
|
|
||||||
value: slotIndex,
|
|
||||||
groupValue: _selectedSimSlot,
|
|
||||||
onChanged: (value) {
|
|
||||||
setState(() {
|
|
||||||
_selectedSimSlot = value;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
activeColor: Colors.blue,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _getSimDisplayName(dynamic card, int index) {
|
|
||||||
if (card.displayName != null && card.displayName.isNotEmpty) {
|
|
||||||
return card.displayName;
|
|
||||||
}
|
|
||||||
if (card.carrierName != null && card.carrierName.isNotEmpty) {
|
|
||||||
return card.carrierName;
|
|
||||||
}
|
|
||||||
return 'SIM ${index + 1}';
|
|
||||||
}
|
|
||||||
|
|
||||||
String _getSimSubtitle(dynamic card) {
|
|
||||||
List<String> subtitleParts = [];
|
|
||||||
|
|
||||||
if (card.phoneNumber != null && card.phoneNumber.isNotEmpty) {
|
|
||||||
subtitleParts.add(card.phoneNumber);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (card.carrierName != null &&
|
|
||||||
card.carrierName.isNotEmpty &&
|
|
||||||
(card.displayName == null || card.displayName.isEmpty)) {
|
|
||||||
subtitleParts.add(card.carrierName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subtitleParts.isEmpty) {
|
|
||||||
subtitleParts.add('Slot ${card.slotIndex}');
|
|
||||||
}
|
|
||||||
|
|
||||||
return subtitleParts.join(' • ');
|
|
||||||
}
|
|
||||||
}
|
|
@ -4,9 +4,7 @@ import 'package:flutter_contacts/flutter_contacts.dart';
|
|||||||
import 'package:dialer/domain/services/call_service.dart';
|
import 'package:dialer/domain/services/call_service.dart';
|
||||||
import 'package:dialer/domain/services/obfuscate_service.dart';
|
import 'package:dialer/domain/services/obfuscate_service.dart';
|
||||||
import 'package:dialer/presentation/common/widgets/username_color_generator.dart';
|
import 'package:dialer/presentation/common/widgets/username_color_generator.dart';
|
||||||
import 'package:dialer/presentation/common/widgets/sim_selection_dialog.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:sim_data_new/sim_data.dart';
|
|
||||||
|
|
||||||
class CallPage extends StatefulWidget {
|
class CallPage extends StatefulWidget {
|
||||||
final String displayName;
|
final String displayName;
|
||||||
@ -37,57 +35,15 @@ class _CallPageState extends State<CallPage> {
|
|||||||
String _callStatus = "Calling...";
|
String _callStatus = "Calling...";
|
||||||
StreamSubscription<String>? _callStateSubscription;
|
StreamSubscription<String>? _callStateSubscription;
|
||||||
StreamSubscription<Map<String, dynamic>>? _audioStateSubscription;
|
StreamSubscription<Map<String, dynamic>>? _audioStateSubscription;
|
||||||
StreamSubscription<int?>? _simStateSubscription;
|
|
||||||
bool _isCallActive = true; // Track if call is still active
|
|
||||||
String? _simName; // Human-readable SIM card name
|
|
||||||
|
|
||||||
bool get isNumberUnknown => widget.displayName == widget.phoneNumber;
|
bool get isNumberUnknown => widget.displayName == widget.phoneNumber;
|
||||||
|
|
||||||
// Fetch and update human-readable SIM name based on slot
|
|
||||||
Future<void> _updateSimName(int? simSlot) async {
|
|
||||||
if (!mounted) return;
|
|
||||||
if (simSlot != null) {
|
|
||||||
try {
|
|
||||||
final simData = await SimDataPlugin.getSimData();
|
|
||||||
// Find the SIM card matching the slot index, if any
|
|
||||||
dynamic card;
|
|
||||||
for (var c in simData.cards) {
|
|
||||||
if (c.slotIndex == simSlot) {
|
|
||||||
card = c;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
String name;
|
|
||||||
if (card != null && card.displayName.isNotEmpty) {
|
|
||||||
name = card.displayName;
|
|
||||||
} else if (card != null && card.carrierName.isNotEmpty) {
|
|
||||||
name = card.carrierName;
|
|
||||||
} else {
|
|
||||||
name = 'SIM ${simSlot + 1}';
|
|
||||||
}
|
|
||||||
setState(() {
|
|
||||||
_simName = name;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
setState(() {
|
|
||||||
_simName = 'SIM ${simSlot + 1}';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setState(() {
|
|
||||||
_simName = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_checkInitialCallState();
|
_checkInitialCallState();
|
||||||
_listenToCallState();
|
_listenToCallState();
|
||||||
_listenToAudioState();
|
_listenToAudioState();
|
||||||
_listenToSimState();
|
|
||||||
_updateSimName(CallService.getCurrentSimSlot); // Initial SIM name
|
|
||||||
_setInitialAudioState();
|
_setInitialAudioState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,7 +52,6 @@ class _CallPageState extends State<CallPage> {
|
|||||||
_callTimer?.cancel();
|
_callTimer?.cancel();
|
||||||
_callStateSubscription?.cancel();
|
_callStateSubscription?.cancel();
|
||||||
_audioStateSubscription?.cancel();
|
_audioStateSubscription?.cancel();
|
||||||
_simStateSubscription?.cancel();
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -114,19 +69,10 @@ class _CallPageState extends State<CallPage> {
|
|||||||
try {
|
try {
|
||||||
final state = await _callService.getCallState();
|
final state = await _callService.getCallState();
|
||||||
print('CallPage: Initial call state: $state');
|
print('CallPage: Initial call state: $state');
|
||||||
if (mounted) {
|
if (mounted && state == "active") {
|
||||||
setState(() {
|
setState(() {
|
||||||
if (state == "active") {
|
_callStatus = "00:00";
|
||||||
_callStatus = "00:00";
|
_startCallTimer();
|
||||||
_isCallActive = true;
|
|
||||||
_startCallTimer();
|
|
||||||
} else if (state == "disconnected" || state == "disconnecting") {
|
|
||||||
_callStatus = "Call Ended";
|
|
||||||
_isCallActive = false;
|
|
||||||
} else {
|
|
||||||
_callStatus = "Calling...";
|
|
||||||
_isCallActive = true;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -141,16 +87,12 @@ class _CallPageState extends State<CallPage> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
if (state == "active") {
|
if (state == "active") {
|
||||||
_callStatus = "00:00";
|
_callStatus = "00:00";
|
||||||
_isCallActive = true;
|
|
||||||
_startCallTimer();
|
_startCallTimer();
|
||||||
} else if (state == "disconnected" || state == "disconnecting") {
|
} else if (state == "disconnected" || state == "disconnecting") {
|
||||||
_callTimer?.cancel();
|
_callTimer?.cancel();
|
||||||
_callStatus = "Call Ended";
|
_callStatus = "Call Ended";
|
||||||
_isCallActive = false;
|
|
||||||
// Let CallService handle navigation - don't navigate from here
|
|
||||||
} else {
|
} else {
|
||||||
_callStatus = "Calling...";
|
_callStatus = "Calling...";
|
||||||
_isCallActive = true;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -168,12 +110,6 @@ class _CallPageState extends State<CallPage> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _listenToSimState() {
|
|
||||||
_simStateSubscription = _callService.simStateStream.listen((simSlot) {
|
|
||||||
_updateSimName(simSlot);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _startCallTimer() {
|
void _startCallTimer() {
|
||||||
_callTimer?.cancel();
|
_callTimer?.cancel();
|
||||||
_callTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
_callTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||||
@ -244,7 +180,7 @@ class _CallPageState extends State<CallPage> {
|
|||||||
final result =
|
final result =
|
||||||
await _callService.speakerCall(context, speaker: !isSpeaker);
|
await _callService.speakerCall(context, speaker: !isSpeaker);
|
||||||
print('CallPage: Speaker call result: $result');
|
print('CallPage: Speaker call result: $result');
|
||||||
if (mounted && result['status'] != 'success') {
|
if (result['status'] != 'success') {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('Failed to toggle speaker: ${result['message']}')),
|
content: Text('Failed to toggle speaker: ${result['message']}')),
|
||||||
@ -266,76 +202,17 @@ class _CallPageState extends State<CallPage> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showSimSelectionDialog() {
|
void _toggleIcingProtocol() {
|
||||||
showDialog(
|
setState(() {
|
||||||
context: context,
|
icingProtocolOk = !icingProtocolOk;
|
||||||
builder: (BuildContext context) {
|
});
|
||||||
return SimSelectionDialog(
|
|
||||||
phoneNumber: widget.phoneNumber,
|
|
||||||
displayName: widget.displayName,
|
|
||||||
onSimSelected: _switchToNewSim,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _switchToNewSim(int simSlot) async {
|
|
||||||
try {
|
|
||||||
print(
|
|
||||||
'CallPage: Initiating SIM switch to slot $simSlot for ${widget.phoneNumber}');
|
|
||||||
|
|
||||||
// Use the CallService to handle the SIM switch logic
|
|
||||||
await _callService.switchSimAndRedial(
|
|
||||||
phoneNumber: widget.phoneNumber,
|
|
||||||
displayName: widget.displayName,
|
|
||||||
simSlot: simSlot,
|
|
||||||
thumbnail: widget.thumbnail,
|
|
||||||
);
|
|
||||||
|
|
||||||
print('CallPage: SIM switch initiated successfully');
|
|
||||||
} catch (e) {
|
|
||||||
print('CallPage: Error initiating SIM switch: $e');
|
|
||||||
|
|
||||||
// Show error feedback if widget is still mounted
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text('Error switching SIM: $e'),
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _hangUp() async {
|
void _hangUp() async {
|
||||||
// Don't try to hang up if call is already ended
|
|
||||||
if (!_isCallActive) {
|
|
||||||
print('CallPage: Ignoring hangup - call already ended');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
print(
|
print('CallPage: Initiating hangUp');
|
||||||
'CallPage: Initiating manual hangUp - canceling any pending SIM switch');
|
|
||||||
|
|
||||||
// Immediately mark call as inactive to prevent further interactions
|
|
||||||
setState(() {
|
|
||||||
_isCallActive = false;
|
|
||||||
_callStatus = "Ending Call...";
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cancel any pending SIM switch since user is manually hanging up
|
|
||||||
_callService.cancelPendingSimSwitch();
|
|
||||||
|
|
||||||
final result = await _callService.hangUpCall(context);
|
final result = await _callService.hangUpCall(context);
|
||||||
print('CallPage: Hang up result: $result');
|
print('CallPage: Hang up result: $result');
|
||||||
|
|
||||||
// If the page is still visible after hangup, try to close it
|
|
||||||
if (mounted && ModalRoute.of(context)?.isCurrent == true) {
|
|
||||||
print('CallPage: Still visible after hangup, navigating back');
|
|
||||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('CallPage: Error hanging up: $e');
|
print('CallPage: Error hanging up: $e');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@ -351,17 +228,15 @@ class _CallPageState extends State<CallPage> {
|
|||||||
final newContact = Contact()..phones = [Phone(widget.phoneNumber)];
|
final newContact = Contact()..phones = [Phone(widget.phoneNumber)];
|
||||||
final updatedContact =
|
final updatedContact =
|
||||||
await FlutterContacts.openExternalInsert(newContact);
|
await FlutterContacts.openExternalInsert(newContact);
|
||||||
if (mounted && updatedContact != null) {
|
if (updatedContact != null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Contact added successfully!')),
|
SnackBar(content: Text('Contact added successfully!')),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (mounted) {
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
SnackBar(content: Text('Permission denied for contacts')),
|
||||||
SnackBar(content: Text('Permission denied for contacts')),
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -373,24 +248,14 @@ class _CallPageState extends State<CallPage> {
|
|||||||
|
|
||||||
print(
|
print(
|
||||||
'CallPage: Building UI, _callStatus: $_callStatus, route: ${ModalRoute.of(context)?.settings.name ?? "unknown"}');
|
'CallPage: Building UI, _callStatus: $_callStatus, route: ${ModalRoute.of(context)?.settings.name ?? "unknown"}');
|
||||||
|
|
||||||
// If call is disconnected and we're not actively navigating, force navigation
|
|
||||||
if ((_callStatus == "Call Ended" || !_isCallActive) && mounted) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
if (mounted && ModalRoute.of(context)?.isCurrent == true) {
|
|
||||||
print('CallPage: Call ended, forcing navigation back to home');
|
|
||||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop:
|
canPop: _callStatus == "Call Ended",
|
||||||
true, // Always allow popping - CallService manages when it's appropriate
|
|
||||||
onPopInvoked: (didPop) {
|
onPopInvoked: (didPop) {
|
||||||
print(
|
if (!didPop) {
|
||||||
'CallPage: PopScope onPopInvoked - didPop: $didPop, _isCallActive: $_isCallActive, _callStatus: $_callStatus');
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
// No longer prevent popping during active calls - CallService handles this
|
SnackBar(content: Text('Cannot leave during an active call')),
|
||||||
|
);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
body: Container(
|
body: Container(
|
||||||
@ -455,30 +320,6 @@ class _CallPageState extends State<CallPage> {
|
|||||||
color: Colors.white70,
|
color: Colors.white70,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Show SIM information if a SIM slot has been set
|
|
||||||
if (_simName != null)
|
|
||||||
Container(
|
|
||||||
margin: const EdgeInsets.only(top: 4.0),
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 8.0, vertical: 2.0),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.blue.withOpacity(0.2),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(
|
|
||||||
color: Colors.blue.withOpacity(0.5),
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
// Show human-readable SIM name plus slot number
|
|
||||||
'$_simName (SIM ${CallService.getCurrentSimSlot! + 1})',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: statusFontSize - 2,
|
|
||||||
color: Colors.lightBlueAccent,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -669,14 +510,10 @@ class _CallPageState extends State<CallPage> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: _isCallActive
|
onPressed: () {},
|
||||||
? _showSimSelectionDialog
|
icon: const Icon(
|
||||||
: null,
|
|
||||||
icon: Icon(
|
|
||||||
Icons.sim_card,
|
Icons.sim_card,
|
||||||
color: _isCallActive
|
color: Colors.white,
|
||||||
? Colors.white
|
|
||||||
: Colors.grey,
|
|
||||||
size: 32,
|
size: 32,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -702,15 +539,15 @@ class _CallPageState extends State<CallPage> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 16.0),
|
padding: const EdgeInsets.only(bottom: 16.0),
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: _isCallActive ? _hangUp : null,
|
onTap: _hangUp,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: _isCallActive ? Colors.red : Colors.grey,
|
color: Colors.red,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: const Icon(
|
||||||
_isCallActive ? Icons.call_end : Icons.call_end,
|
Icons.call_end,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
size: 32,
|
size: 32,
|
||||||
),
|
),
|
||||||
|
@ -19,7 +19,6 @@ class History {
|
|||||||
final String callType; // 'incoming' or 'outgoing'
|
final String callType; // 'incoming' or 'outgoing'
|
||||||
final String callStatus; // 'missed' or 'answered'
|
final String callStatus; // 'missed' or 'answered'
|
||||||
final int attempts;
|
final int attempts;
|
||||||
final String? simName; // Name of the SIM used for the call
|
|
||||||
|
|
||||||
History(
|
History(
|
||||||
this.contact,
|
this.contact,
|
||||||
@ -27,7 +26,6 @@ class History {
|
|||||||
this.callType,
|
this.callType,
|
||||||
this.callStatus,
|
this.callStatus,
|
||||||
this.attempts,
|
this.attempts,
|
||||||
this.simName,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,90 +33,29 @@ class HistoryPage extends StatefulWidget {
|
|||||||
const HistoryPage({Key? key}) : super(key: key);
|
const HistoryPage({Key? key}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
HistoryPageState createState() => HistoryPageState();
|
_HistoryPageState createState() => _HistoryPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class HistoryPageState extends State<HistoryPage>
|
class _HistoryPageState extends State<HistoryPage>
|
||||||
with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
|
||||||
// Static histories list shared across all instances
|
List<History> histories = [];
|
||||||
static List<History> _globalHistories = [];
|
bool loading = true;
|
||||||
|
|
||||||
// Getter to access the global histories list
|
|
||||||
List<History> get histories => _globalHistories;
|
|
||||||
|
|
||||||
bool _isInitialLoad = true;
|
|
||||||
int? _expandedIndex;
|
int? _expandedIndex;
|
||||||
final ObfuscateService _obfuscateService = ObfuscateService();
|
final ObfuscateService _obfuscateService = ObfuscateService();
|
||||||
final CallService _callService = CallService();
|
final CallService _callService = CallService();
|
||||||
Timer? _debounceTimer;
|
|
||||||
|
|
||||||
// Create a MethodChannel instance.
|
// Create a MethodChannel instance.
|
||||||
static const MethodChannel _channel = MethodChannel('com.example.calllog');
|
static const MethodChannel _channel = MethodChannel('com.example.calllog');
|
||||||
|
|
||||||
// Static reference to the current instance for call-end notifications
|
|
||||||
static HistoryPageState? _currentInstance;
|
|
||||||
|
|
||||||
// Global flag to track if history has been loaded once across all instances
|
|
||||||
static bool _hasLoadedInitialHistory = false;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool get wantKeepAlive => true; // Preserve state when switching pages
|
bool get wantKeepAlive => true; // Preserve state when switching pages
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
WidgetsBinding.instance.addObserver(this);
|
|
||||||
_currentInstance = this; // Register this instance
|
|
||||||
|
|
||||||
// Only load initial data if it hasn't been loaded before
|
|
||||||
if (!_hasLoadedInitialHistory) {
|
|
||||||
_buildHistories();
|
|
||||||
} else {
|
|
||||||
// If history was already loaded, just mark this instance as not doing initial load
|
|
||||||
_isInitialLoad = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Public method to trigger reload when page becomes visible
|
|
||||||
void triggerReload() {
|
|
||||||
// Disabled automatic reloading - only load once and add new entries via addNewCallToHistory
|
|
||||||
print("HistoryPage: triggerReload called but disabled to prevent full reload");
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_debounceTimer?.cancel();
|
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
|
||||||
if (_currentInstance == this) {
|
|
||||||
_currentInstance = null; // Unregister this instance
|
|
||||||
}
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Static method to add a new call to the history list
|
|
||||||
static void addNewCallToHistory() {
|
|
||||||
_currentInstance?._addLatestCallToHistory();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Notify all instances to refresh UI when history changes
|
|
||||||
static void _notifyHistoryChanged() {
|
|
||||||
_currentInstance?.setState(() {
|
|
||||||
// Trigger UI rebuild for the current instance
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
||||||
super.didChangeAppLifecycleState(state);
|
|
||||||
// Disabled automatic reloading when app comes to foreground
|
|
||||||
print("HistoryPage: didChangeAppLifecycleState called but disabled to prevent full reload");
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
// didChangeDependencies is not reliable for TabBarView changes
|
if (loading && histories.isEmpty) {
|
||||||
// We'll use a different approach with RouteAware or manual detection
|
_buildHistories();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _refreshContacts() async {
|
Future<void> _refreshContacts() async {
|
||||||
@ -179,22 +116,6 @@ class HistoryPageState extends State<HistoryPage>
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper: Get SIM name from subscription ID
|
|
||||||
String? _getSimNameFromSubscriptionId(int? subscriptionId) {
|
|
||||||
if (subscriptionId == null) return null;
|
|
||||||
|
|
||||||
// Map subscription IDs to SIM names
|
|
||||||
// These values might need to be adjusted based on your device
|
|
||||||
switch (subscriptionId) {
|
|
||||||
case 0:
|
|
||||||
return "SIM 1";
|
|
||||||
case 1:
|
|
||||||
return "SIM 2";
|
|
||||||
default:
|
|
||||||
return "SIM ${subscriptionId + 1}";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Request permission for reading call logs.
|
/// Request permission for reading call logs.
|
||||||
Future<bool> _requestCallLogPermission() async {
|
Future<bool> _requestCallLogPermission() async {
|
||||||
var status = await Permission.phone.status;
|
var status = await Permission.phone.status;
|
||||||
@ -209,12 +130,10 @@ class HistoryPageState extends State<HistoryPage>
|
|||||||
// Request permission.
|
// Request permission.
|
||||||
bool hasPermission = await _requestCallLogPermission();
|
bool hasPermission = await _requestCallLogPermission();
|
||||||
if (!hasPermission) {
|
if (!hasPermission) {
|
||||||
if (mounted) {
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
const SnackBar(content: Text('Call log permission not granted')));
|
||||||
const SnackBar(content: Text('Call log permission not granted')));
|
|
||||||
}
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isInitialLoad = false;
|
loading = false;
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -284,22 +203,8 @@ class HistoryPageState extends State<HistoryPage>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract SIM information if available
|
|
||||||
String? simName;
|
|
||||||
if (entry.containsKey('sim_name') && entry['sim_name'] != null) {
|
|
||||||
simName = entry['sim_name'] as String;
|
|
||||||
print("DEBUG: Found sim_name: $simName for number: $number"); // Debug print
|
|
||||||
} else if (entry.containsKey('subscription_id')) {
|
|
||||||
final subId = entry['subscription_id'];
|
|
||||||
print("DEBUG: Found subscription_id: $subId for number: $number, but no sim_name"); // Debug print
|
|
||||||
simName = _getSimNameFromSubscriptionId(subId);
|
|
||||||
print("DEBUG: Mapped to SIM name: $simName"); // Debug print
|
|
||||||
} else {
|
|
||||||
print("DEBUG: No SIM info found for number: $number"); // Debug print
|
|
||||||
}
|
|
||||||
|
|
||||||
callHistories
|
callHistories
|
||||||
.add(History(matchedContact, callDate, callType, callStatus, 1, simName));
|
.add(History(matchedContact, callDate, callType, callStatus, 1));
|
||||||
// Yield every 10 iterations to avoid blocking the UI.
|
// Yield every 10 iterations to avoid blocking the UI.
|
||||||
if (i % 10 == 0) await Future.delayed(Duration(milliseconds: 1));
|
if (i % 10 == 0) await Future.delayed(Duration(milliseconds: 1));
|
||||||
}
|
}
|
||||||
@ -307,121 +212,10 @@ class HistoryPageState extends State<HistoryPage>
|
|||||||
// Sort histories by most recent.
|
// Sort histories by most recent.
|
||||||
callHistories.sort((a, b) => b.date.compareTo(a.date));
|
callHistories.sort((a, b) => b.date.compareTo(a.date));
|
||||||
|
|
||||||
if (mounted) {
|
setState(() {
|
||||||
setState(() {
|
histories = callHistories;
|
||||||
_globalHistories = callHistories;
|
loading = false;
|
||||||
_isInitialLoad = false;
|
});
|
||||||
_hasLoadedInitialHistory = true; // Mark that history has been loaded once
|
|
||||||
});
|
|
||||||
// Notify other instances about the initial load
|
|
||||||
_notifyHistoryChanged();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add the latest call log entry to the history list
|
|
||||||
Future<void> _addLatestCallToHistory() async {
|
|
||||||
try {
|
|
||||||
// Get the latest call log entry
|
|
||||||
final dynamic rawEntry = await _channel.invokeMethod('getLatestCallLog');
|
|
||||||
|
|
||||||
if (rawEntry == null) {
|
|
||||||
print("No latest call log entry found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert to proper type - handle the method channel result properly
|
|
||||||
final Map<String, dynamic> latestEntry = Map<String, dynamic>.from(
|
|
||||||
(rawEntry as Map<Object?, Object?>).cast<String, dynamic>()
|
|
||||||
);
|
|
||||||
|
|
||||||
final String number = latestEntry['number'] ?? '';
|
|
||||||
if (number.isEmpty) return;
|
|
||||||
|
|
||||||
// Ensure contacts are loaded
|
|
||||||
final contactState = ContactState.of(context);
|
|
||||||
if (contactState.loading) {
|
|
||||||
await Future.doWhile(() async {
|
|
||||||
await Future.delayed(const Duration(milliseconds: 100));
|
|
||||||
return contactState.loading;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
List<Contact> contacts = contactState.contacts;
|
|
||||||
|
|
||||||
// Convert timestamp to DateTime
|
|
||||||
DateTime callDate = DateTime.fromMillisecondsSinceEpoch(latestEntry['date'] ?? 0);
|
|
||||||
|
|
||||||
int typeInt = latestEntry['type'] ?? 0;
|
|
||||||
int duration = latestEntry['duration'] ?? 0;
|
|
||||||
String callType;
|
|
||||||
String callStatus;
|
|
||||||
|
|
||||||
// Map integer values to call type/status
|
|
||||||
switch (typeInt) {
|
|
||||||
case 1:
|
|
||||||
callType = "incoming";
|
|
||||||
callStatus = (duration == 0) ? "missed" : "answered";
|
|
||||||
break;
|
|
||||||
case 2:
|
|
||||||
callType = "outgoing";
|
|
||||||
callStatus = "answered";
|
|
||||||
break;
|
|
||||||
case 3:
|
|
||||||
callType = "incoming";
|
|
||||||
callStatus = "missed";
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
callType = "unknown";
|
|
||||||
callStatus = "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to find a matching contact
|
|
||||||
Contact? matchedContact = findContactForNumber(number, contacts);
|
|
||||||
if (matchedContact == null) {
|
|
||||||
// Create a dummy contact if not found
|
|
||||||
matchedContact = Contact(
|
|
||||||
id: "dummy-$number",
|
|
||||||
displayName: number,
|
|
||||||
phones: [Phone(number)],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract SIM information if available
|
|
||||||
String? simName;
|
|
||||||
if (latestEntry.containsKey('sim_name') && latestEntry['sim_name'] != null) {
|
|
||||||
simName = latestEntry['sim_name'] as String;
|
|
||||||
print("DEBUG: Found sim_name: $simName for number: $number");
|
|
||||||
} else if (latestEntry.containsKey('subscription_id')) {
|
|
||||||
final subId = latestEntry['subscription_id'];
|
|
||||||
print("DEBUG: Found subscription_id: $subId for number: $number, but no sim_name");
|
|
||||||
simName = _getSimNameFromSubscriptionId(subId);
|
|
||||||
print("DEBUG: Mapped to SIM name: $simName");
|
|
||||||
} else {
|
|
||||||
print("DEBUG: No SIM info found for number: $number");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new history entry
|
|
||||||
History newHistory = History(matchedContact, callDate, callType, callStatus, 1, simName);
|
|
||||||
|
|
||||||
// Check if this call is already in the list (avoid duplicates)
|
|
||||||
bool alreadyExists = _globalHistories.any((history) =>
|
|
||||||
history.contact.phones.isNotEmpty &&
|
|
||||||
sanitizeNumber(history.contact.phones.first.number) == sanitizeNumber(number) &&
|
|
||||||
history.date.difference(callDate).abs().inSeconds < 5); // Within 5 seconds
|
|
||||||
|
|
||||||
if (!alreadyExists && mounted) {
|
|
||||||
setState(() {
|
|
||||||
// Insert at the beginning since it's the most recent
|
|
||||||
_globalHistories.insert(0, newHistory);
|
|
||||||
});
|
|
||||||
// Notify other instances about the change
|
|
||||||
_notifyHistoryChanged();
|
|
||||||
print("Added new call to history: $number at $callDate");
|
|
||||||
} else {
|
|
||||||
print("Call already exists in history or widget unmounted");
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
print("Error adding latest call to history: $e");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
List _buildGroupedList(List<History> historyList) {
|
List _buildGroupedList(List<History> historyList) {
|
||||||
@ -489,9 +283,9 @@ class HistoryPageState extends State<HistoryPage>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context); // required due to AutomaticKeepAliveClientMixin
|
super.build(context); // required due to AutomaticKeepAliveClientMixin
|
||||||
|
final contactState = ContactState.of(context);
|
||||||
|
|
||||||
// Show loading only on initial load and if no data is available yet
|
if (loading || contactState.loading) {
|
||||||
if (_isInitialLoad && histories.isEmpty) {
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
body: const Center(child: CircularProgressIndicator()),
|
body: const Center(child: CircularProgressIndicator()),
|
||||||
@ -619,22 +413,9 @@ class HistoryPageState extends State<HistoryPage>
|
|||||||
_obfuscateService.obfuscateData(contact.displayName),
|
_obfuscateService.obfuscateData(contact.displayName),
|
||||||
style: const TextStyle(color: Colors.white),
|
style: const TextStyle(color: Colors.white),
|
||||||
),
|
),
|
||||||
subtitle: Column(
|
subtitle: Text(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
DateFormat('MMM dd, hh:mm a').format(history.date),
|
||||||
children: [
|
style: const TextStyle(color: Colors.grey),
|
||||||
Text(
|
|
||||||
DateFormat('MMM dd, hh:mm a').format(history.date),
|
|
||||||
style: const TextStyle(color: Colors.grey),
|
|
||||||
),
|
|
||||||
if (history.simName != null)
|
|
||||||
Text(
|
|
||||||
history.simName!,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.blue,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
trailing: Row(
|
trailing: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@ -844,11 +625,6 @@ class CallDetailsPage extends StatelessWidget {
|
|||||||
label: 'Attempts:',
|
label: 'Attempts:',
|
||||||
value: '${history.attempts}',
|
value: '${history.attempts}',
|
||||||
),
|
),
|
||||||
if (history.simName != null)
|
|
||||||
DetailRow(
|
|
||||||
label: 'SIM Used:',
|
|
||||||
value: history.simName!,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
if (contact.phones.isNotEmpty)
|
if (contact.phones.isNotEmpty)
|
||||||
DetailRow(
|
DetailRow(
|
||||||
|
@ -19,7 +19,6 @@ class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateM
|
|||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
late SearchController _searchBarController;
|
late SearchController _searchBarController;
|
||||||
String _rawSearchInput = '';
|
String _rawSearchInput = '';
|
||||||
final GlobalKey<HistoryPageState> _historyPageKey = GlobalKey<HistoryPageState>();
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -94,10 +93,6 @@ class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateM
|
|||||||
|
|
||||||
void _handleTabIndex() {
|
void _handleTabIndex() {
|
||||||
setState(() {});
|
setState(() {});
|
||||||
// Trigger history page reload when switching to history tab (index 1)
|
|
||||||
if (_tabController.index == 1) {
|
|
||||||
_historyPageKey.currentState?.triggerReload();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _toggleFavorite(Contact contact) async {
|
void _toggleFavorite(Contact contact) async {
|
||||||
@ -275,11 +270,11 @@ class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateM
|
|||||||
children: [
|
children: [
|
||||||
TabBarView(
|
TabBarView(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
children: [
|
children: const [
|
||||||
const FavoritesPage(),
|
FavoritesPage(),
|
||||||
HistoryPage(key: _historyPageKey),
|
HistoryPage(),
|
||||||
const ContactPage(),
|
ContactPage(),
|
||||||
const VoicemailPage(),
|
VoicemailPage(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
|
@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:dialer/presentation/features/settings/call/settings_call.dart';
|
import 'package:dialer/presentation/features/settings/call/settings_call.dart';
|
||||||
import 'package:dialer/presentation/features/settings/cryptography/key_management.dart';
|
import 'package:dialer/presentation/features/settings/cryptography/key_management.dart';
|
||||||
import 'package:dialer/presentation/features/settings/blocked/settings_blocked.dart';
|
import 'package:dialer/presentation/features/settings/blocked/settings_blocked.dart';
|
||||||
import 'package:dialer/presentation/features/settings/sim/settings_sim.dart';
|
|
||||||
|
|
||||||
class SettingsPage extends StatelessWidget {
|
class SettingsPage extends StatelessWidget {
|
||||||
const SettingsPage({super.key});
|
const SettingsPage({super.key});
|
||||||
@ -27,12 +26,6 @@ class SettingsPage extends StatelessWidget {
|
|||||||
MaterialPageRoute(builder: (context) => const BlockedNumbersPage()),
|
MaterialPageRoute(builder: (context) => const BlockedNumbersPage()),
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'Default SIM':
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(builder: (context) => const SettingsSimPage()),
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
// Add more cases for other settings pages
|
// Add more cases for other settings pages
|
||||||
default:
|
default:
|
||||||
// Handle default or unknown settings
|
// Handle default or unknown settings
|
||||||
@ -45,8 +38,7 @@ class SettingsPage extends StatelessWidget {
|
|||||||
final settingsOptions = [
|
final settingsOptions = [
|
||||||
'Calling settings',
|
'Calling settings',
|
||||||
'Key management',
|
'Key management',
|
||||||
'Blocked numbers',
|
'Blocked numbers'
|
||||||
'Default SIM',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
@ -1,220 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
import 'package:sim_data_new/sim_data.dart';
|
|
||||||
|
|
||||||
class SettingsSimPage extends StatefulWidget {
|
|
||||||
const SettingsSimPage({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
_SettingsSimPageState createState() => _SettingsSimPageState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SettingsSimPageState extends State<SettingsSimPage> {
|
|
||||||
int _selectedSim = 0;
|
|
||||||
SimData? _simData;
|
|
||||||
bool _isLoading = true;
|
|
||||||
String? _error;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_loadSimCards();
|
|
||||||
_loadDefaultSim();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _loadSimCards() async {
|
|
||||||
try {
|
|
||||||
final simData = await SimDataPlugin.getSimData();
|
|
||||||
setState(() {
|
|
||||||
_simData = simData;
|
|
||||||
_isLoading = false;
|
|
||||||
_error = null;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
setState(() {
|
|
||||||
_isLoading = false;
|
|
||||||
_error = e.toString();
|
|
||||||
});
|
|
||||||
print('Error loading SIM cards: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _loadDefaultSim() async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
setState(() {
|
|
||||||
_selectedSim = prefs.getInt('default_sim_slot') ?? 0;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onSimChanged(int? value) async {
|
|
||||||
if (value != null) {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.setInt('default_sim_slot', value);
|
|
||||||
setState(() {
|
|
||||||
_selectedSim = value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: Colors.black,
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Default SIM'),
|
|
||||||
),
|
|
||||||
body: _buildBody(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildBody() {
|
|
||||||
if (_isLoading) {
|
|
||||||
return const Center(
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
color: Colors.blue,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_error != null) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
Icons.error_outline,
|
|
||||||
color: Colors.red,
|
|
||||||
size: 64,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Error loading SIM cards',
|
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 18),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
_error!,
|
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 14),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
setState(() {
|
|
||||||
_isLoading = true;
|
|
||||||
_error = null;
|
|
||||||
});
|
|
||||||
_loadSimCards();
|
|
||||||
},
|
|
||||||
child: const Text('Retry'),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
'Fallback to default options:',
|
|
||||||
style: const TextStyle(color: Colors.grey, fontSize: 14),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_buildFallbackSimList(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_simData == null || _simData!.cards.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
Icons.sim_card_alert,
|
|
||||||
color: Colors.orange,
|
|
||||||
size: 64,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
const Text(
|
|
||||||
'No SIM cards detected',
|
|
||||||
style: TextStyle(color: Colors.white, fontSize: 18),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
const Text(
|
|
||||||
'Using default options:',
|
|
||||||
style: TextStyle(color: Colors.grey, fontSize: 14),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_buildFallbackSimList(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListView.builder(
|
|
||||||
itemCount: _simData!.cards.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final card = _simData!.cards[index];
|
|
||||||
return RadioListTile<int>(
|
|
||||||
title: Text(
|
|
||||||
_getSimDisplayName(card, index),
|
|
||||||
style: const TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
_getSimSubtitle(card),
|
|
||||||
style: const TextStyle(color: Colors.grey),
|
|
||||||
),
|
|
||||||
value: card.slotIndex,
|
|
||||||
groupValue: _selectedSim,
|
|
||||||
onChanged: _onSimChanged,
|
|
||||||
activeColor: Colors.blue,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildFallbackSimList() {
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
RadioListTile<int>(
|
|
||||||
title: const Text('SIM 1', style: TextStyle(color: Colors.white)),
|
|
||||||
value: 0,
|
|
||||||
groupValue: _selectedSim,
|
|
||||||
onChanged: _onSimChanged,
|
|
||||||
activeColor: Colors.blue,
|
|
||||||
),
|
|
||||||
RadioListTile<int>(
|
|
||||||
title: const Text('SIM 2', style: TextStyle(color: Colors.white)),
|
|
||||||
value: 1,
|
|
||||||
groupValue: _selectedSim,
|
|
||||||
onChanged: _onSimChanged,
|
|
||||||
activeColor: Colors.blue,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _getSimDisplayName(dynamic card, int index) {
|
|
||||||
if (card.displayName != null && card.displayName.isNotEmpty) {
|
|
||||||
return card.displayName;
|
|
||||||
}
|
|
||||||
if (card.carrierName != null && card.carrierName.isNotEmpty) {
|
|
||||||
return card.carrierName;
|
|
||||||
}
|
|
||||||
return 'SIM ${index + 1}';
|
|
||||||
}
|
|
||||||
|
|
||||||
String _getSimSubtitle(dynamic card) {
|
|
||||||
List<String> subtitleParts = [];
|
|
||||||
|
|
||||||
if (card.phoneNumber != null && card.phoneNumber.isNotEmpty) {
|
|
||||||
subtitleParts.add(card.phoneNumber);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (card.carrierName != null && card.carrierName.isNotEmpty) {
|
|
||||||
subtitleParts.add(card.carrierName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subtitleParts.isEmpty) {
|
|
||||||
subtitleParts.add('Slot ${card.slotIndex}');
|
|
||||||
}
|
|
||||||
|
|
||||||
return subtitleParts.join(' • ');
|
|
||||||
}
|
|
||||||
}
|
|
@ -52,10 +52,9 @@ dependencies:
|
|||||||
audioplayers: ^6.1.0
|
audioplayers: ^6.1.0
|
||||||
cryptography: ^2.0.0
|
cryptography: ^2.0.0
|
||||||
convert: ^3.0.1
|
convert: ^3.0.1
|
||||||
encrypt: ^5.0.3
|
encrypt: ^5.0.3
|
||||||
uuid: ^4.5.1
|
uuid: ^4.5.1
|
||||||
provider: ^6.1.2
|
provider: ^6.1.2
|
||||||
sim_data_new: ^1.0.1
|
|
||||||
|
|
||||||
intl: any
|
intl: any
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
echo "Running Icing Dialer in STEALTH mode..."
|
echo "Running Icing Dialer in STEALTH mode..."
|
||||||
flutter run --release --dart-define=STEALTH=true
|
flutter run --dart-define=STEALTH=true
|
||||||
|
@ -3,7 +3,7 @@
|
|||||||
An Epitech Innovation Project
|
An Epitech Innovation Project
|
||||||
|
|
||||||
*By*
|
*By*
|
||||||
**Bartosz Michalak - Alexis Danlos - Florian Griffon - Stéphane Corbière**
|
**Bartosz Michalak - Alexis Danlos - Florian Griffon - Ange Duhayon - Stéphane Corbière**
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -17,25 +17,13 @@ An Epitech Innovation Project
|
|||||||
|
|
||||||
## Introduction to Icing
|
## Introduction to Icing
|
||||||
|
|
||||||
Icing is the name of our project, which is divided in **three interconnected goals**:
|
Icing is the name of our project, which is divided in **two interconnected goals**:
|
||||||
1. Build a mutual-authentication and end-to-end encryption protocol, NAAP, for half and full-duplex audio communication, network agnostic. Network Agnostic Authentication Protocol.
|
1. Provide an end-to-end (E2E) encryption **code library**, based on Eliptic Curve Cryptography (ECC), to encrypt phone-calls on an **analog audio** level.
|
||||||
2. Provide a reference implementation in the form of an **Android Package**, that anybody can use to implement the protocol into their application.
|
2. Provide a reference implementation in the form of a totally seamless Android **smartphone dialer** application, that anybody could use without being aware of its encryption feature.
|
||||||
3. Provide a reference implementation in the form of an **Android Dialer**, that uses the android package, and that could seamlessly replace any Android user's default dialer.
|
|
||||||
|
|
||||||
|
This idea came naturally to our minds, when we remarked the lack of such tool.
|
||||||
|
|
||||||
### Setting a new security standard
|
Where "private messaging" and other "encrypted communication" apps flourish, nowadays, they **all** require an internet access to work.
|
||||||
|
|
||||||
#### ***"There is no way to create a backdoor that only the good guys can walk through"***
|
|
||||||
> (*Meredith Whittaker - President of Signal Fundation - July 2023, Channel 4*)
|
|
||||||
|
|
||||||
Enabling strong authentication on the phone network, either cellular or cable, would change the way we use our phone.
|
|
||||||
|
|
||||||
Reduced phone-related scams, simplified and safer banking or government services interactions, reduced dependency to the internet, and more, are the benefits both consumers and organizations would enjoy.
|
|
||||||
|
|
||||||
Encrypting the data end-to-end increases security globally by a considerable factor, particularly in low-bandwidth / no internet areas, and the added privacy would benefit everyone.
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Privacy and security in telecoms should not depend on internet availability.
|
### Privacy and security in telecoms should not depend on internet availability.
|
||||||
|
|
||||||
@ -47,6 +35,21 @@ So in a real-world, stressful and harsh condition, affording privacy or security
|
|||||||
Our solution is for the every-man that is not even aware of its smart phone weakness, as well as for the activists or journalists surviving in hostile environment around the globe.
|
Our solution is for the every-man that is not even aware of its smart phone weakness, as well as for the activists or journalists surviving in hostile environment around the globe.
|
||||||
|
|
||||||
|
|
||||||
|
### Setting a new security standard
|
||||||
|
|
||||||
|
#### ***"There is no way to create a backdoor that only the good guys can walk through"***
|
||||||
|
> (*Meredith Whittaker - President of Signal Fundation - July 2023, Channel 4*)
|
||||||
|
|
||||||
|
If the police can listen to your calls with a mandate, hackers can, without mandate.
|
||||||
|
|
||||||
|
Many online platforms, such as online bank accounts, uses phone calls, or voicemails to drop security codes needed for authentication. The idea is to bring extra security, by requiring a second factor to authenticate the user, but most voicemails security features have been obsolete for a long time now.
|
||||||
|
|
||||||
|
**But this could change with globalized end-to-end encryption.**
|
||||||
|
|
||||||
|
This not only enables obfuscation of the transmitted audio data, but also hard peer authentication.
|
||||||
|
This means that if you are in an important call, where you could communicate sensitive information such as passwords, or financial orders, using Icing protocol you and your peer would know that there is no man in the middle, listening and stealing information, and that your correspondent really is who it says.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Icing's strategy
|
### Icing's strategy
|
||||||
|
|
||||||
|
@ -1,182 +1,71 @@
|
|||||||
# User Manual for Icing Dialer
|
|
||||||
|
|
||||||
## Introduction
|
# User Manual
|
||||||
|
|
||||||
The Icing Dialer is an open-source mobile application designed to enable end-to-end encrypted voice calls over GSM/LTE networks, ensuring privacy without reliance on the internet, servers, or third-party infrastructure. This manual provides comprehensive guidance for three audiences: average users, security experts, and developers. A final section outlines our manual testing policy to ensure the application's reliability and security.
|
|
||||||
|
|
||||||
- **Average User**: Instructions for using the Icing Dialer as a transparent replacement for the default phone dialer.
|
**Utilization documentation.**
|
||||||
- **Security Expert**: Technical details of the Icing protocol, including cryptographic mechanisms and implementation.
|
|
||||||
- **Developer**: In-depth explanation of the code architecture, Icing protocol library, and integration guidelines.
|
Written with chapters for the average Joe user, security experts, and developers.
|
||||||
- **Manual Tests**: Overview of the manual testing policy for validating the application and protocol.
|
|
||||||
|
The average-user section is only about what the average-user will know from Icing: its dialer reference implementation.
|
||||||
|
|
||||||
|
The security expert section will cover all the theory behind our reference implementation, and the Icing protocol. This section can serve as an introduction / transition for the next section:
|
||||||
|
|
||||||
|
The developer section will explain our code architecture and concepts, going in-depth inside the reference implementation and the Icing protocol library.
|
||||||
|
This library will have dedicated documentation in this section, so any developer can implement it in any desired way.
|
||||||
|
|
||||||
|
Lastly, as a continuation of the developer section, the Manual Test section will cover our manual testing policy.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
- [Average User](#average-user)
|
|
||||||
- [Security Expert](#security-expert)
|
|
||||||
|
- [Average User](#averageuser)
|
||||||
|
|
||||||
|
- [Security Expert](#icingsstrategy)
|
||||||
|
|
||||||
- [Developer](#developer)
|
- [Developer](#developer)
|
||||||
- [Manual Tests](#manual-tests)
|
|
||||||
|
- [Manual Tests](#manualtests)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Average User
|
## Average User
|
||||||
|
|
||||||
The Icing Dialer is a privacy-focused mobile application that replaces your default phone dialer, providing secure, end-to-end encrypted voice calls over GSM/LTE networks. It is designed to be intuitive and indistinguishable from a standard dialer, ensuring seamless use for all users.
|
|
||||||
|
Use the Icing dialer like your normal dialer, if you can't do that we can't help, you dumb retard lmfao.
|
||||||
|
|
||||||
### Key Features
|
|
||||||
- **Seamless Dialer Replacement**: Functions as a full replacement for your phone’s default dialer, supporting standard call features and encrypted calls.
|
|
||||||
- **Cryptographic Key Pair Generation**: Automatically generates an ED25519 key pair during setup for secure communications, stored securely using the Android Keystore.
|
|
||||||
- **Secure Contact Sharing**: Adds and shares contacts via QR codes or VCF files, ensuring privacy.
|
|
||||||
- **Automatic Call Encryption**: Encrypts calls with compatible Icing Dialer users using the Noise protocol, encoded into the analog audio signal via Codec2 and 4FSK modulation.
|
|
||||||
- **On-the-Fly Pairing**: Detects other Icing Dialer users and offers encrypted pairing during calls (optional, under development).
|
|
||||||
- **Call Management**: Includes call history, contact management, visual voicemail, and features like mute, speaker, and SIM selection.
|
|
||||||
- **Privacy Protection**: Safeguards sensitive communications with secure voice authentication and encrypted voicemail.
|
|
||||||
|
|
||||||
### Getting Started
|
|
||||||
1. **Installation**: Install the Icing Dialer from a trusted source (e.g., a partnered AOSP fork or Magisk module for rooted Android devices).
|
|
||||||
2. **Setup**: Upon first launch, the app generates an ED25519 key pair using the Android Keystore. Follow prompts to complete setup.
|
|
||||||
3. **Adding Contacts**: Use the QR code or VCF import feature to securely add contacts. Scan a contact’s QR code or import a VCF file to establish a secure connection.
|
|
||||||
4. **Making Calls**: Dial numbers using the full dialer interface (numbers, *, #). The app uses the Android Telephony API to detect compatible users and automatically encrypts calls when possible.
|
|
||||||
5. **Encrypted Calls**: Calls to known contacts with public keys are automatically encrypted. A data rate and error indicator provide real-time feedback. Use the disable encryption button if needed.
|
|
||||||
6. **Call History and Contacts**: Access call history with filters for missed, incoming, and outgoing calls. Tap a call to view details or open a contact modal. Manage contacts with search, favorites, and blocklist features.
|
|
||||||
7. **Visual Voicemail**: Play, pause, or manage voicemails with quick links to call, text, block, or share numbers.
|
|
||||||
8. **Settings**: Configure default SIM, manage public keys, and access the blocklist via the settings menu.
|
|
||||||
|
|
||||||
### Troubleshooting
|
|
||||||
- **FAQs**: Available on our Reddit and Telegram channels for common issues and setup guidance.
|
|
||||||
- **Feedback**: Submit feedback via our anonymous CryptPad form for prompt issue resolution.
|
|
||||||
|
|
||||||
### Example Scenarios
|
|
||||||
- **Secure Voicemail Access**: Mathilda, 34, uses Icing to securely retrieve a PayPal authentication code from her voicemail, protected by her registered Icing public key.
|
|
||||||
- **Authenticated Calls**: Jeff, 70, authenticates with his bank using encrypted DTMF transmission, ensuring secure and verified communication.
|
|
||||||
- **Private Communication**: Elise, 42, a journalist, uses Icing to make discreet, encrypted calls despite unreliable or monitored networks.
|
|
||||||
- **Emergency Calls Abroad**: Paul, 22, a developer, uses Icing to securely assist colleagues with a critical issue while abroad, relying only on voice calls.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Security Expert
|
## Security Expert
|
||||||
|
|
||||||
The Icing Dialer is the reference implementation of the Icing protocol, an open, decentralized encryption protocol for telephony. This section details the cryptographic and technical foundations, focusing on security principles.
|
SecUriTy eXpeRt
|
||||||
|
|
||||||
### Icing Protocol Overview
|
|
||||||
The Icing protocol enables end-to-end encrypted voice calls over GSM/LTE networks by encoding cryptographic data into the analog audio signal. Key components include:
|
|
||||||
- **End-to-End Encryption**: Utilizes the Noise protocol with XX (mutual authentication) and XK (known-key) handshake patterns for secure session establishment, using ED25519 key pairs.
|
|
||||||
- **Perfect Forward Secrecy**: Ensures session keys are ephemeral and discarded after use, with future sessions salted using pseudo-random values derived from past calls.
|
|
||||||
- **Codec2 and 4FSK**: Voice data is compressed using Codec2 and modulated with 4FSK (Four Frequency Shift Keying) for transmission over GSM/LTE.
|
|
||||||
- **Secure Contact Pairing**: Uses QR codes or VCF files for secure key exchange, preventing man-in-the-middle attacks.
|
|
||||||
- **Encrypted DTMF**: Supports secure transmission of DTMF signals for authentication scenarios.
|
|
||||||
- **Forward Error Correction (FEC)**: Detects up to 50% of transmission errors in Alpha 1, with plans for stronger FEC (>80% detection, 20% correction) in future iterations.
|
|
||||||
- **Decentralized Design**: Operates without servers or third-party intermediaries, minimizing attack surfaces.
|
|
||||||
- **Voice Authentication**: Implements cryptographic voice authentication to verify caller identity.
|
|
||||||
|
|
||||||
### Security Implementation
|
|
||||||
- **Cryptographic Framework**: Uses ED25519 key pairs for authentication and encryption, generated and stored securely via the Android Keystore. The Noise protocol ensures secure key exchange and session setup. AES-256 and ECC (P-256, ECDH) are employed for data encryption.
|
|
||||||
- **Analog Signal Encoding**: Codec2 compresses voice data, and 4FSK modulates encrypted data into the analog audio signal, ensuring compatibility with GSM/LTE networks.
|
|
||||||
- **Threat Model**: Protects against eavesdropping, interception, replay attacks, and unauthorized access. Includes replay protection mechanisms and assumes adversaries may control network infrastructure but not device endpoints.
|
|
||||||
- **Data Privacy**: Minimizes data storage (only encrypted keys and minimal metadata), with no unencrypted call metadata stored. Sensitive data is encrypted at rest.
|
|
||||||
- **Current Status**: Protocol Alpha 1, tested in DryBox, validates peer ping, ephemeral key management, handshakes, real-time encryption, stream compression, and 4FSK transmission. Alpha 2 will enhance FEC and on-the-fly key exchange.
|
|
||||||
|
|
||||||
### Future Considerations
|
|
||||||
- Develop a full RFC for the Icing protocol, documenting peer ping, handshakes, and FEC.
|
|
||||||
- Optimize Codec2 and 4FSK for improved audio quality and transmission reliability.
|
|
||||||
- Implement embedded silent data transmission (e.g., DTMF) and on-the-fly key exchange.
|
|
||||||
- Enhance interoperability with existing standards (e.g., SIP, WebRTC).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Developer
|
## Developer
|
||||||
|
|
||||||
The Icing Dialer and its protocol are open-source and extensible. This section explains the code architecture, Icing protocol library, and guidelines for integration into custom applications.
|
int main;
|
||||||
|
|
||||||
### Code Architecture
|
---
|
||||||
The Icing Dialer is developed in two implementations:
|
|
||||||
- **Root-app**: For rooted Android devices, deployed via a Magisk module (~85% complete for Alpha 1).
|
|
||||||
- **AOSP-app**: Integrated into a custom AOSP fork for native support, pending partnerships with AOSP-based projects (e.g., GrapheneOS, LineageOS).
|
|
||||||
|
|
||||||
The application is written in Kotlin, leveraging the Android Telephony API for call management and a modular architecture:
|
|
||||||
- **UI Layer**: A responsive, intuitive interface resembling a default phone dialer, with accessibility features, contact management, and call history.
|
|
||||||
- **Encryption Layer**: Manages ED25519 key pair generation (via Android Keystore or RAM for export), Noise protocol handshakes (XX and XK), Codec2 compression, and 4FSK modulation.
|
|
||||||
- **Network Layer**: Interfaces with GSM/LTE networks via the Android Telephony API to encode encrypted data into analog audio signals.
|
|
||||||
|
|
||||||
### Icing Protocol Library
|
|
||||||
The Kotlin-based Icing protocol library (~75% complete for Alpha 1) enables third-party applications to implement the Icing protocol. Key components include:
|
|
||||||
- **KeyPairGenerator**: Generates and manages ED25519 key pairs, supporting secure (Android Keystore) and insecure (RAM) generation, with export/import capabilities.
|
|
||||||
- **NoiseProtocolHandler**: Implements XX and XK handshakes for secure session establishment, ensuring Perfect Forward Secrecy.
|
|
||||||
- **QRCodeHandler**: Manages secure contact sharing via QR codes or VCF files.
|
|
||||||
- **AudioEncoder**: Compresses voice with Codec2 and modulates encrypted data with 4FSK.
|
|
||||||
- **CallManager**: Uses the Android Telephony API to detect peers, initiate calls, and handle DTMF transmission.
|
|
||||||
- **FECModule**: Implements basic FEC for detecting 50% of transmission errors, with plans for enhanced detection and correction.
|
|
||||||
|
|
||||||
#### Integration Guide
|
|
||||||
1. **Include the Library**: Add the Icing protocol library to your project (available upon Beta release).
|
|
||||||
2. **Initialize KeyPairGenerator**: Generate an ED25519 key pair using Android Keystore for secure storage or RAM for exportable keys.
|
|
||||||
3. **Implement NoiseProtocolHandler**: Configure XX or XK handshakes for authentication and session setup.
|
|
||||||
4. **Integrate QRCodeHandler**: Enable contact sharing via QR codes or VCF files.
|
|
||||||
5. **Use AudioEncoder**: Compress voice with Codec2 and modulate with 4FSK for GSM/LTE transmission.
|
|
||||||
6. **Leverage CallManager**: Manage calls and DTMF via the Android Telephony API.
|
|
||||||
7. **Test with DryBox**: Validate implementation using the Python-based DryBox environment for end-to-end call simulation.
|
|
||||||
|
|
||||||
### Development Status
|
|
||||||
- **Protocol Alpha 1**: Implemented in DryBox, supporting peer ping, ephemeral keys, handshakes, encryption, Codec2 compression, and 4FSK modulation.
|
|
||||||
- **Kotlin Library**: 75% complete, with tasks remaining for Alpha 1 completion.
|
|
||||||
- **Root-app**: 85% complete, with Magisk deployment in progress.
|
|
||||||
- **AOSP-app**: In development, pending AOSP fork partnerships.
|
|
||||||
|
|
||||||
Developers can join our Reddit or Telegram communities for updates and to contribute to the open-source project.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Manual Tests
|
## Manual Tests
|
||||||
|
|
||||||
The Icing project employs a rigorous manual testing policy to ensure the reliability, security, and usability of the Icing Dialer and its protocol. This section outlines our testing approach, incorporating beta testing scenarios and evaluation criteria.
|
1. Call grandpa
|
||||||
|
2. Receive mum call
|
||||||
### Testing Environment
|
3. Order 150g of 95% pure Bolivian coke without encryption
|
||||||
- **DryBox**: A Python-based environment simulating end-to-end encrypted calls over a controlled network, used to validate Protocol Alpha 1 and future iterations.
|
4. Order again but with encryption
|
||||||
- **Root-app Testing**: Conducted on rooted Android devices using Magisk modules.
|
5. Compare results
|
||||||
- **AOSP-app Testing**: Planned for custom AOSP forks, pending partnerships.
|
|
||||||
|
|
||||||
### Manual Testing Policy
|
|
||||||
- **Usability Testing**: Beta testers evaluate the dialer’s intuitiveness as a drop-in replacement, testing call initiation, contact management, and voicemail. Initial tests confirm usability without prior instructions.
|
|
||||||
- **Encryption Validation**: Tests in DryBox verify end-to-end encryption using Noise protocol (XX/XK handshakes), ED25519 key pairs, Codec2, and 4FSK. Includes encrypted DTMF and FEC (50% error detection).
|
|
||||||
- **Contact Pairing**: Tests QR code and VCF-based contact sharing for security and functionality.
|
|
||||||
- **Call Scenarios**: Tests include clear calls (to non-Icing and Icing dialers), encrypted calls (to known and unknown contacts), and DTMF transmission.
|
|
||||||
- **Performance Testing**: Ensures minimal latency, low bandwidth usage, and high audio quality (clarity, minimal distortion) via Codec2/4FSK.
|
|
||||||
- **Privacy Testing**: Verifies encrypted storage of keys and minimal metadata, with no unencrypted call logs stored.
|
|
||||||
- **Integration Testing**: Validates Android Telephony API integration, permissions (microphone, camera, contacts), and background operation.
|
|
||||||
|
|
||||||
### Beta Testing Scenarios
|
|
||||||
- Clear call from Icing Dialer to another dialer (e.g., Google, Apple).
|
|
||||||
- Clear call between two Icing Dialers.
|
|
||||||
- Clear call to a known contact (with public key) without Icing Dialer.
|
|
||||||
- Encrypted call to a known contact with Icing Dialer.
|
|
||||||
- Encrypted call to an unknown contact with Icing Dialer (optional, under development).
|
|
||||||
- Create/edit/save contacts with/without public keys.
|
|
||||||
- Share/import contacts via QR code/VCF.
|
|
||||||
- Listen to voicemail and verify encryption.
|
|
||||||
- Record and verify encrypted call integrity.
|
|
||||||
- Change default SIM.
|
|
||||||
|
|
||||||
### Evaluation Criteria
|
|
||||||
- **Security**: Validates AES-256/ECC encryption, ED25519 key management, Perfect Forward Secrecy, replay protection, and end-to-end encryption integrity.
|
|
||||||
- **Performance**: Measures call setup latency, bandwidth efficiency, and audio quality (clarity, consistency).
|
|
||||||
- **Usability**: Ensures intuitive UI, seamless call handling, and robust error recovery.
|
|
||||||
- **Interoperability**: Tests compatibility with GSM/LTE networks and potential future integration with SIP/WebRTC.
|
|
||||||
- **Privacy**: Confirms encrypted data storage, minimal permissions, and no unencrypted metadata.
|
|
||||||
- **Maintainability**: Reviews code quality, modularity, and documentation for the protocol and library.
|
|
||||||
|
|
||||||
### Current Testing Status
|
|
||||||
- **Protocol Alpha 1**: Validated in DryBox for encryption, handshakes, Codec2/4FSK, and FEC.
|
|
||||||
- **Root-app**: 85% complete, undergoing usability, performance, and security testing.
|
|
||||||
- **Feedback Channels**: Anonymous feedback via CryptPad and FAQs on Reddit/Telegram inform testing.
|
|
||||||
|
|
||||||
### Future Testing Plans
|
|
||||||
- Test Protocol Alpha 2 for enhanced FEC and on-the-fly key exchange.
|
|
||||||
- Conduct AOSP-app testing with partnered forks.
|
|
||||||
- Incorporate NPS/CSAT metrics from AMAs to assess user satisfaction.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
The Icing Dialer and its protocol offer a pioneering approach to secure telephony, leveraging ED25519 key pairs, the Noise protocol, Codec2, 4FSK, and the Android Telephony API. This manual provides comprehensive guidance for users, security experts, and developers, supported by a robust testing policy. For further details or to contribute, visit our Reddit or Telegram communities.
|
|
||||||
|
@ -1,69 +0,0 @@
|
|||||||
# Context
|
|
||||||
|
|
||||||
## Glossary
|
|
||||||
|
|
||||||
- DryBox: Our python test environment, simulating a call Initiator and Responder using the protocol through a controlled cellular network.
|
|
||||||
- Root-app: A version of the application only installable on rooted Android devices.
|
|
||||||
- AOSP-app: A version of the application imbedded in an AOSP fork, coming with an AOSP install.
|
|
||||||
- AOSP: Android Open Source Project. The bare-bone of Android maintained by google, currently Open-Source, and forked by many. GrapheneOS, LineageOS, and /e/ are examples of AOSP forks.
|
|
||||||
- Magisk module: Magisk is the reference open-source Android rooting tool, a Magisk Module is a script leveraging Magisk's root tools to install or tweak a specific app or setting with special permissions.
|
|
||||||
- Alpha 1, 2, Beta: We plan two iterations of Alpha development, leading towards a final Beta protocol and root/AOSP applications release.
|
|
||||||
|
|
||||||
## Current status:
|
|
||||||
|
|
||||||
Protocol Alpha 1 => DryBox
|
|
||||||
|
|
||||||
App => 85% done
|
|
||||||
|
|
||||||
Kotlin Lib => 75% Alpha 1 done
|
|
||||||
|
|
||||||
Partnerships => Communication engaged
|
|
||||||
|
|
||||||
## Remaining steps:
|
|
||||||
|
|
||||||
- Design and test Protocol Alpha 2
|
|
||||||
- Finish Alpha 1 Lib's implementation
|
|
||||||
- Implement Root-app Alpha 1
|
|
||||||
- Streamline Root-app tests
|
|
||||||
- Develop at least one AOSP fork partnership
|
|
||||||
- AOSP forks' local AOSP-app implementation
|
|
||||||
- AOSP forks' partnership AOSP-app implementation
|
|
||||||
- Magisk Root-app module self-hosted or official deployment
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
# Plan
|
|
||||||
|
|
||||||
Our plan is defined with monthly granularity.
|
|
||||||
|
|
||||||
Every month, a recap will be published on Telegram and Reddit about the achieved goals, suprises, and feeling on next month's goal.
|
|
||||||
|
|
||||||
- ### September & October
|
|
||||||
|
|
||||||
- Finish Alpha 1 lib's and Root-app implementation
|
|
||||||
- App features & UI improvements
|
|
||||||
- Streamlined Root-app tests
|
|
||||||
|
|
||||||
- ### November
|
|
||||||
|
|
||||||
- Magisk Root-app module
|
|
||||||
- Drybox features improvements
|
|
||||||
- Alpha 2 theory and Drybox testing
|
|
||||||
|
|
||||||
- ### December
|
|
||||||
|
|
||||||
- AOSP forks' local app implementation (AOSP-app)
|
|
||||||
- App features & UI improvements
|
|
||||||
- IF APPLICABLE: AOSP fork real partnership (i.e GrapheneOS, LineageOS, /e/, etc...)
|
|
||||||
|
|
||||||
- ### January
|
|
||||||
|
|
||||||
- Root-app & AOSP-app Alpha 2 implementation
|
|
||||||
- Entire code audit and Beta Release preparation
|
|
||||||
- Enhancement from "Alpha 2" to Beta
|
|
||||||
|
|
||||||
- ### February
|
|
||||||
|
|
||||||
- Website and community Beta Release preparation
|
|
||||||
- Official Beta Release event: Root-app and AOSP-app APK Alpha APK publication
|
|
Binary file not shown.
@ -2,6 +2,10 @@
|
|||||||
<a href="https://github.com/AlexisDanlos" target="_blank">Alexis DANLOS</a>
|
<a href="https://github.com/AlexisDanlos" target="_blank">Alexis DANLOS</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{define "ange"}}
|
||||||
|
<a href="https://yw5n.com" target="_blank">Ange DUHAYON</a>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{define "bartosz"}}
|
{{define "bartosz"}}
|
||||||
<a href="https://github.com/Bartoszkk" target="_blank">Bartosz MICHALAK</a>
|
<a href="https://github.com/Bartoszkk" target="_blank">Bartosz MICHALAK</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
Loading…
Reference in New Issue
Block a user