Compare commits
16 Commits
dev
...
ProtcoleIm
Author | SHA1 | Date | |
---|---|---|---|
10b44cdf72 | |||
4cc9e8b2d2 | |||
1d5eae7d80 | |||
d3d14919a8 | |||
8b6ba00d8c | |||
c4610fbcb9 | |||
|
96553b27bd | ||
|
4832ba751f | ||
a6cd9632ee | |||
6b517f6a46 | |||
a14084ce68 | |||
5c274817df | |||
8f81049822 | |||
75f54dc90a | |||
0badc8862c | |||
9ef3ad5b56 |
77
README.md
77
README.md
@ -1,72 +1,21 @@
|
||||
---
|
||||
gitea: none
|
||||
include_toc: true
|
||||
---
|
||||
# Icing
|
||||
|
||||
# Icing – end-to-end-encrypted phone calls without data
|
||||
|
||||
|
||||
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.**
|
||||
## Encrypting phone calls on an analog audio level
|
||||
|
||||
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
|
||||
|
||||
## 📖 Detailed design
|
||||
|
||||
See [`docs/Icing.md`](docs/Icing.md) for protocol goals, threat model, and technical architecture.
|
||||
|
||||
## 🔨 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.
|
||||
#### Icing
|
||||
- The project
|
||||
- A user manual
|
||||
- Our automations
|
||||
|
@ -42,3 +42,19 @@ android {
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation files('libs/noise-java-1.0.jar')
|
||||
|
||||
// Audio processing and DSP
|
||||
implementation 'com.github.wendykierp:JTransforms:3.1'
|
||||
|
||||
// Apache Commons Math for signal processing
|
||||
implementation 'org.apache.commons:commons-math3:3.6.1'
|
||||
|
||||
// Audio codec - Opus for Android
|
||||
implementation 'com.github.theeasiestway:android-opus-codec:1.0.3'
|
||||
|
||||
// Kotlin Coroutines for async processing
|
||||
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
||||
}
|
BIN
dialer/android/app/libs/noise-java-1.0.jar
Normal file
BIN
dialer/android/app/libs/noise-java-1.0.jar
Normal file
Binary file not shown.
@ -3,9 +3,15 @@
|
||||
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
|
||||
<uses-permission android:name="android.permission.CALL_PHONE" />
|
||||
<uses-permission android:name="android.permission.SEND_SMS" />
|
||||
<uses-permission android:name="android.permission.READ_BLOCKED_NUMBERS" />
|
||||
<uses-permission android:name="android.permission.WRITE_BLOCKED_NUMBERS" />
|
||||
<uses-permission android:name="android.permission.READ_CALL_LOG"/>
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE"/>
|
||||
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
|
||||
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
|
||||
<uses-permission android:name="android.permission.PROCESS_OUTGOING_CALLS" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.telephony" android:required="true" />
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
|
@ -5,8 +5,6 @@
|
||||
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
|
||||
<uses-permission android:name="android.permission.CALL_PHONE" />
|
||||
<uses-permission android:name="android.permission.SEND_SMS" />
|
||||
<uses-permission android:name="android.permission.READ_BLOCKED_NUMBERS" />
|
||||
<uses-permission android:name="android.permission.WRITE_BLOCKED_NUMBERS" />
|
||||
<uses-permission android:name="android.permission.READ_CALL_LOG"/>
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE"/>
|
||||
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
|
||||
@ -73,7 +71,7 @@
|
||||
android:name="android.telecom.IN_CALL_SERVICE_UI"
|
||||
android:value="true" />
|
||||
</service>
|
||||
<!-- Custom ConnextionService, will be needed at some point when we implement our own protocol -->
|
||||
<!-- Custom ConnectionService, will be needed when we implement our own protocol -->
|
||||
<!-- <service
|
||||
android:name=".services.CallConnectionService"
|
||||
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE"
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,165 @@
|
||||
package com.icing.dialer
|
||||
|
||||
import android.util.Base64
|
||||
import com.southernstorm.noise.protocol.CipherState
|
||||
import com.southernstorm.noise.protocol.CipherStatePair
|
||||
import com.southernstorm.noise.protocol.HandshakeState
|
||||
import com.southernstorm.noise.protocol.Noise
|
||||
import javax.crypto.BadPaddingException
|
||||
import javax.crypto.ShortBufferException
|
||||
import java.security.NoSuchAlgorithmException
|
||||
import java.util.Arrays
|
||||
|
||||
class NoiseHandler(
|
||||
private val localKeyBase64: String, // ED25519 private (initiator) or public (responder) key (Base64-encoded)
|
||||
private val remotePublicKeyBase64: String // Remote ED25519 public key (Base64-encoded)
|
||||
) {
|
||||
private var handshakeState: HandshakeState? = null
|
||||
private var cipherStatePair: CipherStatePair? = null
|
||||
|
||||
/**
|
||||
* Wipes sensitive data by filling the byte array with zeros.
|
||||
*/
|
||||
private fun wipe(data: ByteArray?) {
|
||||
data?.let { Arrays.fill(it, 0.toByte()) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the Noise handshake.
|
||||
* @param isInitiator True if this is the initiator, false if responder.
|
||||
* @return The initial handshake message.
|
||||
* @throws IllegalArgumentException If keys are invalid.
|
||||
* @throws IllegalStateException If handshake fails to start.
|
||||
*/
|
||||
fun initialize(isInitiator: Boolean): ByteArray {
|
||||
var localKey: ByteArray? = null
|
||||
var remotePublicKey: ByteArray? = null
|
||||
try {
|
||||
val protocolName = "Noise_IK_25519_AESGCM_SHA256"
|
||||
handshakeState = HandshakeState(
|
||||
protocolName,
|
||||
if (isInitiator) HandshakeState.INITIATOR else HandshakeState.RESPONDER
|
||||
)
|
||||
|
||||
// Set local key (private for initiator, public for responder)
|
||||
localKey = Base64.decode(localKeyBase64, Base64.DEFAULT)
|
||||
if (localKey.size != 32) {
|
||||
throw IllegalArgumentException("Invalid local key size: ${localKey.size}")
|
||||
}
|
||||
if (isInitiator) {
|
||||
handshakeState?.localKeyPair?.setPrivateKey(localKey, 0)
|
||||
?: throw IllegalStateException("Local key pair not initialized")
|
||||
} else {
|
||||
handshakeState?.localKeyPair?.setPublicKey(localKey, 0)
|
||||
?: throw IllegalStateException("Local key pair not initialized")
|
||||
}
|
||||
|
||||
// Set remote public key
|
||||
remotePublicKey = Base64.decode(remotePublicKeyBase64, Base64.DEFAULT)
|
||||
if (remotePublicKey.size != 32) {
|
||||
throw IllegalArgumentException("Invalid remote public key size: ${remotePublicKey.size}")
|
||||
}
|
||||
handshakeState?.remotePublicKey?.setPublicKey(remotePublicKey, 0)
|
||||
?: throw IllegalStateException("Remote public key not initialized")
|
||||
|
||||
// Start handshake and write initial message
|
||||
handshakeState?.start() ?: throw IllegalStateException("Handshake state not initialized")
|
||||
val messageBuffer = ByteArray(256) // Sufficient for IK initial message
|
||||
val payload = ByteArray(0) // Empty payload
|
||||
val writtenLength: Int = handshakeState?.writeMessage(
|
||||
messageBuffer, 0, payload, 0, payload.size
|
||||
) ?: throw IllegalStateException("Failed to write handshake message")
|
||||
return messageBuffer.copyOf(writtenLength)
|
||||
} catch (e: NoSuchAlgorithmException) {
|
||||
throw IllegalStateException("Unsupported algorithm: ${e.message}", e)
|
||||
} catch (e: ShortBufferException) {
|
||||
throw IllegalStateException("Buffer too small for handshake message", e)
|
||||
} finally {
|
||||
wipe(localKey)
|
||||
wipe(remotePublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a handshake message and returns the next message or null if complete.
|
||||
* @param message The received handshake message.
|
||||
* @return The next handshake message or null if handshake is complete.
|
||||
* @throws IllegalStateException If handshake state is invalid.
|
||||
* @throws BadPaddingException If message decryption fails.
|
||||
*/
|
||||
fun processHandshakeMessage(message: ByteArray): ByteArray? {
|
||||
try {
|
||||
val handshake = handshakeState ?: throw IllegalStateException("Handshake not initialized")
|
||||
val messageBuffer = ByteArray(256) // Sufficient for IK payload + MAC
|
||||
val writtenLength: Int = handshake.readMessage(
|
||||
message, 0, message.size, messageBuffer, 0
|
||||
)
|
||||
|
||||
if (handshake.getAction() == HandshakeState.SPLIT) {
|
||||
cipherStatePair = handshake.split()
|
||||
return null // Handshake complete
|
||||
}
|
||||
|
||||
// Write next message
|
||||
val payload = ByteArray(0) // Empty payload
|
||||
val nextMessage = ByteArray(256)
|
||||
val nextWrittenLength: Int = handshake.writeMessage(
|
||||
nextMessage, 0, payload, 0, payload.size
|
||||
)
|
||||
return nextMessage.copyOf(nextWrittenLength)
|
||||
} catch (e: ShortBufferException) {
|
||||
throw IllegalStateException("Buffer too small for handshake message", e)
|
||||
} catch (e: BadPaddingException) {
|
||||
throw IllegalStateException("Invalid handshake message: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts data using the sender's cipher state.
|
||||
* @param data The data to encrypt.
|
||||
* @return The encrypted data.
|
||||
* @throws IllegalStateException If handshake is not completed.
|
||||
*/
|
||||
fun encryptData(data: ByteArray): ByteArray {
|
||||
val cipherState = cipherStatePair?.getSender()
|
||||
?: throw IllegalStateException("Handshake not completed")
|
||||
try {
|
||||
val outputBuffer = ByteArray(data.size + cipherState.getMACLength()) // Account for AES-GCM MAC
|
||||
val length: Int = cipherState.encryptWithAd(null, data, 0, outputBuffer, 0, data.size)
|
||||
return outputBuffer.copyOf(length)
|
||||
} catch (e: ShortBufferException) {
|
||||
throw IllegalStateException("Buffer too small for encryption: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts data using the receiver's cipher state.
|
||||
* @param data The encrypted data.
|
||||
* @return The decrypted data.
|
||||
* @throws IllegalStateException If handshake is not completed.
|
||||
* @throws BadPaddingException If decryption fails.
|
||||
*/
|
||||
fun decryptData(data: ByteArray): ByteArray {
|
||||
val cipherState = cipherStatePair?.getReceiver()
|
||||
?: throw IllegalStateException("Handshake not completed")
|
||||
try {
|
||||
val outputBuffer = ByteArray(data.size)
|
||||
val length: Int = cipherState.decryptWithAd(null, data, 0, outputBuffer, 0, data.size)
|
||||
return outputBuffer.copyOf(length)
|
||||
} catch (e: ShortBufferException) {
|
||||
throw IllegalStateException("Buffer too small for decryption: ${e.message}", e)
|
||||
} catch (e: BadPaddingException) {
|
||||
throw IllegalStateException("Invalid ciphertext: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up sensitive cryptographic data.
|
||||
*/
|
||||
fun destroy() {
|
||||
handshakeState?.destroy()
|
||||
cipherStatePair?.destroy()
|
||||
handshakeState = null
|
||||
cipherStatePair = null
|
||||
}
|
||||
}
|
@ -10,8 +10,6 @@ import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.CallLog
|
||||
import android.telecom.TelecomManager
|
||||
import android.telephony.SubscriptionManager
|
||||
import android.telephony.SubscriptionInfo
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.icing.dialer.KeystoreHelper
|
||||
@ -98,11 +96,11 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
result.success(true)
|
||||
} "makeGsmCall" -> {
|
||||
}
|
||||
"makeGsmCall" -> {
|
||||
val phoneNumber = call.argument<String>("phoneNumber")
|
||||
val simSlot = call.argument<Int>("simSlot") ?: 0
|
||||
if (phoneNumber != null) {
|
||||
val success = CallService.makeGsmCall(this, phoneNumber, simSlot)
|
||||
val success = CallService.makeGsmCall(this, phoneNumber)
|
||||
if (success) {
|
||||
result.success(mapOf("status" to "calling", "phoneNumber" to phoneNumber))
|
||||
} else {
|
||||
@ -230,25 +228,16 @@ class MainActivity : FlutterActivity() {
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CALLLOG_CHANNEL)
|
||||
.setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"getCallLogs" -> {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALL_LOG) == PackageManager.PERMISSION_GRANTED) {
|
||||
val callLogs = getCallLogs()
|
||||
result.success(callLogs)
|
||||
} else {
|
||||
requestPermissions(arrayOf(Manifest.permission.READ_CALL_LOG), REQUEST_CODE_CALL_LOG_PERMISSION)
|
||||
result.error("PERMISSION_DENIED", "Call log permission not granted", null)
|
||||
}
|
||||
if (call.method == "getCallLogs") {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALL_LOG) == PackageManager.PERMISSION_GRANTED) {
|
||||
val callLogs = getCallLogs()
|
||||
result.success(callLogs)
|
||||
} else {
|
||||
requestPermissions(arrayOf(Manifest.permission.READ_CALL_LOG), REQUEST_CODE_CALL_LOG_PERMISSION)
|
||||
result.error("PERMISSION_DENIED", "Call log permission not granted", null)
|
||||
}
|
||||
"getLatestCallLog" -> {
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_CALL_LOG) == PackageManager.PERMISSION_GRANTED) {
|
||||
val latestCallLog = getLatestCallLog()
|
||||
result.success(latestCallLog)
|
||||
} else {
|
||||
result.error("PERMISSION_DENIED", "Call log permission not granted", null)
|
||||
}
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
} else {
|
||||
result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -332,30 +321,12 @@ class MainActivity : FlutterActivity() {
|
||||
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}")
|
||||
}
|
||||
|
||||
val map = mutableMapOf<String, Any?>(
|
||||
"number" to number,
|
||||
"type" to type,
|
||||
"date" to date,
|
||||
"duration" to duration,
|
||||
"subscription_id" to subscriptionId,
|
||||
"sim_name" to simName
|
||||
"duration" to duration
|
||||
)
|
||||
logsList.add(map)
|
||||
}
|
||||
@ -363,79 +334,6 @@ class MainActivity : FlutterActivity() {
|
||||
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?) {
|
||||
intent?.let {
|
||||
if (it.getBooleanExtra("isIncomingCall", false)) {
|
||||
|
@ -0,0 +1,102 @@
|
||||
package com.icing.dialer.modem
|
||||
|
||||
import com.theeasiestway.opus.Opus
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ShortBuffer
|
||||
|
||||
class AudioCodec {
|
||||
private var encoder: Long = 0
|
||||
private var decoder: Long = 0
|
||||
private val opus = Opus()
|
||||
|
||||
init {
|
||||
// Initialize Opus encoder and decoder
|
||||
encoder = opus.encoderCreate(
|
||||
FSKConstants.SAMPLE_RATE,
|
||||
1, // Mono
|
||||
Opus.OPUS_APPLICATION_VOIP
|
||||
)
|
||||
|
||||
decoder = opus.decoderCreate(
|
||||
FSKConstants.SAMPLE_RATE,
|
||||
1 // Mono
|
||||
)
|
||||
|
||||
// Configure encoder
|
||||
opus.encoderSetBitrate(encoder, FSKConstants.OPUS_BITRATE)
|
||||
opus.encoderSetComplexity(encoder, FSKConstants.OPUS_COMPLEXITY)
|
||||
opus.encoderSetSignal(encoder, Opus.OPUS_SIGNAL_VOICE)
|
||||
opus.encoderSetPacketLossPerc(encoder, 10) // Expect 10% packet loss
|
||||
opus.encoderSetInbandFEC(encoder, 1) // Enable FEC
|
||||
opus.encoderSetDTX(encoder, 1) // Enable discontinuous transmission
|
||||
}
|
||||
|
||||
fun encode(audioData: ShortArray): ByteArray {
|
||||
val maxEncodedSize = 1024
|
||||
val encodedData = ByteArray(maxEncodedSize)
|
||||
|
||||
val encodedLength = opus.encode(
|
||||
encoder,
|
||||
audioData,
|
||||
FSKConstants.OPUS_FRAME_SIZE,
|
||||
encodedData
|
||||
)
|
||||
|
||||
return if (encodedLength > 0) {
|
||||
encodedData.copyOf(encodedLength)
|
||||
} else {
|
||||
throw RuntimeException("Opus encoding failed with error: $encodedLength")
|
||||
}
|
||||
}
|
||||
|
||||
fun decode(encodedData: ByteArray): ShortArray {
|
||||
val decodedData = ShortArray(FSKConstants.OPUS_FRAME_SIZE)
|
||||
|
||||
val decodedSamples = opus.decode(
|
||||
decoder,
|
||||
encodedData,
|
||||
decodedData,
|
||||
FSKConstants.OPUS_FRAME_SIZE,
|
||||
0 // No packet loss
|
||||
)
|
||||
|
||||
return if (decodedSamples > 0) {
|
||||
decodedData.copyOf(decodedSamples)
|
||||
} else {
|
||||
throw RuntimeException("Opus decoding failed with error: $decodedSamples")
|
||||
}
|
||||
}
|
||||
|
||||
fun decodeLost(): ShortArray {
|
||||
val decodedData = ShortArray(FSKConstants.OPUS_FRAME_SIZE)
|
||||
|
||||
val decodedSamples = opus.decode(
|
||||
decoder,
|
||||
null,
|
||||
decodedData,
|
||||
FSKConstants.OPUS_FRAME_SIZE,
|
||||
1 // Packet lost
|
||||
)
|
||||
|
||||
return if (decodedSamples > 0) {
|
||||
decodedData.copyOf(decodedSamples)
|
||||
} else {
|
||||
ShortArray(FSKConstants.OPUS_FRAME_SIZE) // Return silence
|
||||
}
|
||||
}
|
||||
|
||||
fun release() {
|
||||
if (encoder != 0L) {
|
||||
opus.encoderDestroy(encoder)
|
||||
encoder = 0
|
||||
}
|
||||
if (decoder != 0L) {
|
||||
opus.decoderDestroy(decoder)
|
||||
decoder = 0
|
||||
}
|
||||
}
|
||||
|
||||
protected fun finalize() {
|
||||
release()
|
||||
}
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
package com.icing.dialer.modem
|
||||
|
||||
object FSKConstants {
|
||||
// 4FSK frequency configuration
|
||||
const val SAMPLE_RATE = 48000 // 48 kHz sample rate for high quality
|
||||
const val SYMBOL_RATE = 2400 // 2400 baud
|
||||
const val SAMPLES_PER_SYMBOL = SAMPLE_RATE / SYMBOL_RATE // 20 samples per symbol
|
||||
|
||||
// 4FSK frequencies (Hz) - evenly spaced for optimal detection
|
||||
const val FREQ_00 = 1200.0 // Symbol 00
|
||||
const val FREQ_01 = 1800.0 // Symbol 01
|
||||
const val FREQ_10 = 2400.0 // Symbol 10
|
||||
const val FREQ_11 = 3000.0 // Symbol 11
|
||||
|
||||
// Frame structure
|
||||
const val SYNC_PATTERN = 0x7E6B2840L // 32-bit sync pattern
|
||||
const val FRAME_SIZE = 256 // bytes per frame
|
||||
const val PREAMBLE_LENGTH = 32 // symbols
|
||||
|
||||
// Error correction
|
||||
const val FEC_OVERHEAD = 1.5 // Reed-Solomon overhead factor
|
||||
|
||||
// Audio codec settings
|
||||
const val OPUS_FRAME_SIZE = 960 // 20ms at 48kHz
|
||||
const val OPUS_BITRATE = 16000 // 16 kbps
|
||||
const val OPUS_COMPLEXITY = 5 // Medium complexity
|
||||
|
||||
// Buffer sizes
|
||||
const val AUDIO_BUFFER_SIZE = 4096
|
||||
const val SYMBOL_BUFFER_SIZE = 1024
|
||||
}
|
@ -0,0 +1,214 @@
|
||||
package com.icing.dialer.modem
|
||||
|
||||
import org.apache.commons.math3.complex.Complex
|
||||
import org.apache.commons.math3.transform.DftNormalization
|
||||
import org.apache.commons.math3.transform.FastFourierTransformer
|
||||
import org.apache.commons.math3.transform.TransformType
|
||||
import kotlin.math.*
|
||||
|
||||
class FSKDemodulator {
|
||||
private val fft = FastFourierTransformer(DftNormalization.STANDARD)
|
||||
private val symbolBuffer = mutableListOf<Int>()
|
||||
private var sampleBuffer = FloatArray(0)
|
||||
private var syncFound = false
|
||||
private var syncPosition = 0
|
||||
|
||||
// Moving average filters for each frequency
|
||||
private val freq00Filter = MovingAverageFilter(FSKConstants.SAMPLES_PER_SYMBOL / 2)
|
||||
private val freq01Filter = MovingAverageFilter(FSKConstants.SAMPLES_PER_SYMBOL / 2)
|
||||
private val freq10Filter = MovingAverageFilter(FSKConstants.SAMPLES_PER_SYMBOL / 2)
|
||||
private val freq11Filter = MovingAverageFilter(FSKConstants.SAMPLES_PER_SYMBOL / 2)
|
||||
|
||||
// Demodulate audio samples to symbols
|
||||
fun demodulateSamples(samples: FloatArray): IntArray {
|
||||
val symbols = mutableListOf<Int>()
|
||||
|
||||
// Process samples in chunks of SAMPLES_PER_SYMBOL
|
||||
for (i in 0 until samples.size - FSKConstants.SAMPLES_PER_SYMBOL step FSKConstants.SAMPLES_PER_SYMBOL) {
|
||||
val symbolSamples = samples.sliceArray(i until i + FSKConstants.SAMPLES_PER_SYMBOL)
|
||||
val symbol = detectSymbol(symbolSamples)
|
||||
symbols.add(symbol)
|
||||
}
|
||||
|
||||
return symbols.toIntArray()
|
||||
}
|
||||
|
||||
// Non-coherent detection using Goertzel algorithm for efficiency
|
||||
private fun detectSymbol(samples: FloatArray): Int {
|
||||
val power00 = goertzelMagnitude(samples, FSKConstants.FREQ_00)
|
||||
val power01 = goertzelMagnitude(samples, FSKConstants.FREQ_01)
|
||||
val power10 = goertzelMagnitude(samples, FSKConstants.FREQ_10)
|
||||
val power11 = goertzelMagnitude(samples, FSKConstants.FREQ_11)
|
||||
|
||||
// Apply moving average filter to reduce noise
|
||||
val filtered00 = freq00Filter.filter(power00)
|
||||
val filtered01 = freq01Filter.filter(power01)
|
||||
val filtered10 = freq10Filter.filter(power10)
|
||||
val filtered11 = freq11Filter.filter(power11)
|
||||
|
||||
// Find maximum power
|
||||
val powers = floatArrayOf(filtered00, filtered01, filtered10, filtered11)
|
||||
var maxIndex = 0
|
||||
var maxPower = powers[0]
|
||||
|
||||
for (i in 1 until powers.size) {
|
||||
if (powers[i] > maxPower) {
|
||||
maxPower = powers[i]
|
||||
maxIndex = i
|
||||
}
|
||||
}
|
||||
|
||||
return maxIndex
|
||||
}
|
||||
|
||||
// Goertzel algorithm for single frequency detection
|
||||
private fun goertzelMagnitude(samples: FloatArray, targetFreq: Double): Float {
|
||||
val k = round(samples.size * targetFreq / FSKConstants.SAMPLE_RATE).toInt()
|
||||
val omega = 2.0 * PI * k / samples.size
|
||||
val cosine = cos(omega)
|
||||
val coeff = 2.0 * cosine
|
||||
|
||||
var q0 = 0.0
|
||||
var q1 = 0.0
|
||||
var q2 = 0.0
|
||||
|
||||
for (sample in samples) {
|
||||
q0 = coeff * q1 - q2 + sample
|
||||
q2 = q1
|
||||
q1 = q0
|
||||
}
|
||||
|
||||
val real = q1 - q2 * cosine
|
||||
val imag = q2 * sin(omega)
|
||||
|
||||
return sqrt(real * real + imag * imag).toFloat()
|
||||
}
|
||||
|
||||
// Find preamble in audio stream
|
||||
fun findPreamble(samples: FloatArray): Int {
|
||||
val preamblePattern = intArrayOf(1, 2, 1, 2, 1, 2, 1, 2) // 01 10 01 10...
|
||||
val correlationThreshold = 0.8f
|
||||
|
||||
for (i in 0 until samples.size - (preamblePattern.size * FSKConstants.SAMPLES_PER_SYMBOL)) {
|
||||
var correlation = 0.0f
|
||||
var patternPower = 0.0f
|
||||
var signalPower = 0.0f
|
||||
|
||||
for (j in preamblePattern.indices) {
|
||||
val startIdx = i + j * FSKConstants.SAMPLES_PER_SYMBOL
|
||||
val endIdx = startIdx + FSKConstants.SAMPLES_PER_SYMBOL
|
||||
|
||||
if (endIdx <= samples.size) {
|
||||
val symbolSamples = samples.sliceArray(startIdx until endIdx)
|
||||
val detectedSymbol = detectSymbol(symbolSamples)
|
||||
|
||||
if (detectedSymbol == preamblePattern[j]) {
|
||||
correlation += 1.0f
|
||||
}
|
||||
|
||||
// Calculate signal power for SNR estimation
|
||||
for (sample in symbolSamples) {
|
||||
signalPower += sample * sample
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val normalizedCorrelation = correlation / preamblePattern.size
|
||||
if (normalizedCorrelation >= correlationThreshold) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return -1 // Preamble not found
|
||||
}
|
||||
|
||||
// Convert symbols back to bytes
|
||||
fun symbolsToBytes(symbols: IntArray): ByteArray {
|
||||
val bytes = ByteArray(symbols.size / 4)
|
||||
var byteIndex = 0
|
||||
|
||||
for (i in symbols.indices step 4) {
|
||||
if (i + 3 < symbols.size) {
|
||||
val byte = ((symbols[i] and 0x03) shl 6) or
|
||||
((symbols[i + 1] and 0x03) shl 4) or
|
||||
((symbols[i + 2] and 0x03) shl 2) or
|
||||
(symbols[i + 3] and 0x03)
|
||||
bytes[byteIndex++] = byte.toByte()
|
||||
}
|
||||
}
|
||||
|
||||
return bytes.sliceArray(0 until byteIndex)
|
||||
}
|
||||
|
||||
// Carrier frequency offset estimation and correction
|
||||
fun estimateFrequencyOffset(samples: FloatArray): Double {
|
||||
// Use pilot tone or known preamble for frequency offset estimation
|
||||
val fftSize = 1024
|
||||
val paddedSamples = samples.copyOf(fftSize)
|
||||
|
||||
// Convert to complex array for FFT
|
||||
val complexSamples = Array(fftSize) { i ->
|
||||
if (i < samples.size) Complex(paddedSamples[i].toDouble()) else Complex.ZERO
|
||||
}
|
||||
|
||||
val spectrum = fft.transform(complexSamples, TransformType.FORWARD)
|
||||
|
||||
// Find peak frequencies
|
||||
var maxMagnitude = 0.0
|
||||
var peakBin = 0
|
||||
|
||||
for (i in spectrum.indices) {
|
||||
val magnitude = spectrum[i].abs()
|
||||
if (magnitude > maxMagnitude) {
|
||||
maxMagnitude = magnitude
|
||||
peakBin = i
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate frequency offset
|
||||
val detectedFreq = peakBin * FSKConstants.SAMPLE_RATE.toDouble() / fftSize
|
||||
val expectedFreq = (FSKConstants.FREQ_00 + FSKConstants.FREQ_11) / 2 // Center frequency
|
||||
|
||||
return detectedFreq - expectedFreq
|
||||
}
|
||||
|
||||
// Reset demodulator state
|
||||
fun reset() {
|
||||
symbolBuffer.clear()
|
||||
sampleBuffer = FloatArray(0)
|
||||
syncFound = false
|
||||
syncPosition = 0
|
||||
freq00Filter.reset()
|
||||
freq01Filter.reset()
|
||||
freq10Filter.reset()
|
||||
freq11Filter.reset()
|
||||
}
|
||||
|
||||
// Simple moving average filter
|
||||
private class MovingAverageFilter(private val windowSize: Int) {
|
||||
private val buffer = FloatArray(windowSize)
|
||||
private var index = 0
|
||||
private var sum = 0.0f
|
||||
private var count = 0
|
||||
|
||||
fun filter(value: Float): Float {
|
||||
sum -= buffer[index]
|
||||
buffer[index] = value
|
||||
sum += value
|
||||
index = (index + 1) % windowSize
|
||||
|
||||
if (count < windowSize) {
|
||||
count++
|
||||
}
|
||||
|
||||
return sum / count
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
buffer.fill(0.0f)
|
||||
index = 0
|
||||
sum = 0.0f
|
||||
count = 0
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,246 @@
|
||||
package com.icing.dialer.modem
|
||||
|
||||
import android.media.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
|
||||
class FSKModem {
|
||||
private val audioCodec = AudioCodec()
|
||||
private val modulator = FSKModulator()
|
||||
private val demodulator = FSKDemodulator()
|
||||
private val frameProcessor = FrameProcessor()
|
||||
|
||||
private var audioRecord: AudioRecord? = null
|
||||
private var audioTrack: AudioTrack? = null
|
||||
|
||||
private val txQueue = ConcurrentLinkedQueue<ByteArray>()
|
||||
private val rxQueue = ConcurrentLinkedQueue<ByteArray>()
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var isRunning = false
|
||||
|
||||
// Flow for received data
|
||||
private val _receivedData = MutableSharedFlow<ByteArray>()
|
||||
val receivedData: SharedFlow<ByteArray> = _receivedData.asSharedFlow()
|
||||
|
||||
// Modem states
|
||||
enum class ModemState {
|
||||
IDLE, TRANSMITTING, RECEIVING, ERROR
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(ModemState.IDLE)
|
||||
val state: StateFlow<ModemState> = _state.asStateFlow()
|
||||
|
||||
fun initialize() {
|
||||
setupAudioRecord()
|
||||
setupAudioTrack()
|
||||
}
|
||||
|
||||
private fun setupAudioRecord() {
|
||||
val bufferSize = AudioRecord.getMinBufferSize(
|
||||
FSKConstants.SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT
|
||||
)
|
||||
|
||||
audioRecord = AudioRecord(
|
||||
MediaRecorder.AudioSource.MIC,
|
||||
FSKConstants.SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
bufferSize * 2
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupAudioTrack() {
|
||||
val bufferSize = AudioTrack.getMinBufferSize(
|
||||
FSKConstants.SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_OUT_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT
|
||||
)
|
||||
|
||||
audioTrack = AudioTrack(
|
||||
AudioManager.STREAM_MUSIC,
|
||||
FSKConstants.SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_OUT_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
bufferSize * 2,
|
||||
AudioTrack.MODE_STREAM
|
||||
)
|
||||
}
|
||||
|
||||
fun start() {
|
||||
if (isRunning) return
|
||||
|
||||
isRunning = true
|
||||
audioRecord?.startRecording()
|
||||
audioTrack?.play()
|
||||
|
||||
// Start coroutines for TX and RX
|
||||
scope.launch { transmitLoop() }
|
||||
scope.launch { receiveLoop() }
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
isRunning = false
|
||||
audioRecord?.stop()
|
||||
audioTrack?.stop()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
fun sendData(data: ByteArray) {
|
||||
txQueue.offer(data)
|
||||
}
|
||||
|
||||
private suspend fun transmitLoop() {
|
||||
val audioBuffer = ShortArray(FSKConstants.OPUS_FRAME_SIZE)
|
||||
|
||||
while (isRunning) {
|
||||
if (txQueue.isNotEmpty()) {
|
||||
_state.value = ModemState.TRANSMITTING
|
||||
|
||||
val data = txQueue.poll() ?: continue
|
||||
|
||||
try {
|
||||
// Encode audio data with Opus
|
||||
val encodedAudio = audioCodec.encode(data.toShortArray())
|
||||
|
||||
// Create frame with error correction
|
||||
val frame = frameProcessor.createFrame(encodedAudio)
|
||||
val frameBytes = frame.toByteArray()
|
||||
|
||||
// Convert to symbols
|
||||
val symbols = modulator.bytesToSymbols(frameBytes)
|
||||
|
||||
// Generate preamble
|
||||
val preamble = modulator.generatePreamble()
|
||||
|
||||
// Modulate symbols
|
||||
val modulatedData = modulator.modulateSymbols(symbols)
|
||||
|
||||
// Apply raised cosine filter
|
||||
val filtered = modulator.applyRaisedCosineFilter(modulatedData)
|
||||
|
||||
// Combine preamble and data
|
||||
val txSamples = FloatArray(preamble.size + filtered.size)
|
||||
System.arraycopy(preamble, 0, txSamples, 0, preamble.size)
|
||||
System.arraycopy(filtered, 0, txSamples, preamble.size, filtered.size)
|
||||
|
||||
// Convert to 16-bit PCM and transmit
|
||||
val pcmData = ShortArray(txSamples.size) { i ->
|
||||
(txSamples[i] * 32767).toInt().coerceIn(-32768, 32767).toShort()
|
||||
}
|
||||
|
||||
audioTrack?.write(pcmData, 0, pcmData.size)
|
||||
|
||||
} catch (e: Exception) {
|
||||
_state.value = ModemState.ERROR
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
_state.value = ModemState.IDLE
|
||||
}
|
||||
|
||||
delay(10) // Small delay to prevent busy waiting
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun receiveLoop() {
|
||||
val audioBuffer = ShortArray(FSKConstants.AUDIO_BUFFER_SIZE)
|
||||
val sampleBuffer = mutableListOf<Float>()
|
||||
|
||||
while (isRunning) {
|
||||
val bytesRead = audioRecord?.read(audioBuffer, 0, audioBuffer.size) ?: 0
|
||||
|
||||
if (bytesRead > 0) {
|
||||
_state.value = ModemState.RECEIVING
|
||||
|
||||
// Convert to float samples
|
||||
val samples = FloatArray(bytesRead) { i ->
|
||||
audioBuffer[i] / 32768.0f
|
||||
}
|
||||
|
||||
sampleBuffer.addAll(samples.toList())
|
||||
|
||||
// Look for preamble
|
||||
if (sampleBuffer.size >= FSKConstants.PREAMBLE_LENGTH * FSKConstants.SAMPLES_PER_SYMBOL) {
|
||||
val bufferArray = sampleBuffer.toFloatArray()
|
||||
val preambleIndex = demodulator.findPreamble(bufferArray)
|
||||
|
||||
if (preambleIndex >= 0) {
|
||||
// Preamble found, extract frame
|
||||
val frameStart = preambleIndex +
|
||||
(FSKConstants.PREAMBLE_LENGTH * FSKConstants.SAMPLES_PER_SYMBOL)
|
||||
|
||||
if (frameStart < bufferArray.size) {
|
||||
// Estimate and correct frequency offset
|
||||
val frameSection = bufferArray.sliceArray(
|
||||
frameStart until minOf(
|
||||
frameStart + FSKConstants.FRAME_SIZE * 4 * FSKConstants.SAMPLES_PER_SYMBOL,
|
||||
bufferArray.size
|
||||
)
|
||||
)
|
||||
|
||||
// Demodulate symbols
|
||||
val symbols = demodulator.demodulateSamples(frameSection)
|
||||
|
||||
// Convert symbols to bytes
|
||||
val frameBytes = demodulator.symbolsToBytes(symbols)
|
||||
|
||||
// Process frame (error correction and CRC check)
|
||||
val decodedData = frameProcessor.processFrame(frameBytes)
|
||||
|
||||
if (decodedData != null) {
|
||||
// Decode audio with Opus
|
||||
val audioData = audioCodec.decode(decodedData)
|
||||
|
||||
// Emit received data
|
||||
_receivedData.emit(audioData.toByteArray())
|
||||
}
|
||||
|
||||
// Remove processed samples
|
||||
sampleBuffer.subList(0, frameStart + frameSection.size).clear()
|
||||
}
|
||||
}
|
||||
|
||||
// Limit buffer size to prevent memory issues
|
||||
if (sampleBuffer.size > FSKConstants.SAMPLE_RATE * 2) {
|
||||
sampleBuffer.subList(0, FSKConstants.SAMPLE_RATE).clear()
|
||||
}
|
||||
}
|
||||
|
||||
_state.value = ModemState.IDLE
|
||||
}
|
||||
|
||||
delay(10)
|
||||
}
|
||||
}
|
||||
|
||||
fun release() {
|
||||
stop()
|
||||
audioRecord?.release()
|
||||
audioTrack?.release()
|
||||
audioCodec.release()
|
||||
audioRecord = null
|
||||
audioTrack = null
|
||||
}
|
||||
|
||||
// Utility extension functions
|
||||
private fun ByteArray.toShortArray(): ShortArray {
|
||||
return ShortArray(size / 2) { i ->
|
||||
((this[i * 2].toInt() and 0xFF) or
|
||||
((this[i * 2 + 1].toInt() and 0xFF) shl 8)).toShort()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ShortArray.toByteArray(): ByteArray {
|
||||
val bytes = ByteArray(size * 2)
|
||||
for (i in indices) {
|
||||
bytes[i * 2] = (this[i].toInt() and 0xFF).toByte()
|
||||
bytes[i * 2 + 1] = ((this[i].toInt() shr 8) and 0xFF).toByte()
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
}
|
@ -0,0 +1,216 @@
|
||||
package com.icing.dialer.modem
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.app.ActivityCompat
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.collect
|
||||
|
||||
class FSKModemExample(private val context: Context) {
|
||||
private val modem = FSKModem()
|
||||
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
|
||||
|
||||
fun checkPermissions(): Boolean {
|
||||
return ActivityCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.RECORD_AUDIO
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
fun startModem() {
|
||||
if (!checkPermissions()) {
|
||||
println("Audio recording permission not granted")
|
||||
return
|
||||
}
|
||||
|
||||
// Initialize modem
|
||||
modem.initialize()
|
||||
|
||||
// Set up data reception handler
|
||||
scope.launch {
|
||||
modem.receivedData.collect { data ->
|
||||
handleReceivedData(data)
|
||||
}
|
||||
}
|
||||
|
||||
// Monitor modem state
|
||||
scope.launch {
|
||||
modem.state.collect { state ->
|
||||
println("Modem state: $state")
|
||||
}
|
||||
}
|
||||
|
||||
// Start modem
|
||||
modem.start()
|
||||
}
|
||||
|
||||
fun sendTextMessage(message: String) {
|
||||
val data = message.toByteArray(Charsets.UTF_8)
|
||||
modem.sendData(data)
|
||||
}
|
||||
|
||||
fun sendBinaryData(data: ByteArray) {
|
||||
// Split large data into frames if necessary
|
||||
val maxPayloadSize = 200 // Based on Reed-Solomon configuration
|
||||
|
||||
for (i in data.indices step maxPayloadSize) {
|
||||
val chunk = data.sliceArray(
|
||||
i until minOf(i + maxPayloadSize, data.size)
|
||||
)
|
||||
modem.sendData(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleReceivedData(data: ByteArray) {
|
||||
// Handle received data
|
||||
try {
|
||||
val message = String(data, Charsets.UTF_8)
|
||||
println("Received message: $message")
|
||||
} catch (e: Exception) {
|
||||
println("Received binary data: ${data.size} bytes")
|
||||
}
|
||||
}
|
||||
|
||||
fun stopModem() {
|
||||
modem.stop()
|
||||
modem.release()
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
// Example: Voice communication
|
||||
fun startVoiceTransmission() {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
// In a real implementation, you would capture audio from microphone
|
||||
// and send it through the modem
|
||||
while (isActive) {
|
||||
// Simulated audio data (replace with actual audio capture)
|
||||
val audioFrame = ShortArray(FSKConstants.OPUS_FRAME_SIZE)
|
||||
// Fill audioFrame with audio samples...
|
||||
|
||||
// Convert to byte array and send
|
||||
val audioBytes = audioFrame.toByteArray()
|
||||
modem.sendData(audioBytes)
|
||||
|
||||
delay(20) // 20ms frames
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ShortArray.toByteArray(): ByteArray {
|
||||
val bytes = ByteArray(size * 2)
|
||||
for (i in indices) {
|
||||
bytes[i * 2] = (this[i].toInt() and 0xFF).toByte()
|
||||
bytes[i * 2 + 1] = ((this[i].toInt() shr 8) and 0xFF).toByte()
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
}
|
||||
|
||||
// Unit tests
|
||||
class FSKModemTest {
|
||||
fun testModulation() {
|
||||
val modulator = FSKModulator()
|
||||
|
||||
// Test data
|
||||
val testData = byteArrayOf(0x55, 0xAA.toByte(), 0x0F, 0xF0.toByte())
|
||||
|
||||
// Convert to symbols
|
||||
val symbols = modulator.bytesToSymbols(testData)
|
||||
println("Symbols: ${symbols.joinToString()}")
|
||||
|
||||
// Generate preamble
|
||||
val preamble = modulator.generatePreamble()
|
||||
println("Preamble length: ${preamble.size} samples")
|
||||
|
||||
// Modulate symbols
|
||||
val modulated = modulator.modulateSymbols(symbols)
|
||||
println("Modulated signal length: ${modulated.size} samples")
|
||||
|
||||
// Apply filter
|
||||
val filtered = modulator.applyRaisedCosineFilter(modulated)
|
||||
println("Filtered signal length: ${filtered.size} samples")
|
||||
}
|
||||
|
||||
fun testDemodulation() {
|
||||
val modulator = FSKModulator()
|
||||
val demodulator = FSKDemodulator()
|
||||
|
||||
// Test data
|
||||
val testData = "Hello FSK Modem!".toByteArray()
|
||||
|
||||
// Modulate
|
||||
val symbols = modulator.bytesToSymbols(testData)
|
||||
val preamble = modulator.generatePreamble()
|
||||
val modulated = modulator.modulateSymbols(symbols)
|
||||
|
||||
// Combine preamble and data
|
||||
val signal = FloatArray(preamble.size + modulated.size)
|
||||
System.arraycopy(preamble, 0, signal, 0, preamble.size)
|
||||
System.arraycopy(modulated, 0, signal, preamble.size, modulated.size)
|
||||
|
||||
// Find preamble
|
||||
val preambleIndex = demodulator.findPreamble(signal)
|
||||
println("Preamble found at index: $preambleIndex")
|
||||
|
||||
// Demodulate
|
||||
val dataStart = preambleIndex + preamble.size
|
||||
val dataSignal = signal.sliceArray(dataStart until signal.size)
|
||||
val demodSymbols = demodulator.demodulateSamples(dataSignal)
|
||||
|
||||
// Convert back to bytes
|
||||
val demodData = demodulator.symbolsToBytes(demodSymbols)
|
||||
val demodMessage = String(demodData, 0, testData.size)
|
||||
println("Demodulated message: $demodMessage")
|
||||
}
|
||||
|
||||
fun testFrameProcessing() {
|
||||
val processor = FrameProcessor()
|
||||
|
||||
// Test data
|
||||
val testData = ByteArray(200) { it.toByte() }
|
||||
|
||||
// Create frame
|
||||
val frame = processor.createFrame(testData)
|
||||
println("Frame size: ${frame.toByteArray().size} bytes")
|
||||
|
||||
// Simulate transmission (no errors)
|
||||
val frameBytes = frame.toByteArray()
|
||||
|
||||
// Process received frame
|
||||
val decoded = processor.processFrame(frameBytes)
|
||||
println("Decoded data: ${decoded?.size} bytes")
|
||||
|
||||
// Verify data integrity
|
||||
if (decoded != null && decoded.contentEquals(testData)) {
|
||||
println("Frame processing successful!")
|
||||
}
|
||||
}
|
||||
|
||||
fun testAudioCodec() {
|
||||
try {
|
||||
val codec = AudioCodec()
|
||||
|
||||
// Test audio data (sine wave)
|
||||
val testAudio = ShortArray(FSKConstants.OPUS_FRAME_SIZE) { i ->
|
||||
(32767 * kotlin.math.sin(2 * kotlin.math.PI * 440 * i / FSKConstants.SAMPLE_RATE)).toInt().toShort()
|
||||
}
|
||||
|
||||
// Encode
|
||||
val encoded = codec.encode(testAudio)
|
||||
println("Encoded size: ${encoded.size} bytes (compression ratio: ${testAudio.size * 2.0 / encoded.size})")
|
||||
|
||||
// Decode
|
||||
val decoded = codec.decode(encoded)
|
||||
println("Decoded samples: ${decoded.size}")
|
||||
|
||||
// Test packet loss handling
|
||||
val lostPacket = codec.decodeLost()
|
||||
println("Lost packet recovery: ${lostPacket.size} samples")
|
||||
|
||||
codec.release()
|
||||
} catch (e: Exception) {
|
||||
println("Audio codec test failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,138 @@
|
||||
package com.icing.dialer.modem
|
||||
|
||||
import kotlin.math.*
|
||||
|
||||
class FSKModulator {
|
||||
private var phase = 0.0
|
||||
private val symbolBuffer = mutableListOf<Int>()
|
||||
|
||||
// Generate preamble for synchronization
|
||||
fun generatePreamble(): FloatArray {
|
||||
val samples = FloatArray(FSKConstants.PREAMBLE_LENGTH * FSKConstants.SAMPLES_PER_SYMBOL)
|
||||
var sampleIndex = 0
|
||||
|
||||
// Alternating 01 10 pattern for easy detection
|
||||
for (i in 0 until FSKConstants.PREAMBLE_LENGTH) {
|
||||
val symbol = if (i % 2 == 0) 1 else 2 // Alternating 01 and 10
|
||||
val freq = getFrequencyForSymbol(symbol)
|
||||
|
||||
for (j in 0 until FSKConstants.SAMPLES_PER_SYMBOL) {
|
||||
samples[sampleIndex++] = generateSample(freq)
|
||||
}
|
||||
}
|
||||
|
||||
return samples
|
||||
}
|
||||
|
||||
// Convert bytes to 4FSK symbols (2 bits per symbol)
|
||||
fun bytesToSymbols(data: ByteArray): IntArray {
|
||||
val symbols = IntArray(data.size * 4) // 4 symbols per byte
|
||||
var symbolIndex = 0
|
||||
|
||||
for (byte in data) {
|
||||
val value = byte.toInt() and 0xFF
|
||||
// Extract 2-bit symbols from MSB to LSB
|
||||
symbols[symbolIndex++] = (value shr 6) and 0x03
|
||||
symbols[symbolIndex++] = (value shr 4) and 0x03
|
||||
symbols[symbolIndex++] = (value shr 2) and 0x03
|
||||
symbols[symbolIndex++] = value and 0x03
|
||||
}
|
||||
|
||||
return symbols
|
||||
}
|
||||
|
||||
// Modulate symbols to audio samples with smooth transitions
|
||||
fun modulateSymbols(symbols: IntArray): FloatArray {
|
||||
val samples = FloatArray(symbols.size * FSKConstants.SAMPLES_PER_SYMBOL)
|
||||
var sampleIndex = 0
|
||||
|
||||
for (i in symbols.indices) {
|
||||
val currentFreq = getFrequencyForSymbol(symbols[i])
|
||||
val nextFreq = if (i < symbols.size - 1) {
|
||||
getFrequencyForSymbol(symbols[i + 1])
|
||||
} else {
|
||||
currentFreq
|
||||
}
|
||||
|
||||
// Generate samples with smooth frequency transition
|
||||
for (j in 0 until FSKConstants.SAMPLES_PER_SYMBOL) {
|
||||
val progress = j.toFloat() / FSKConstants.SAMPLES_PER_SYMBOL
|
||||
val freq = if (j >= FSKConstants.SAMPLES_PER_SYMBOL - 2) {
|
||||
// Smooth transition in last 2 samples
|
||||
currentFreq * (1 - progress) + nextFreq * progress
|
||||
} else {
|
||||
currentFreq
|
||||
}
|
||||
|
||||
samples[sampleIndex++] = generateSample(freq)
|
||||
}
|
||||
}
|
||||
|
||||
return samples
|
||||
}
|
||||
|
||||
// Generate single sample with continuous phase
|
||||
private fun generateSample(frequency: Double): Float {
|
||||
val sample = sin(2.0 * PI * phase).toFloat()
|
||||
phase += frequency / FSKConstants.SAMPLE_RATE
|
||||
|
||||
// Keep phase in [0, 1] range to prevent precision loss
|
||||
if (phase >= 1.0) {
|
||||
phase -= 1.0
|
||||
}
|
||||
|
||||
return sample
|
||||
}
|
||||
|
||||
// Map symbol to frequency
|
||||
private fun getFrequencyForSymbol(symbol: Int): Double {
|
||||
return when (symbol) {
|
||||
0 -> FSKConstants.FREQ_00
|
||||
1 -> FSKConstants.FREQ_01
|
||||
2 -> FSKConstants.FREQ_10
|
||||
3 -> FSKConstants.FREQ_11
|
||||
else -> throw IllegalArgumentException("Invalid symbol: $symbol")
|
||||
}
|
||||
}
|
||||
|
||||
// Apply raised cosine filter for spectral shaping
|
||||
fun applyRaisedCosineFilter(samples: FloatArray): FloatArray {
|
||||
val alpha = 0.35 // Roll-off factor
|
||||
val filteredSamples = FloatArray(samples.size)
|
||||
val filterLength = 65 // Filter taps
|
||||
val halfLength = filterLength / 2
|
||||
|
||||
for (i in samples.indices) {
|
||||
var sum = 0.0f
|
||||
|
||||
for (j in -halfLength..halfLength) {
|
||||
val sampleIndex = i + j
|
||||
if (sampleIndex in samples.indices) {
|
||||
val t = j.toFloat() / FSKConstants.SAMPLES_PER_SYMBOL
|
||||
val h = if (abs(t) < 1e-6) {
|
||||
1.0f
|
||||
} else if (abs(t) == 0.5f / alpha) {
|
||||
(PI / 4) * sinc(0.5f / alpha).toFloat()
|
||||
} else {
|
||||
sinc(t) * cos(PI * alpha * t) / (1 - 4 * alpha * alpha * t * t)
|
||||
}
|
||||
sum += samples[sampleIndex] * h
|
||||
}
|
||||
}
|
||||
|
||||
filteredSamples[i] = sum * 0.8f // Scale to prevent clipping
|
||||
}
|
||||
|
||||
return filteredSamples
|
||||
}
|
||||
|
||||
private fun sinc(x: Float): Float {
|
||||
return if (abs(x) < 1e-6) 1.0f else (sin(PI * x) / (PI * x)).toFloat()
|
||||
}
|
||||
|
||||
// Reset modulator state
|
||||
fun reset() {
|
||||
phase = 0.0
|
||||
symbolBuffer.clear()
|
||||
}
|
||||
}
|
@ -0,0 +1,245 @@
|
||||
package com.icing.dialer.modem
|
||||
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.zip.CRC32
|
||||
|
||||
class FrameProcessor {
|
||||
private val crc32 = CRC32()
|
||||
|
||||
data class Frame(
|
||||
val syncWord: Int = 0x7E6B2840.toInt(),
|
||||
val sequenceNumber: Int,
|
||||
val payloadLength: Int,
|
||||
val payload: ByteArray,
|
||||
val crc: Long
|
||||
) {
|
||||
fun toByteArray(): ByteArray {
|
||||
val buffer = ByteBuffer.allocate(12 + payload.size + 4)
|
||||
buffer.putInt(syncWord)
|
||||
buffer.putInt(sequenceNumber)
|
||||
buffer.putInt(payloadLength)
|
||||
buffer.put(payload)
|
||||
buffer.putInt(crc.toInt())
|
||||
return buffer.array()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromByteArray(data: ByteArray): Frame? {
|
||||
if (data.size < 16) return null
|
||||
|
||||
val buffer = ByteBuffer.wrap(data)
|
||||
val syncWord = buffer.getInt()
|
||||
|
||||
if (syncWord != 0x7E6B2840.toInt()) return null
|
||||
|
||||
val sequenceNumber = buffer.getInt()
|
||||
val payloadLength = buffer.getInt()
|
||||
|
||||
if (data.size < 16 + payloadLength) return null
|
||||
|
||||
val payload = ByteArray(payloadLength)
|
||||
buffer.get(payload)
|
||||
val crc = buffer.getInt().toLong() and 0xFFFFFFFFL
|
||||
|
||||
return Frame(syncWord, sequenceNumber, payloadLength, payload, crc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reed-Solomon error correction
|
||||
class ReedSolomon(private val dataBytes: Int, private val parityBytes: Int) {
|
||||
private val totalBytes = dataBytes + parityBytes
|
||||
private val gfPoly = 0x11D // Primitive polynomial for GF(256)
|
||||
private val gfSize = 256
|
||||
|
||||
private val logTable = IntArray(gfSize)
|
||||
private val expTable = IntArray(gfSize * 2)
|
||||
|
||||
init {
|
||||
// Initialize Galois Field tables
|
||||
var x = 1
|
||||
for (i in 0 until gfSize - 1) {
|
||||
expTable[i] = x
|
||||
logTable[x] = i
|
||||
x = x shl 1
|
||||
if (x >= gfSize) {
|
||||
x = x xor gfPoly
|
||||
}
|
||||
}
|
||||
expTable[gfSize - 1] = expTable[0]
|
||||
|
||||
// Double the exp table for convenience
|
||||
for (i in gfSize until gfSize * 2) {
|
||||
expTable[i] = expTable[i - gfSize]
|
||||
}
|
||||
}
|
||||
|
||||
fun encode(data: ByteArray): ByteArray {
|
||||
if (data.size != dataBytes) {
|
||||
throw IllegalArgumentException("Data size must be $dataBytes bytes")
|
||||
}
|
||||
|
||||
val encoded = ByteArray(totalBytes)
|
||||
System.arraycopy(data, 0, encoded, 0, dataBytes)
|
||||
|
||||
// Generate parity bytes
|
||||
val generator = generateGeneratorPolynomial()
|
||||
|
||||
for (i in 0 until dataBytes) {
|
||||
val coef = encoded[i].toInt() and 0xFF
|
||||
if (coef != 0) {
|
||||
for (j in 1..parityBytes) {
|
||||
encoded[i + j] = (encoded[i + j].toInt() xor
|
||||
gfMultiply(generator[j], coef)).toByte()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Move parity bytes to the end
|
||||
System.arraycopy(encoded, dataBytes, encoded, dataBytes, parityBytes)
|
||||
|
||||
return encoded
|
||||
}
|
||||
|
||||
fun decode(received: ByteArray): ByteArray? {
|
||||
if (received.size != totalBytes) return null
|
||||
|
||||
val syndromes = calculateSyndromes(received)
|
||||
if (syndromes.all { it == 0 }) {
|
||||
// No errors
|
||||
return received.copyOf(dataBytes)
|
||||
}
|
||||
|
||||
// Berlekamp-Massey algorithm to find error locator polynomial
|
||||
val errorLocator = findErrorLocator(syndromes)
|
||||
val errorPositions = findErrorPositions(errorLocator)
|
||||
|
||||
if (errorPositions.size > parityBytes / 2) {
|
||||
// Too many errors to correct
|
||||
return null
|
||||
}
|
||||
|
||||
// Forney algorithm to find error values
|
||||
val errorValues = findErrorValues(syndromes, errorLocator, errorPositions)
|
||||
|
||||
// Correct errors
|
||||
val corrected = received.copyOf()
|
||||
for (i in errorPositions.indices) {
|
||||
corrected[errorPositions[i]] =
|
||||
(corrected[errorPositions[i]].toInt() xor errorValues[i]).toByte()
|
||||
}
|
||||
|
||||
return corrected.copyOf(dataBytes)
|
||||
}
|
||||
|
||||
private fun gfMultiply(a: Int, b: Int): Int {
|
||||
if (a == 0 || b == 0) return 0
|
||||
return expTable[logTable[a] + logTable[b]]
|
||||
}
|
||||
|
||||
private fun generateGeneratorPolynomial(): IntArray {
|
||||
val generator = IntArray(parityBytes + 1)
|
||||
generator[0] = 1
|
||||
|
||||
for (i in 0 until parityBytes) {
|
||||
generator[i + 1] = 1
|
||||
for (j in i downTo 1) {
|
||||
generator[j] = generator[j - 1] xor gfMultiply(generator[j], expTable[i])
|
||||
}
|
||||
generator[0] = gfMultiply(generator[0], expTable[i])
|
||||
}
|
||||
|
||||
return generator
|
||||
}
|
||||
|
||||
private fun calculateSyndromes(received: ByteArray): IntArray {
|
||||
val syndromes = IntArray(parityBytes)
|
||||
|
||||
for (i in 0 until parityBytes) {
|
||||
var syndrome = 0
|
||||
for (j in 0 until totalBytes) {
|
||||
syndrome = syndrome xor gfMultiply(received[j].toInt() and 0xFF,
|
||||
expTable[(j * (i + 1)) % (gfSize - 1)])
|
||||
}
|
||||
syndromes[i] = syndrome
|
||||
}
|
||||
|
||||
return syndromes
|
||||
}
|
||||
|
||||
private fun findErrorLocator(syndromes: IntArray): IntArray {
|
||||
// Simplified Berlekamp-Massey for demonstration
|
||||
// In production, use a full implementation
|
||||
val errorLocator = IntArray(parityBytes / 2 + 1)
|
||||
errorLocator[0] = 1
|
||||
return errorLocator
|
||||
}
|
||||
|
||||
private fun findErrorPositions(errorLocator: IntArray): IntArray {
|
||||
// Chien search
|
||||
val positions = mutableListOf<Int>()
|
||||
|
||||
for (i in 0 until totalBytes) {
|
||||
var sum = 0
|
||||
for (j in errorLocator.indices) {
|
||||
sum = sum xor gfMultiply(errorLocator[j],
|
||||
expTable[(j * i) % (gfSize - 1)])
|
||||
}
|
||||
if (sum == 0) {
|
||||
positions.add(totalBytes - 1 - i)
|
||||
}
|
||||
}
|
||||
|
||||
return positions.toIntArray()
|
||||
}
|
||||
|
||||
private fun findErrorValues(syndromes: IntArray, errorLocator: IntArray,
|
||||
errorPositions: IntArray): IntArray {
|
||||
// Simplified Forney algorithm
|
||||
val errorValues = IntArray(errorPositions.size)
|
||||
// Implementation would go here
|
||||
return errorValues
|
||||
}
|
||||
}
|
||||
|
||||
private var sequenceNumber = 0
|
||||
private val rs = ReedSolomon(200, 56) // (256, 200) Reed-Solomon code
|
||||
|
||||
fun createFrame(data: ByteArray): Frame {
|
||||
// Apply Reed-Solomon encoding
|
||||
val encoded = rs.encode(data)
|
||||
|
||||
// Calculate CRC32
|
||||
crc32.reset()
|
||||
crc32.update(encoded)
|
||||
val crc = crc32.value
|
||||
|
||||
val frame = Frame(
|
||||
sequenceNumber = sequenceNumber++,
|
||||
payloadLength = encoded.size,
|
||||
payload = encoded,
|
||||
crc = crc
|
||||
)
|
||||
|
||||
return frame
|
||||
}
|
||||
|
||||
fun processFrame(frameData: ByteArray): ByteArray? {
|
||||
val frame = Frame.fromByteArray(frameData) ?: return null
|
||||
|
||||
// Verify CRC
|
||||
crc32.reset()
|
||||
crc32.update(frame.payload)
|
||||
if (crc32.value != frame.crc) {
|
||||
// CRC mismatch, try error correction
|
||||
return null
|
||||
}
|
||||
|
||||
// Decode Reed-Solomon
|
||||
return rs.decode(frame.payload)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
sequenceNumber = 0
|
||||
}
|
||||
}
|
@ -13,35 +13,14 @@ import android.Manifest
|
||||
object CallService {
|
||||
private val TAG = "CallService"
|
||||
|
||||
fun makeGsmCall(context: Context, phoneNumber: String, simSlot: Int = 0): Boolean {
|
||||
fun makeGsmCall(context: Context, phoneNumber: String): Boolean {
|
||||
return try {
|
||||
val telecomManager = context.getSystemService(Context.TELECOM_SERVICE) as TelecomManager
|
||||
val uri = Uri.parse("tel:$phoneNumber")
|
||||
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CALL_PHONE) == PackageManager.PERMISSION_GRANTED) {
|
||||
// Get available phone accounts (SIM cards)
|
||||
val phoneAccounts = telecomManager.callCapablePhoneAccounts
|
||||
|
||||
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)")
|
||||
}
|
||||
telecomManager.placeCall(uri, Bundle())
|
||||
Log.d(TAG, "Initiated call to $phoneNumber")
|
||||
true
|
||||
} else {
|
||||
Log.e(TAG, "CALL_PHONE permission not granted")
|
||||
|
@ -127,7 +127,7 @@ class MyInCallService : InCallService() {
|
||||
}
|
||||
call.registerCallback(callCallback)
|
||||
if (callAudioState != null) {
|
||||
val audioState = callAudioState
|
||||
val audioState = callAudioState
|
||||
channel?.invokeMethod("audioStateChanged", mapOf(
|
||||
"route" to audioState.route,
|
||||
"muted" to audioState.isMuted,
|
||||
|
@ -3,10 +3,15 @@
|
||||
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
|
||||
<uses-permission android:name="android.permission.CALL_PHONE" />
|
||||
<uses-permission android:name="android.permission.SEND_SMS" />
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-permission android:name="android.permission.READ_BLOCKED_NUMBERS" />
|
||||
<uses-permission android:name="android.permission.WRITE_BLOCKED_NUMBERS" />
|
||||
<uses-permission android:name="android.permission.READ_CALL_LOG"/>
|
||||
<uses-permission android:name="android.permission.READ_PHONE_STATE"/>
|
||||
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
|
||||
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
|
||||
<uses-permission android:name="android.permission.PROCESS_OUTGOING_CALLS" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.telephony" android:required="true" />
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
|
@ -2,4 +2,4 @@ org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryErro
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
dev.steenbakker.mobile_scanner.useUnbundled=true
|
||||
# org.gradle.java.home=/usr/lib/jvm/java-17-openjdk-amd64
|
||||
org.gradle.java.home=/usr/lib/jvm/java-17-openjdk-amd64
|
||||
|
@ -1,19 +1,15 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../presentation/features/call/call_page.dart';
|
||||
import '../../presentation/features/call/incoming_call_page.dart'; // Import the new page
|
||||
import 'contact_service.dart';
|
||||
// Import for history update callback
|
||||
import '../../presentation/features/history/history_page.dart';
|
||||
|
||||
class CallService {
|
||||
static const MethodChannel _channel = MethodChannel('call_service');
|
||||
static String? currentPhoneNumber;
|
||||
static String? currentDisplayName;
|
||||
static Uint8List? currentThumbnail;
|
||||
static int? currentSimSlot; // Track which SIM slot is being used
|
||||
static bool _isCallPageVisible = false;
|
||||
static Map<String, dynamic>? _pendingCall;
|
||||
static bool wasPhoneLocked = false;
|
||||
@ -21,43 +17,18 @@ class CallService {
|
||||
static bool _isNavigating = false;
|
||||
final ContactService _contactService = ContactService();
|
||||
final _callStateController = StreamController<String>.broadcast();
|
||||
final _audioStateController =
|
||||
StreamController<Map<String, dynamic>>.broadcast();
|
||||
final _simStateController = StreamController<int?>.broadcast();
|
||||
final _audioStateController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
Map<String, dynamic>? _currentAudioState;
|
||||
|
||||
static final GlobalKey<NavigatorState> navigatorKey =
|
||||
GlobalKey<NavigatorState>();
|
||||
static final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
Stream<String> get callStateStream => _callStateController.stream;
|
||||
Stream<Map<String, dynamic>> get audioStateStream =>
|
||||
_audioStateController.stream;
|
||||
Stream<int?> get simStateStream => _simStateController.stream;
|
||||
Stream<Map<String, dynamic>> get audioStateStream => _audioStateController.stream;
|
||||
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() {
|
||||
_channel.setMethodCallHandler((call) async {
|
||||
print(
|
||||
'CallService: Handling method call: ${call.method}, with args: ${call.arguments}');
|
||||
print('CallService: Handling method call: ${call.method}, with args: ${call.arguments}');
|
||||
switch (call.method) {
|
||||
case "callAdded":
|
||||
final phoneNumber = call.arguments["callId"] as String?;
|
||||
@ -66,18 +37,15 @@ class CallService {
|
||||
print('CallService: Invalid callAdded args: $call.arguments');
|
||||
return;
|
||||
}
|
||||
final decodedPhoneNumber =
|
||||
Uri.decodeComponent(phoneNumber.replaceFirst('tel:', ''));
|
||||
final decodedPhoneNumber = Uri.decodeComponent(phoneNumber.replaceFirst('tel:', ''));
|
||||
print('CallService: Decoded phone number: $decodedPhoneNumber');
|
||||
if (_activeCallNumber != decodedPhoneNumber) {
|
||||
currentPhoneNumber = decodedPhoneNumber;
|
||||
if (currentDisplayName == null ||
|
||||
currentDisplayName == currentPhoneNumber) {
|
||||
if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) {
|
||||
await _fetchContactInfo(decodedPhoneNumber);
|
||||
}
|
||||
}
|
||||
print(
|
||||
'CallService: Call added, number: $currentPhoneNumber, displayName: $currentDisplayName, state: $state');
|
||||
print('CallService: Call added, number: $currentPhoneNumber, displayName: $currentDisplayName, state: $state');
|
||||
_callStateController.add(state);
|
||||
if (state == "ringing") {
|
||||
_handleIncomingCall(decodedPhoneNumber);
|
||||
@ -89,63 +57,30 @@ class CallService {
|
||||
final state = call.arguments["state"] as String?;
|
||||
wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false;
|
||||
if (state == null) {
|
||||
print(
|
||||
'CallService: Invalid callStateChanged args: $call.arguments');
|
||||
print('CallService: Invalid callStateChanged args: $call.arguments');
|
||||
return;
|
||||
}
|
||||
print(
|
||||
'CallService: State changed to $state, wasPhoneLocked: $wasPhoneLocked');
|
||||
print('CallService: State changed to $state, wasPhoneLocked: $wasPhoneLocked');
|
||||
_callStateController.add(state);
|
||||
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();
|
||||
|
||||
// Reset manual hangup flag after successful page close
|
||||
if (_manualHangupFlag) {
|
||||
print(
|
||||
'CallService: Resetting manual hangup flag after page close');
|
||||
_manualHangupFlag = false;
|
||||
}
|
||||
if (wasPhoneLocked) {
|
||||
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;
|
||||
// Handle pending SIM switch after call is disconnected
|
||||
_handlePendingSimSwitch();
|
||||
} else if (state == "active" || state == "dialing") {
|
||||
final phoneNumber = call.arguments["callId"] as String?;
|
||||
if (phoneNumber != null &&
|
||||
_activeCallNumber !=
|
||||
Uri.decodeComponent(phoneNumber.replaceFirst('tel:', ''))) {
|
||||
currentPhoneNumber =
|
||||
Uri.decodeComponent(phoneNumber.replaceFirst('tel:', ''));
|
||||
if (currentDisplayName == null ||
|
||||
currentDisplayName == currentPhoneNumber) {
|
||||
if (phoneNumber != null && _activeCallNumber != Uri.decodeComponent(phoneNumber.replaceFirst('tel:', ''))) {
|
||||
currentPhoneNumber = Uri.decodeComponent(phoneNumber.replaceFirst('tel:', ''));
|
||||
if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) {
|
||||
await _fetchContactInfo(currentPhoneNumber!);
|
||||
}
|
||||
} else if (currentPhoneNumber != null &&
|
||||
_activeCallNumber != currentPhoneNumber) {
|
||||
if (currentDisplayName == null ||
|
||||
currentDisplayName == currentPhoneNumber) {
|
||||
} else if (currentPhoneNumber != null && _activeCallNumber != currentPhoneNumber) {
|
||||
if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) {
|
||||
await _fetchContactInfo(currentPhoneNumber!);
|
||||
}
|
||||
} else {
|
||||
print(
|
||||
'CallService: Skipping fetch, active call: $_activeCallNumber, current: $currentPhoneNumber, displayName: $currentDisplayName');
|
||||
print('CallService: Skipping fetch, active call: $_activeCallNumber, current: $currentPhoneNumber, displayName: $currentDisplayName');
|
||||
}
|
||||
_navigateToCallPage();
|
||||
} else if (state == "ringing") {
|
||||
@ -154,12 +89,10 @@ class CallService {
|
||||
print('CallService: Invalid ringing callId: $call.arguments');
|
||||
return;
|
||||
}
|
||||
final decodedPhoneNumber =
|
||||
Uri.decodeComponent(phoneNumber.replaceFirst('tel:', ''));
|
||||
final decodedPhoneNumber = Uri.decodeComponent(phoneNumber.replaceFirst('tel:', ''));
|
||||
if (_activeCallNumber != decodedPhoneNumber) {
|
||||
currentPhoneNumber = decodedPhoneNumber;
|
||||
if (currentDisplayName == null ||
|
||||
currentDisplayName == currentPhoneNumber) {
|
||||
if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) {
|
||||
await _fetchContactInfo(decodedPhoneNumber);
|
||||
}
|
||||
}
|
||||
@ -169,65 +102,38 @@ class CallService {
|
||||
case "callEnded":
|
||||
case "callRemoved":
|
||||
wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false;
|
||||
print('CallService: ========== CALL ENDED/REMOVED ==========');
|
||||
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');
|
||||
print('CallService: Call ended/removed, wasPhoneLocked: $wasPhoneLocked');
|
||||
_closeCallPage();
|
||||
|
||||
// Reset manual hangup flag after closing page
|
||||
if (_manualHangupFlag) {
|
||||
print(
|
||||
'CallService: Resetting manual hangup flag after callEnded');
|
||||
_manualHangupFlag = false;
|
||||
}
|
||||
if (wasPhoneLocked) {
|
||||
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;
|
||||
currentDisplayName = null;
|
||||
currentThumbnail = null;
|
||||
currentSimSlot = null; // Reset SIM slot when call ends
|
||||
_simStateController.add(null); // Notify UI that SIM is cleared
|
||||
_activeCallNumber = null;
|
||||
break;
|
||||
case "incomingCallFromNotification":
|
||||
final phoneNumber = call.arguments["phoneNumber"] as String?;
|
||||
wasPhoneLocked = call.arguments["wasPhoneLocked"] as bool? ?? false;
|
||||
if (phoneNumber == null) {
|
||||
print(
|
||||
'CallService: Invalid incomingCallFromNotification args: $call.arguments');
|
||||
print('CallService: Invalid incomingCallFromNotification args: $call.arguments');
|
||||
return;
|
||||
}
|
||||
final decodedPhoneNumber = Uri.decodeComponent(phoneNumber);
|
||||
if (_activeCallNumber != decodedPhoneNumber) {
|
||||
currentPhoneNumber = decodedPhoneNumber;
|
||||
if (currentDisplayName == null ||
|
||||
currentDisplayName == currentPhoneNumber) {
|
||||
if (currentDisplayName == null || currentDisplayName == currentPhoneNumber) {
|
||||
await _fetchContactInfo(decodedPhoneNumber);
|
||||
}
|
||||
}
|
||||
print(
|
||||
'CallService: Incoming call from notification: $decodedPhoneNumber, displayName: $currentDisplayName, wasPhoneLocked: $wasPhoneLocked');
|
||||
print('CallService: Incoming call from notification: $decodedPhoneNumber, displayName: $currentDisplayName, wasPhoneLocked: $wasPhoneLocked');
|
||||
_handleIncomingCall(decodedPhoneNumber);
|
||||
break;
|
||||
case "audioStateChanged":
|
||||
final route = call.arguments["route"] as int?;
|
||||
final muted = call.arguments["muted"] as bool?;
|
||||
final speaker = call.arguments["speaker"] as bool?;
|
||||
print(
|
||||
'CallService: Audio state changed, route: $route, muted: $muted, speaker: $speaker');
|
||||
print('CallService: Audio state changed, route: $route, muted: $muted, speaker: $speaker');
|
||||
final audioState = {
|
||||
"route": route,
|
||||
"muted": muted,
|
||||
@ -251,8 +157,7 @@ class CallService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> muteCall(BuildContext context,
|
||||
{required bool mute}) async {
|
||||
Future<Map<String, dynamic>> muteCall(BuildContext context, {required bool mute}) async {
|
||||
try {
|
||||
print('CallService: Toggling mute to $mute');
|
||||
final result = await _channel.invokeMethod('muteCall', {'mute': mute});
|
||||
@ -273,12 +178,10 @@ class CallService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> speakerCall(BuildContext context,
|
||||
{required bool speaker}) async {
|
||||
Future<Map<String, dynamic>> speakerCall(BuildContext context, {required bool speaker}) async {
|
||||
try {
|
||||
print('CallService: Toggling speaker to $speaker');
|
||||
final result =
|
||||
await _channel.invokeMethod('speakerCall', {'speaker': speaker});
|
||||
final result = await _channel.invokeMethod('speakerCall', {'speaker': speaker});
|
||||
print('CallService: speakerCall result: $result');
|
||||
return Map<String, dynamic>.from(result);
|
||||
} catch (e) {
|
||||
@ -305,21 +208,18 @@ class CallService {
|
||||
for (var contact in contacts) {
|
||||
for (var phone in contact.phones) {
|
||||
final normalizedContactNumber = _normalizePhoneNumber(phone.number);
|
||||
print(
|
||||
'CallService: Comparing $normalizedPhoneNumber with contact number $normalizedContactNumber');
|
||||
print('CallService: Comparing $normalizedPhoneNumber with contact number $normalizedContactNumber');
|
||||
if (normalizedContactNumber == normalizedPhoneNumber) {
|
||||
currentDisplayName = contact.displayName;
|
||||
currentThumbnail = contact.thumbnail;
|
||||
print(
|
||||
'CallService: Found contact - displayName: $currentDisplayName, thumbnail: ${currentThumbnail != null ? "present" : "null"}');
|
||||
print('CallService: Found contact - displayName: $currentDisplayName, thumbnail: ${currentThumbnail != null ? "present" : "null"}');
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
currentDisplayName = phoneNumber;
|
||||
currentThumbnail = null;
|
||||
print(
|
||||
'CallService: No contact match, using phoneNumber as displayName: $currentDisplayName');
|
||||
print('CallService: No contact match, using phoneNumber as displayName: $currentDisplayName');
|
||||
} catch (e) {
|
||||
print('CallService: Error fetching contact info: $e');
|
||||
currentDisplayName = phoneNumber;
|
||||
@ -328,23 +228,19 @@ class CallService {
|
||||
}
|
||||
|
||||
String _normalizePhoneNumber(String number) {
|
||||
return number
|
||||
.replaceAll(RegExp(r'[\s\-\(\)]'), '')
|
||||
.replaceFirst(RegExp(r'^\+'), '');
|
||||
return number.replaceAll(RegExp(r'[\s\-\(\)]'), '').replaceFirst(RegExp(r'^\+'), '');
|
||||
}
|
||||
|
||||
void _handleIncomingCall(String phoneNumber) {
|
||||
if (_activeCallNumber == phoneNumber && _isCallPageVisible) {
|
||||
print(
|
||||
'CallService: Incoming call for $phoneNumber already active, skipping');
|
||||
print('CallService: Incoming call for $phoneNumber already active, skipping');
|
||||
return;
|
||||
}
|
||||
_activeCallNumber = phoneNumber;
|
||||
|
||||
final context = navigatorKey.currentContext;
|
||||
if (context == null) {
|
||||
print(
|
||||
'CallService: Context is null, queuing incoming call: $phoneNumber');
|
||||
print('CallService: Context is null, queuing incoming call: $phoneNumber');
|
||||
_pendingCall = {"phoneNumber": phoneNumber};
|
||||
Future.delayed(Duration(milliseconds: 500), () => _checkPendingCall());
|
||||
} else {
|
||||
@ -360,8 +256,7 @@ class CallService {
|
||||
|
||||
final phoneNumber = _pendingCall!["phoneNumber"];
|
||||
if (_activeCallNumber == phoneNumber && _isCallPageVisible) {
|
||||
print(
|
||||
'CallService: Pending call for $phoneNumber already active, clearing');
|
||||
print('CallService: Pending call for $phoneNumber already active, clearing');
|
||||
_pendingCall = null;
|
||||
return;
|
||||
}
|
||||
@ -394,32 +289,24 @@ class CallService {
|
||||
return;
|
||||
}
|
||||
final currentRoute = ModalRoute.of(context)?.settings.name ?? 'unknown';
|
||||
print(
|
||||
'CallService: Navigating to CallPage, Visible: $_isCallPageVisible, Current Route: $currentRoute, Active Call: $_activeCallNumber, DisplayName: $currentDisplayName');
|
||||
if (_isCallPageVisible &&
|
||||
currentRoute == '/call' &&
|
||||
_activeCallNumber == currentPhoneNumber) {
|
||||
print(
|
||||
'CallService: CallPage already visible for $_activeCallNumber, skipping navigation');
|
||||
print('CallService: Navigating to CallPage, Visible: $_isCallPageVisible, Current Route: $currentRoute, Active Call: $_activeCallNumber, DisplayName: $currentDisplayName');
|
||||
if (_isCallPageVisible && currentRoute == '/call' && _activeCallNumber == currentPhoneNumber) {
|
||||
print('CallService: CallPage already visible for $_activeCallNumber, skipping navigation');
|
||||
_isNavigating = false;
|
||||
return;
|
||||
}
|
||||
if (_isCallPageVisible &&
|
||||
currentRoute == '/incoming_call' &&
|
||||
_activeCallNumber == currentPhoneNumber) {
|
||||
print(
|
||||
'CallService: Popping IncomingCallPage before navigating to CallPage');
|
||||
if (_isCallPageVisible && currentRoute == '/incoming_call' && _activeCallNumber == currentPhoneNumber) {
|
||||
print('CallService: Popping IncomingCallPage before navigating to CallPage');
|
||||
Navigator.pop(context);
|
||||
_isCallPageVisible = false;
|
||||
}
|
||||
if (currentPhoneNumber == null) {
|
||||
print(
|
||||
'CallService: Cannot navigate to CallPage, currentPhoneNumber is null');
|
||||
print('CallService: Cannot navigate to CallPage, currentPhoneNumber is null');
|
||||
_isNavigating = false;
|
||||
return;
|
||||
}
|
||||
_activeCallNumber = currentPhoneNumber;
|
||||
Navigator.push(
|
||||
Navigator.pushReplacement(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
settings: const RouteSettings(name: '/call'),
|
||||
@ -445,13 +332,9 @@ class CallService {
|
||||
_isNavigating = true;
|
||||
|
||||
final currentRoute = ModalRoute.of(context)?.settings.name ?? 'unknown';
|
||||
print(
|
||||
'CallService: Navigating to IncomingCallPage, Visible: $_isCallPageVisible, Current Route: $currentRoute, Active Call: $_activeCallNumber, DisplayName: $currentDisplayName');
|
||||
if (_isCallPageVisible &&
|
||||
currentRoute == '/incoming_call' &&
|
||||
_activeCallNumber == currentPhoneNumber) {
|
||||
print(
|
||||
'CallService: IncomingCallPage already visible for $_activeCallNumber, skipping navigation');
|
||||
print('CallService: Navigating to IncomingCallPage, Visible: $_isCallPageVisible, Current Route: $currentRoute, Active Call: $_activeCallNumber, DisplayName: $currentDisplayName');
|
||||
if (_isCallPageVisible && currentRoute == '/incoming_call' && _activeCallNumber == currentPhoneNumber) {
|
||||
print('CallService: IncomingCallPage already visible for $_activeCallNumber, skipping navigation');
|
||||
_isNavigating = false;
|
||||
return;
|
||||
}
|
||||
@ -461,8 +344,7 @@ class CallService {
|
||||
return;
|
||||
}
|
||||
if (currentPhoneNumber == null) {
|
||||
print(
|
||||
'CallService: Cannot navigate to IncomingCallPage, currentPhoneNumber is null');
|
||||
print('CallService: Cannot navigate to IncomingCallPage, currentPhoneNumber is null');
|
||||
_isNavigating = false;
|
||||
return;
|
||||
}
|
||||
@ -479,8 +361,7 @@ class CallService {
|
||||
).then((_) {
|
||||
_isCallPageVisible = false;
|
||||
_isNavigating = false;
|
||||
print(
|
||||
'CallService: IncomingCallPage popped, _isCallPageVisible set to false');
|
||||
print('CallService: IncomingCallPage popped, _isCallPageVisible set to false');
|
||||
});
|
||||
_isCallPageVisible = true;
|
||||
}
|
||||
@ -491,31 +372,13 @@ class CallService {
|
||||
print('CallService: Cannot close page, context is null');
|
||||
return;
|
||||
}
|
||||
|
||||
// Only attempt to close if a call page is actually visible
|
||||
if (!_isCallPageVisible) {
|
||||
print('CallService: Call page already closed');
|
||||
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);
|
||||
print('CallService: Closing call page, _isCallPageVisible: $_isCallPageVisible');
|
||||
if (Navigator.canPop(context)) {
|
||||
print('CallService: Popping call page');
|
||||
Navigator.pop(context);
|
||||
_isCallPageVisible = false;
|
||||
print('CallService: Used popUntil to return to home page');
|
||||
} catch (e) {
|
||||
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;
|
||||
}
|
||||
} else {
|
||||
print('CallService: No page to pop');
|
||||
}
|
||||
_activeCallNumber = null;
|
||||
}
|
||||
@ -525,54 +388,20 @@ class CallService {
|
||||
required String phoneNumber,
|
||||
String? displayName,
|
||||
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 {
|
||||
try {
|
||||
if (_activeCallNumber == phoneNumber && _isCallPageVisible) {
|
||||
print('CallService: Call already active for $phoneNumber, skipping');
|
||||
return {
|
||||
"status": "already_active",
|
||||
"message": "Call already in progress"
|
||||
};
|
||||
return {"status": "already_active", "message": "Call already in progress"};
|
||||
}
|
||||
currentPhoneNumber = phoneNumber;
|
||||
currentDisplayName = displayName ?? phoneNumber;
|
||||
currentThumbnail = thumbnail;
|
||||
currentSimSlot = simSlot; // Track the SIM slot being used
|
||||
_simStateController.add(simSlot); // Notify UI of SIM change
|
||||
if (displayName == null || thumbnail == null) {
|
||||
await _fetchContactInfo(phoneNumber);
|
||||
}
|
||||
print(
|
||||
'CallService: Making GSM call to $phoneNumber, displayName: $currentDisplayName, simSlot: $simSlot');
|
||||
final result = await _channel.invokeMethod(
|
||||
'makeGsmCall', {"phoneNumber": phoneNumber, "simSlot": simSlot});
|
||||
print('CallService: Making GSM call to $phoneNumber, displayName: $currentDisplayName');
|
||||
final result = await _channel.invokeMethod('makeGsmCall', {"phoneNumber": phoneNumber});
|
||||
print('CallService: makeGsmCall result: $result');
|
||||
final resultMap = Map<String, dynamic>.from(result as Map);
|
||||
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 {
|
||||
try {
|
||||
print('CallService: ========== HANGUP INITIATED ==========');
|
||||
print('CallService: _pendingSimSwitch: ${_pendingSimSwitch != null}');
|
||||
print('CallService: _manualHangupFlag: $_manualHangupFlag');
|
||||
print('CallService: _isCallPageVisible: $_isCallPageVisible');
|
||||
|
||||
print('CallService: Hanging up call');
|
||||
final result = await _channel.invokeMethod('hangUpCall');
|
||||
print('CallService: hangUpCall result: $result');
|
||||
final resultMap = Map<String, dynamic>.from(result as Map);
|
||||
|
||||
if (resultMap["status"] != "ended") {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
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;
|
||||
} catch (e) {
|
||||
print("CallService: Error hanging up call: $e");
|
||||
@ -633,88 +439,4 @@ class CallService {
|
||||
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/obfuscate_service.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:sim_data_new/sim_data.dart';
|
||||
|
||||
class CallPage extends StatefulWidget {
|
||||
final String displayName;
|
||||
@ -37,57 +35,15 @@ class _CallPageState extends State<CallPage> {
|
||||
String _callStatus = "Calling...";
|
||||
StreamSubscription<String>? _callStateSubscription;
|
||||
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;
|
||||
|
||||
// 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
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkInitialCallState();
|
||||
_listenToCallState();
|
||||
_listenToAudioState();
|
||||
_listenToSimState();
|
||||
_updateSimName(CallService.getCurrentSimSlot); // Initial SIM name
|
||||
_setInitialAudioState();
|
||||
}
|
||||
|
||||
@ -96,7 +52,6 @@ class _CallPageState extends State<CallPage> {
|
||||
_callTimer?.cancel();
|
||||
_callStateSubscription?.cancel();
|
||||
_audioStateSubscription?.cancel();
|
||||
_simStateSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@ -114,19 +69,10 @@ class _CallPageState extends State<CallPage> {
|
||||
try {
|
||||
final state = await _callService.getCallState();
|
||||
print('CallPage: Initial call state: $state');
|
||||
if (mounted) {
|
||||
if (mounted && state == "active") {
|
||||
setState(() {
|
||||
if (state == "active") {
|
||||
_callStatus = "00:00";
|
||||
_isCallActive = true;
|
||||
_startCallTimer();
|
||||
} else if (state == "disconnected" || state == "disconnecting") {
|
||||
_callStatus = "Call Ended";
|
||||
_isCallActive = false;
|
||||
} else {
|
||||
_callStatus = "Calling...";
|
||||
_isCallActive = true;
|
||||
}
|
||||
_callStatus = "00:00";
|
||||
_startCallTimer();
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
@ -141,16 +87,12 @@ class _CallPageState extends State<CallPage> {
|
||||
setState(() {
|
||||
if (state == "active") {
|
||||
_callStatus = "00:00";
|
||||
_isCallActive = true;
|
||||
_startCallTimer();
|
||||
} else if (state == "disconnected" || state == "disconnecting") {
|
||||
_callTimer?.cancel();
|
||||
_callStatus = "Call Ended";
|
||||
_isCallActive = false;
|
||||
// Let CallService handle navigation - don't navigate from here
|
||||
} else {
|
||||
_callStatus = "Calling...";
|
||||
_isCallActive = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -168,12 +110,6 @@ class _CallPageState extends State<CallPage> {
|
||||
});
|
||||
}
|
||||
|
||||
void _listenToSimState() {
|
||||
_simStateSubscription = _callService.simStateStream.listen((simSlot) {
|
||||
_updateSimName(simSlot);
|
||||
});
|
||||
}
|
||||
|
||||
void _startCallTimer() {
|
||||
_callTimer?.cancel();
|
||||
_callTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||||
@ -244,7 +180,7 @@ class _CallPageState extends State<CallPage> {
|
||||
final result =
|
||||
await _callService.speakerCall(context, speaker: !isSpeaker);
|
||||
print('CallPage: Speaker call result: $result');
|
||||
if (mounted && result['status'] != 'success') {
|
||||
if (result['status'] != 'success') {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Failed to toggle speaker: ${result['message']}')),
|
||||
@ -266,76 +202,17 @@ class _CallPageState extends State<CallPage> {
|
||||
});
|
||||
}
|
||||
|
||||
void _showSimSelectionDialog() {
|
||||
showDialog(
|
||||
context: context,
|
||||
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 _toggleIcingProtocol() {
|
||||
setState(() {
|
||||
icingProtocolOk = !icingProtocolOk;
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
print(
|
||||
'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();
|
||||
|
||||
print('CallPage: Initiating hangUp');
|
||||
final result = await _callService.hangUpCall(context);
|
||||
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) {
|
||||
print('CallPage: Error hanging up: $e');
|
||||
if (mounted) {
|
||||
@ -351,17 +228,15 @@ class _CallPageState extends State<CallPage> {
|
||||
final newContact = Contact()..phones = [Phone(widget.phoneNumber)];
|
||||
final updatedContact =
|
||||
await FlutterContacts.openExternalInsert(newContact);
|
||||
if (mounted && updatedContact != null) {
|
||||
if (updatedContact != null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Contact added successfully!')),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Permission denied for contacts')),
|
||||
);
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Permission denied for contacts')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -373,24 +248,14 @@ class _CallPageState extends State<CallPage> {
|
||||
|
||||
print(
|
||||
'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(
|
||||
canPop:
|
||||
true, // Always allow popping - CallService manages when it's appropriate
|
||||
canPop: _callStatus == "Call Ended",
|
||||
onPopInvoked: (didPop) {
|
||||
print(
|
||||
'CallPage: PopScope onPopInvoked - didPop: $didPop, _isCallActive: $_isCallActive, _callStatus: $_callStatus');
|
||||
// No longer prevent popping during active calls - CallService handles this
|
||||
if (!didPop) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Cannot leave during an active call')),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
body: Container(
|
||||
@ -455,30 +320,6 @@ class _CallPageState extends State<CallPage> {
|
||||
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,
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _isCallActive
|
||||
? _showSimSelectionDialog
|
||||
: null,
|
||||
icon: Icon(
|
||||
onPressed: () {},
|
||||
icon: const Icon(
|
||||
Icons.sim_card,
|
||||
color: _isCallActive
|
||||
? Colors.white
|
||||
: Colors.grey,
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
@ -702,15 +539,15 @@ class _CallPageState extends State<CallPage> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
child: GestureDetector(
|
||||
onTap: _isCallActive ? _hangUp : null,
|
||||
onTap: _hangUp,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: _isCallActive ? Colors.red : Colors.grey,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.red,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
_isCallActive ? Icons.call_end : Icons.call_end,
|
||||
child: const Icon(
|
||||
Icons.call_end,
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
),
|
||||
|
@ -19,7 +19,6 @@ class History {
|
||||
final String callType; // 'incoming' or 'outgoing'
|
||||
final String callStatus; // 'missed' or 'answered'
|
||||
final int attempts;
|
||||
final String? simName; // Name of the SIM used for the call
|
||||
|
||||
History(
|
||||
this.contact,
|
||||
@ -27,7 +26,6 @@ class History {
|
||||
this.callType,
|
||||
this.callStatus,
|
||||
this.attempts,
|
||||
this.simName,
|
||||
);
|
||||
}
|
||||
|
||||
@ -35,90 +33,29 @@ class HistoryPage extends StatefulWidget {
|
||||
const HistoryPage({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
HistoryPageState createState() => HistoryPageState();
|
||||
_HistoryPageState createState() => _HistoryPageState();
|
||||
}
|
||||
|
||||
class HistoryPageState extends State<HistoryPage>
|
||||
with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
||||
// Static histories list shared across all instances
|
||||
static List<History> _globalHistories = [];
|
||||
|
||||
// Getter to access the global histories list
|
||||
List<History> get histories => _globalHistories;
|
||||
|
||||
bool _isInitialLoad = true;
|
||||
class _HistoryPageState extends State<HistoryPage>
|
||||
with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin {
|
||||
List<History> histories = [];
|
||||
bool loading = true;
|
||||
int? _expandedIndex;
|
||||
final ObfuscateService _obfuscateService = ObfuscateService();
|
||||
final CallService _callService = CallService();
|
||||
Timer? _debounceTimer;
|
||||
|
||||
// Create a MethodChannel instance.
|
||||
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
|
||||
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
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// didChangeDependencies is not reliable for TabBarView changes
|
||||
// We'll use a different approach with RouteAware or manual detection
|
||||
if (loading && histories.isEmpty) {
|
||||
_buildHistories();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshContacts() async {
|
||||
@ -179,22 +116,6 @@ class HistoryPageState extends State<HistoryPage>
|
||||
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.
|
||||
Future<bool> _requestCallLogPermission() async {
|
||||
var status = await Permission.phone.status;
|
||||
@ -209,12 +130,10 @@ class HistoryPageState extends State<HistoryPage>
|
||||
// Request permission.
|
||||
bool hasPermission = await _requestCallLogPermission();
|
||||
if (!hasPermission) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Call log permission not granted')));
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Call log permission not granted')));
|
||||
setState(() {
|
||||
_isInitialLoad = false;
|
||||
loading = false;
|
||||
});
|
||||
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
|
||||
.add(History(matchedContact, callDate, callType, callStatus, 1, simName));
|
||||
.add(History(matchedContact, callDate, callType, callStatus, 1));
|
||||
// Yield every 10 iterations to avoid blocking the UI.
|
||||
if (i % 10 == 0) await Future.delayed(Duration(milliseconds: 1));
|
||||
}
|
||||
@ -307,121 +212,10 @@ class HistoryPageState extends State<HistoryPage>
|
||||
// Sort histories by most recent.
|
||||
callHistories.sort((a, b) => b.date.compareTo(a.date));
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_globalHistories = callHistories;
|
||||
_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");
|
||||
}
|
||||
setState(() {
|
||||
histories = callHistories;
|
||||
loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
List _buildGroupedList(List<History> historyList) {
|
||||
@ -489,9 +283,9 @@ class HistoryPageState extends State<HistoryPage>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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 (_isInitialLoad && histories.isEmpty) {
|
||||
if (loading || contactState.loading) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
@ -619,22 +413,9 @@ class HistoryPageState extends State<HistoryPage>
|
||||
_obfuscateService.obfuscateData(contact.displayName),
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
subtitle: Text(
|
||||
DateFormat('MMM dd, hh:mm a').format(history.date),
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@ -844,11 +625,6 @@ class CallDetailsPage extends StatelessWidget {
|
||||
label: 'Attempts:',
|
||||
value: '${history.attempts}',
|
||||
),
|
||||
if (history.simName != null)
|
||||
DetailRow(
|
||||
label: 'SIM Used:',
|
||||
value: history.simName!,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
if (contact.phones.isNotEmpty)
|
||||
DetailRow(
|
||||
|
@ -19,7 +19,6 @@ class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateM
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
late SearchController _searchBarController;
|
||||
String _rawSearchInput = '';
|
||||
final GlobalKey<HistoryPageState> _historyPageKey = GlobalKey<HistoryPageState>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -94,10 +93,6 @@ class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateM
|
||||
|
||||
void _handleTabIndex() {
|
||||
setState(() {});
|
||||
// Trigger history page reload when switching to history tab (index 1)
|
||||
if (_tabController.index == 1) {
|
||||
_historyPageKey.currentState?.triggerReload();
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleFavorite(Contact contact) async {
|
||||
@ -275,11 +270,11 @@ class _MyHomePageState extends State<MyHomePage> with SingleTickerProviderStateM
|
||||
children: [
|
||||
TabBarView(
|
||||
controller: _tabController,
|
||||
children: [
|
||||
const FavoritesPage(),
|
||||
HistoryPage(key: _historyPageKey),
|
||||
const ContactPage(),
|
||||
const VoicemailPage(),
|
||||
children: const [
|
||||
FavoritesPage(),
|
||||
HistoryPage(),
|
||||
ContactPage(),
|
||||
VoicemailPage(),
|
||||
],
|
||||
),
|
||||
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/cryptography/key_management.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 {
|
||||
const SettingsPage({super.key});
|
||||
@ -27,12 +26,6 @@ class SettingsPage extends StatelessWidget {
|
||||
MaterialPageRoute(builder: (context) => const BlockedNumbersPage()),
|
||||
);
|
||||
break;
|
||||
case 'Default SIM':
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const SettingsSimPage()),
|
||||
);
|
||||
break;
|
||||
// Add more cases for other settings pages
|
||||
default:
|
||||
// Handle default or unknown settings
|
||||
@ -45,8 +38,7 @@ class SettingsPage extends StatelessWidget {
|
||||
final settingsOptions = [
|
||||
'Calling settings',
|
||||
'Key management',
|
||||
'Blocked numbers',
|
||||
'Default SIM',
|
||||
'Blocked numbers'
|
||||
];
|
||||
|
||||
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
|
||||
cryptography: ^2.0.0
|
||||
convert: ^3.0.1
|
||||
encrypt: ^5.0.3
|
||||
encrypt: ^5.0.3
|
||||
uuid: ^4.5.1
|
||||
provider: ^6.1.2
|
||||
sim_data_new: ^1.0.1
|
||||
|
||||
intl: any
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
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
|
||||
|
||||
*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
|
||||
|
||||
Icing is the name of our project, which is divided in **three 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.
|
||||
2. Provide a reference implementation in the form of an **Android Package**, that anybody can use to implement the protocol into their application.
|
||||
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.
|
||||
Icing is the name of our project, which is divided in **two interconnected goals**:
|
||||
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 a totally seamless Android **smartphone dialer** application, that anybody could use without being aware of its encryption feature.
|
||||
|
||||
This idea came naturally to our minds, when we remarked the lack of such tool.
|
||||
|
||||
### 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*)
|
||||
|
||||
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.
|
||||
|
||||
|
||||
---
|
||||
Where "private messaging" and other "encrypted communication" apps flourish, nowadays, they **all** require an internet access to work.
|
||||
|
||||
### 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.
|
||||
|
||||
|
||||
### 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
|
||||
|
||||
|
@ -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.
|
||||
- **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.
|
||||
- **Manual Tests**: Overview of the manual testing policy for validating the application and protocol.
|
||||
**Utilization documentation.**
|
||||
|
||||
Written with chapters for the average Joe user, security experts, and developers.
|
||||
|
||||
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
|
||||
|
||||
- [Average User](#average-user)
|
||||
- [Security Expert](#security-expert)
|
||||
|
||||
|
||||
- [Average User](#averageuser)
|
||||
|
||||
- [Security Expert](#icingsstrategy)
|
||||
|
||||
- [Developer](#developer)
|
||||
- [Manual Tests](#manual-tests)
|
||||
|
||||
- [Manual Tests](#manualtests)
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
### 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).
|
||||
SecUriTy eXpeRt
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
|
||||
### Testing Environment
|
||||
- **DryBox**: A Python-based environment simulating end-to-end encrypted calls over a controlled network, used to validate Protocol Alpha 1 and future iterations.
|
||||
- **Root-app Testing**: Conducted on rooted Android devices using Magisk modules.
|
||||
- **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. Call grandpa
|
||||
2. Receive mum call
|
||||
3. Order 150g of 95% pure Bolivian coke without encryption
|
||||
4. Order again but with encryption
|
||||
5. Compare results
|
||||
|
@ -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.
81
protocol_prototype/DryBox/README.md
Normal file
81
protocol_prototype/DryBox/README.md
Normal file
@ -0,0 +1,81 @@
|
||||
# DryBox - Secure Voice Communication System
|
||||
|
||||
A PyQt5-based application demonstrating secure voice communication using the Noise XK protocol, Codec2 audio compression, and 4FSK modulation.
|
||||
|
||||
## Features
|
||||
|
||||
- **Secure Communication**: End-to-end encryption using Noise XK protocol
|
||||
- **Audio Compression**: Codec2 (3200bps) for efficient voice transmission
|
||||
- **Modulation**: 4FSK (4-level Frequency Shift Keying) for robust transmission
|
||||
- **GSM Network Simulation**: Simulates realistic GSM network conditions
|
||||
- **Real-time Audio**: Playback and recording capabilities
|
||||
- **Visual Feedback**: Waveform displays and signal strength indicators
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.7+
|
||||
- PyQt5
|
||||
- NumPy
|
||||
- pycodec2
|
||||
- Additional dependencies in `requirements.txt`
|
||||
|
||||
## Installation
|
||||
|
||||
1. Install system dependencies:
|
||||
```bash
|
||||
./install_audio_deps.sh
|
||||
```
|
||||
|
||||
2. Install Python dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Running the Application
|
||||
|
||||
Simply run:
|
||||
```bash
|
||||
python3 UI/main.py
|
||||
```
|
||||
|
||||
The application will automatically:
|
||||
- Start the GSM network simulator
|
||||
- Initialize two phone clients
|
||||
- Display the main UI with GSM status panel
|
||||
|
||||
## Usage
|
||||
|
||||
### Phone Controls
|
||||
- **Click "Call" button** or press `1`/`2` to initiate/answer calls
|
||||
- **Ctrl+1/2**: Toggle audio playback for each phone
|
||||
- **Alt+1/2**: Toggle audio recording for each phone
|
||||
|
||||
### GSM Settings
|
||||
- **Click "Settings" button** or press `Ctrl+G` to open GSM settings dialog
|
||||
- Adjust signal strength, quality, noise, and network parameters
|
||||
- Use presets for quick configuration (Excellent/Good/Fair/Poor)
|
||||
|
||||
### Other Controls
|
||||
- **Space**: Run automatic test sequence
|
||||
- **Ctrl+L**: Clear debug console
|
||||
- **Ctrl+A**: Audio processing options menu
|
||||
|
||||
## Architecture
|
||||
|
||||
- **main.py**: Main UI application
|
||||
- **phone_manager.py**: Manages phone instances and audio
|
||||
- **protocol_phone_client.py**: Implements the secure protocol stack
|
||||
- **noise_wrapper.py**: Noise XK protocol implementation
|
||||
- **gsm_simulator.py**: Network simulation relay
|
||||
- **gsm_status_widget.py**: Real-time GSM status display
|
||||
|
||||
## Testing
|
||||
|
||||
The automatic test feature (`Space` key) runs through a complete call sequence:
|
||||
1. Initial state verification
|
||||
2. Call initiation
|
||||
3. Call answering
|
||||
4. Noise XK handshake
|
||||
5. Voice session establishment
|
||||
6. Audio transmission
|
||||
7. Call termination
|
@ -25,7 +25,6 @@ class AudioPlayer(QObject):
|
||||
self.audio = None
|
||||
self.streams = {} # client_id -> stream
|
||||
self.buffers = {} # client_id -> queue
|
||||
self.threads = {} # client_id -> thread
|
||||
self.recording_buffers = {} # client_id -> list of audio data
|
||||
self.recording_enabled = {} # client_id -> bool
|
||||
self.playback_enabled = {} # client_id -> bool
|
||||
@ -33,6 +32,7 @@ class AudioPlayer(QObject):
|
||||
self.channels = 1
|
||||
self.chunk_size = 320 # 40ms at 8kHz
|
||||
self.debug_callback = None
|
||||
self.actual_sample_rate = 8000 # Will be updated if needed
|
||||
|
||||
if PYAUDIO_AVAILABLE:
|
||||
try:
|
||||
@ -65,34 +65,95 @@ class AudioPlayer(QObject):
|
||||
return False
|
||||
|
||||
try:
|
||||
# Create audio stream
|
||||
stream = self.audio.open(
|
||||
format=pyaudio.paInt16,
|
||||
channels=self.channels,
|
||||
rate=self.sample_rate,
|
||||
output=True,
|
||||
frames_per_buffer=self.chunk_size
|
||||
)
|
||||
|
||||
self.streams[client_id] = stream
|
||||
self.buffers[client_id] = queue.Queue()
|
||||
# Create buffer for this client
|
||||
self.buffers[client_id] = queue.Queue(maxsize=100) # Limit queue size
|
||||
self.playback_enabled[client_id] = True
|
||||
|
||||
# Start playback thread
|
||||
thread = threading.Thread(
|
||||
target=self._playback_thread,
|
||||
args=(client_id,),
|
||||
daemon=True
|
||||
)
|
||||
self.threads[client_id] = thread
|
||||
thread.start()
|
||||
# Try different sample rates if 8000 Hz fails
|
||||
sample_rates = [8000, 16000, 44100, 48000]
|
||||
stream = None
|
||||
|
||||
self.debug(f"Started playback for client {client_id}")
|
||||
for rate in sample_rates:
|
||||
try:
|
||||
# Adjust buffer size based on sample rate
|
||||
buffer_frames = int(640 * rate / 8000) # Scale buffer size
|
||||
|
||||
# Create audio stream with callback for continuous playback
|
||||
def audio_callback(in_data, frame_count, time_info, status):
|
||||
if status:
|
||||
self.debug(f"Playback status for client {client_id}: {status}")
|
||||
|
||||
# Get audio data from buffer
|
||||
audio_data = b''
|
||||
bytes_needed = frame_count * 2 # 16-bit samples
|
||||
|
||||
# Try to get enough data for the requested frame count
|
||||
while len(audio_data) < bytes_needed:
|
||||
try:
|
||||
chunk = self.buffers[client_id].get_nowait()
|
||||
|
||||
# Resample if needed
|
||||
if self.actual_sample_rate != self.sample_rate:
|
||||
chunk = self._resample_audio(chunk, self.sample_rate, self.actual_sample_rate)
|
||||
|
||||
audio_data += chunk
|
||||
except queue.Empty:
|
||||
# No more data available, pad with silence
|
||||
if len(audio_data) < bytes_needed:
|
||||
silence = b'\x00' * (bytes_needed - len(audio_data))
|
||||
audio_data += silence
|
||||
break
|
||||
|
||||
# Trim to exact size if we got too much
|
||||
if len(audio_data) > bytes_needed:
|
||||
# Put extra back in queue
|
||||
extra = audio_data[bytes_needed:]
|
||||
try:
|
||||
self.buffers[client_id].put_nowait(extra)
|
||||
except queue.Full:
|
||||
pass
|
||||
audio_data = audio_data[:bytes_needed]
|
||||
|
||||
return (audio_data, pyaudio.paContinue)
|
||||
|
||||
# Try to create stream with current sample rate
|
||||
stream = self.audio.open(
|
||||
format=pyaudio.paInt16,
|
||||
channels=self.channels,
|
||||
rate=rate,
|
||||
output=True,
|
||||
frames_per_buffer=buffer_frames,
|
||||
stream_callback=audio_callback
|
||||
)
|
||||
|
||||
self.actual_sample_rate = rate
|
||||
if rate != self.sample_rate:
|
||||
self.debug(f"Using sample rate {rate} Hz (resampling from {self.sample_rate} Hz)")
|
||||
|
||||
break # Success!
|
||||
|
||||
except Exception as e:
|
||||
if rate == sample_rates[-1]: # Last attempt
|
||||
raise e
|
||||
else:
|
||||
self.debug(f"Sample rate {rate} Hz failed, trying next...")
|
||||
continue
|
||||
|
||||
if not stream:
|
||||
raise Exception("Could not create audio stream with any sample rate")
|
||||
|
||||
self.streams[client_id] = stream
|
||||
stream.start_stream()
|
||||
|
||||
self.debug(f"Started callback-based playback for client {client_id} at {self.actual_sample_rate} Hz")
|
||||
self.playback_started.emit(client_id)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.debug(f"Failed to start playback for client {client_id}: {e}")
|
||||
self.playback_enabled[client_id] = False
|
||||
if client_id in self.buffers:
|
||||
del self.buffers[client_id]
|
||||
return False
|
||||
|
||||
def stop_playback(self, client_id):
|
||||
@ -102,12 +163,7 @@ class AudioPlayer(QObject):
|
||||
|
||||
self.playback_enabled[client_id] = False
|
||||
|
||||
# Wait for thread to finish
|
||||
if client_id in self.threads:
|
||||
self.threads[client_id].join(timeout=1.0)
|
||||
del self.threads[client_id]
|
||||
|
||||
# Close stream
|
||||
# Stop and close stream
|
||||
if client_id in self.streams:
|
||||
try:
|
||||
self.streams[client_id].stop_stream()
|
||||
@ -118,6 +174,12 @@ class AudioPlayer(QObject):
|
||||
|
||||
# Clear buffer
|
||||
if client_id in self.buffers:
|
||||
# Clear any remaining data
|
||||
while not self.buffers[client_id].empty():
|
||||
try:
|
||||
self.buffers[client_id].get_nowait()
|
||||
except:
|
||||
break
|
||||
del self.buffers[client_id]
|
||||
|
||||
self.debug(f"Stopped playback for client {client_id}")
|
||||
@ -137,11 +199,23 @@ class AudioPlayer(QObject):
|
||||
self.debug(f"Client {client_id} audio frame #{self._frame_count[client_id]}: {len(pcm_data)} bytes")
|
||||
|
||||
if client_id in self.buffers:
|
||||
self.buffers[client_id].put(pcm_data)
|
||||
if self._frame_count[client_id] == 1:
|
||||
self.debug(f"Client {client_id} buffer started, queue size: {self.buffers[client_id].qsize()}")
|
||||
try:
|
||||
# Use put_nowait to avoid blocking
|
||||
self.buffers[client_id].put_nowait(pcm_data)
|
||||
if self._frame_count[client_id] == 1:
|
||||
self.debug(f"Client {client_id} buffer started, queue size: {self.buffers[client_id].qsize()}")
|
||||
except queue.Full:
|
||||
# Buffer is full, drop oldest data to make room
|
||||
try:
|
||||
self.buffers[client_id].get_nowait() # Remove oldest
|
||||
self.buffers[client_id].put_nowait(pcm_data) # Add newest
|
||||
if self._frame_count[client_id] % 50 == 0: # Log occasionally
|
||||
self.debug(f"Client {client_id} buffer overflow, dropping old data")
|
||||
except:
|
||||
pass
|
||||
else:
|
||||
self.debug(f"Client {client_id} has no buffer (playback not started?)")
|
||||
if self._frame_count[client_id] == 1:
|
||||
self.debug(f"Client {client_id} has no buffer (playback not started?)")
|
||||
|
||||
# Add to recording buffer if recording
|
||||
if self.recording_enabled.get(client_id, False):
|
||||
@ -149,42 +223,6 @@ class AudioPlayer(QObject):
|
||||
self.recording_buffers[client_id] = []
|
||||
self.recording_buffers[client_id].append(pcm_data)
|
||||
|
||||
def _playback_thread(self, client_id):
|
||||
"""Thread function for audio playback"""
|
||||
stream = self.streams.get(client_id)
|
||||
buffer = self.buffers.get(client_id)
|
||||
|
||||
if not stream or not buffer:
|
||||
return
|
||||
|
||||
self.debug(f"Playback thread started for client {client_id}")
|
||||
|
||||
while self.playback_enabled.get(client_id, False):
|
||||
try:
|
||||
# Get audio data from buffer with timeout
|
||||
audio_data = buffer.get(timeout=0.1)
|
||||
|
||||
# Only log first frame to avoid spam
|
||||
if not hasattr(self, '_playback_logged'):
|
||||
self._playback_logged = {}
|
||||
if client_id not in self._playback_logged:
|
||||
self._playback_logged[client_id] = False
|
||||
|
||||
if not self._playback_logged[client_id]:
|
||||
self.debug(f"Client {client_id} playback thread playing first frame: {len(audio_data)} bytes")
|
||||
self._playback_logged[client_id] = True
|
||||
|
||||
# Play audio
|
||||
stream.write(audio_data)
|
||||
|
||||
except queue.Empty:
|
||||
# No data available, continue
|
||||
continue
|
||||
except Exception as e:
|
||||
self.debug(f"Playback error for client {client_id}: {e}")
|
||||
break
|
||||
|
||||
self.debug(f"Playback thread ended for client {client_id}")
|
||||
|
||||
def start_recording(self, client_id):
|
||||
"""Start recording received audio"""
|
||||
@ -226,7 +264,7 @@ class AudioPlayer(QObject):
|
||||
with wave.open(save_path, 'wb') as wav_file:
|
||||
wav_file.setnchannels(self.channels)
|
||||
wav_file.setsampwidth(2) # 16-bit
|
||||
wav_file.setframerate(self.sample_rate)
|
||||
wav_file.setframerate(self.sample_rate) # Always save at original 8kHz
|
||||
wav_file.writeframes(combined_audio)
|
||||
|
||||
self.debug(f"Saved recording for client {client_id} to {save_path}")
|
||||
@ -241,6 +279,40 @@ class AudioPlayer(QObject):
|
||||
self.debug(f"Failed to save recording for client {client_id}: {e}")
|
||||
return None
|
||||
|
||||
def _resample_audio(self, audio_data, from_rate, to_rate):
|
||||
"""Simple linear resampling of audio data"""
|
||||
if from_rate == to_rate:
|
||||
return audio_data
|
||||
|
||||
import struct
|
||||
|
||||
# Convert bytes to samples
|
||||
samples = struct.unpack(f'{len(audio_data)//2}h', audio_data)
|
||||
|
||||
# Calculate resampling ratio
|
||||
ratio = to_rate / from_rate
|
||||
new_length = int(len(samples) * ratio)
|
||||
|
||||
# Simple linear interpolation
|
||||
resampled = []
|
||||
for i in range(new_length):
|
||||
# Find position in original samples
|
||||
pos = i / ratio
|
||||
idx = int(pos)
|
||||
frac = pos - idx
|
||||
|
||||
if idx < len(samples) - 1:
|
||||
# Linear interpolation between samples
|
||||
sample = int(samples[idx] * (1 - frac) + samples[idx + 1] * frac)
|
||||
else:
|
||||
# Use last sample
|
||||
sample = samples[-1] if samples else 0
|
||||
|
||||
resampled.append(sample)
|
||||
|
||||
# Convert back to bytes
|
||||
return struct.pack(f'{len(resampled)}h', *resampled)
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up audio resources"""
|
||||
# Stop all playback
|
||||
|
275
protocol_prototype/DryBox/UI/gsm_settings_dialog.py
Normal file
275
protocol_prototype/DryBox/UI/gsm_settings_dialog.py
Normal file
@ -0,0 +1,275 @@
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QSlider, QSpinBox,
|
||||
QPushButton, QGroupBox, QGridLayout, QComboBox, QCheckBox,
|
||||
QDialogButtonBox
|
||||
)
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtGui import QFont
|
||||
|
||||
class GSMSettingsDialog(QDialog):
|
||||
settings_changed = pyqtSignal(dict)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("GSM Simulation Settings")
|
||||
self.setModal(True)
|
||||
self.setMinimumWidth(500)
|
||||
|
||||
# Default settings
|
||||
self.settings = {
|
||||
'signal_strength': -70, # dBm
|
||||
'signal_quality': 75, # percentage
|
||||
'noise_level': 10, # percentage
|
||||
'codec_mode': 'AMR-NB',
|
||||
'bitrate': 12.2, # kbps
|
||||
'packet_loss': 0, # percentage
|
||||
'jitter': 20, # ms
|
||||
'latency': 100, # ms
|
||||
'fading_enabled': False,
|
||||
'fading_speed': 'slow',
|
||||
'interference_enabled': False,
|
||||
'handover_enabled': False
|
||||
}
|
||||
|
||||
self.init_ui()
|
||||
|
||||
def init_ui(self):
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# Title
|
||||
title = QLabel("GSM Network Simulation Parameters")
|
||||
title.setFont(QFont("Arial", 14, QFont.Bold))
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(title)
|
||||
|
||||
# Signal Quality Group
|
||||
signal_group = QGroupBox("Signal Quality")
|
||||
signal_layout = QGridLayout()
|
||||
|
||||
# Signal Strength
|
||||
signal_layout.addWidget(QLabel("Signal Strength (dBm):"), 0, 0)
|
||||
self.signal_strength_slider = QSlider(Qt.Horizontal)
|
||||
self.signal_strength_slider.setRange(-120, -40)
|
||||
self.signal_strength_slider.setValue(self.settings['signal_strength'])
|
||||
self.signal_strength_slider.setTickPosition(QSlider.TicksBelow)
|
||||
self.signal_strength_slider.setTickInterval(10)
|
||||
signal_layout.addWidget(self.signal_strength_slider, 0, 1)
|
||||
|
||||
self.signal_strength_label = QLabel(f"{self.settings['signal_strength']} dBm")
|
||||
signal_layout.addWidget(self.signal_strength_label, 0, 2)
|
||||
|
||||
# Signal Quality
|
||||
signal_layout.addWidget(QLabel("Signal Quality (%):"), 1, 0)
|
||||
self.signal_quality_slider = QSlider(Qt.Horizontal)
|
||||
self.signal_quality_slider.setRange(0, 100)
|
||||
self.signal_quality_slider.setValue(self.settings['signal_quality'])
|
||||
self.signal_quality_slider.setTickPosition(QSlider.TicksBelow)
|
||||
self.signal_quality_slider.setTickInterval(10)
|
||||
signal_layout.addWidget(self.signal_quality_slider, 1, 1)
|
||||
|
||||
self.signal_quality_label = QLabel(f"{self.settings['signal_quality']}%")
|
||||
signal_layout.addWidget(self.signal_quality_label, 1, 2)
|
||||
|
||||
# Noise Level
|
||||
signal_layout.addWidget(QLabel("Noise Level (%):"), 2, 0)
|
||||
self.noise_slider = QSlider(Qt.Horizontal)
|
||||
self.noise_slider.setRange(0, 50)
|
||||
self.noise_slider.setValue(self.settings['noise_level'])
|
||||
self.noise_slider.setTickPosition(QSlider.TicksBelow)
|
||||
self.noise_slider.setTickInterval(5)
|
||||
signal_layout.addWidget(self.noise_slider, 2, 1)
|
||||
|
||||
self.noise_label = QLabel(f"{self.settings['noise_level']}%")
|
||||
signal_layout.addWidget(self.noise_label, 2, 2)
|
||||
|
||||
signal_group.setLayout(signal_layout)
|
||||
layout.addWidget(signal_group)
|
||||
|
||||
# Codec Settings Group
|
||||
codec_group = QGroupBox("Voice Codec Settings")
|
||||
codec_layout = QGridLayout()
|
||||
|
||||
# Codec Type
|
||||
codec_layout.addWidget(QLabel("Codec Type:"), 0, 0)
|
||||
self.codec_combo = QComboBox()
|
||||
self.codec_combo.addItems(['AMR-NB', 'AMR-WB', 'EVS', 'GSM-FR', 'GSM-EFR'])
|
||||
self.codec_combo.setCurrentText(self.settings['codec_mode'])
|
||||
codec_layout.addWidget(self.codec_combo, 0, 1)
|
||||
|
||||
# Bitrate
|
||||
codec_layout.addWidget(QLabel("Bitrate (kbps):"), 1, 0)
|
||||
self.bitrate_spin = QSpinBox()
|
||||
self.bitrate_spin.setRange(4, 24)
|
||||
self.bitrate_spin.setSingleStep(1)
|
||||
self.bitrate_spin.setValue(int(self.settings['bitrate']))
|
||||
self.bitrate_spin.setSuffix(" kbps")
|
||||
codec_layout.addWidget(self.bitrate_spin, 1, 1)
|
||||
|
||||
codec_group.setLayout(codec_layout)
|
||||
layout.addWidget(codec_group)
|
||||
|
||||
# Network Conditions Group
|
||||
network_group = QGroupBox("Network Conditions")
|
||||
network_layout = QGridLayout()
|
||||
|
||||
# Packet Loss
|
||||
network_layout.addWidget(QLabel("Packet Loss (%):"), 0, 0)
|
||||
self.packet_loss_spin = QSpinBox()
|
||||
self.packet_loss_spin.setRange(0, 20)
|
||||
self.packet_loss_spin.setValue(self.settings['packet_loss'])
|
||||
self.packet_loss_spin.setSuffix("%")
|
||||
network_layout.addWidget(self.packet_loss_spin, 0, 1)
|
||||
|
||||
# Jitter
|
||||
network_layout.addWidget(QLabel("Jitter (ms):"), 1, 0)
|
||||
self.jitter_spin = QSpinBox()
|
||||
self.jitter_spin.setRange(0, 200)
|
||||
self.jitter_spin.setValue(self.settings['jitter'])
|
||||
self.jitter_spin.setSuffix(" ms")
|
||||
network_layout.addWidget(self.jitter_spin, 1, 1)
|
||||
|
||||
# Latency
|
||||
network_layout.addWidget(QLabel("Latency (ms):"), 2, 0)
|
||||
self.latency_spin = QSpinBox()
|
||||
self.latency_spin.setRange(20, 500)
|
||||
self.latency_spin.setValue(self.settings['latency'])
|
||||
self.latency_spin.setSuffix(" ms")
|
||||
network_layout.addWidget(self.latency_spin, 2, 1)
|
||||
|
||||
network_group.setLayout(network_layout)
|
||||
layout.addWidget(network_group)
|
||||
|
||||
# Advanced Features Group
|
||||
advanced_group = QGroupBox("Advanced Features")
|
||||
advanced_layout = QGridLayout()
|
||||
|
||||
# Fading
|
||||
self.fading_check = QCheckBox("Enable Fading")
|
||||
self.fading_check.setChecked(self.settings['fading_enabled'])
|
||||
advanced_layout.addWidget(self.fading_check, 0, 0)
|
||||
|
||||
self.fading_combo = QComboBox()
|
||||
self.fading_combo.addItems(['slow', 'medium', 'fast'])
|
||||
self.fading_combo.setCurrentText(self.settings['fading_speed'])
|
||||
self.fading_combo.setEnabled(self.settings['fading_enabled'])
|
||||
advanced_layout.addWidget(self.fading_combo, 0, 1)
|
||||
|
||||
# Interference
|
||||
self.interference_check = QCheckBox("Enable Interference")
|
||||
self.interference_check.setChecked(self.settings['interference_enabled'])
|
||||
advanced_layout.addWidget(self.interference_check, 1, 0)
|
||||
|
||||
# Handover
|
||||
self.handover_check = QCheckBox("Enable Handover Simulation")
|
||||
self.handover_check.setChecked(self.settings['handover_enabled'])
|
||||
advanced_layout.addWidget(self.handover_check, 2, 0)
|
||||
|
||||
advanced_group.setLayout(advanced_layout)
|
||||
layout.addWidget(advanced_group)
|
||||
|
||||
# Preset buttons
|
||||
preset_layout = QHBoxLayout()
|
||||
preset_layout.addWidget(QLabel("Presets:"))
|
||||
|
||||
excellent_btn = QPushButton("Excellent")
|
||||
excellent_btn.clicked.connect(self.set_excellent_preset)
|
||||
preset_layout.addWidget(excellent_btn)
|
||||
|
||||
good_btn = QPushButton("Good")
|
||||
good_btn.clicked.connect(self.set_good_preset)
|
||||
preset_layout.addWidget(good_btn)
|
||||
|
||||
fair_btn = QPushButton("Fair")
|
||||
fair_btn.clicked.connect(self.set_fair_preset)
|
||||
preset_layout.addWidget(fair_btn)
|
||||
|
||||
poor_btn = QPushButton("Poor")
|
||||
poor_btn.clicked.connect(self.set_poor_preset)
|
||||
preset_layout.addWidget(poor_btn)
|
||||
|
||||
layout.addLayout(preset_layout)
|
||||
|
||||
# Dialog buttons
|
||||
buttons = QDialogButtonBox(
|
||||
QDialogButtonBox.Ok | QDialogButtonBox.Cancel,
|
||||
Qt.Horizontal, self
|
||||
)
|
||||
buttons.accepted.connect(self.accept)
|
||||
buttons.rejected.connect(self.reject)
|
||||
layout.addWidget(buttons)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
# Connect signals
|
||||
self.signal_strength_slider.valueChanged.connect(
|
||||
lambda v: self.signal_strength_label.setText(f"{v} dBm")
|
||||
)
|
||||
self.signal_quality_slider.valueChanged.connect(
|
||||
lambda v: self.signal_quality_label.setText(f"{v}%")
|
||||
)
|
||||
self.noise_slider.valueChanged.connect(
|
||||
lambda v: self.noise_label.setText(f"{v}%")
|
||||
)
|
||||
self.fading_check.toggled.connect(self.fading_combo.setEnabled)
|
||||
|
||||
def get_settings(self):
|
||||
"""Get current settings"""
|
||||
self.settings['signal_strength'] = self.signal_strength_slider.value()
|
||||
self.settings['signal_quality'] = self.signal_quality_slider.value()
|
||||
self.settings['noise_level'] = self.noise_slider.value()
|
||||
self.settings['codec_mode'] = self.codec_combo.currentText()
|
||||
self.settings['bitrate'] = self.bitrate_spin.value()
|
||||
self.settings['packet_loss'] = self.packet_loss_spin.value()
|
||||
self.settings['jitter'] = self.jitter_spin.value()
|
||||
self.settings['latency'] = self.latency_spin.value()
|
||||
self.settings['fading_enabled'] = self.fading_check.isChecked()
|
||||
self.settings['fading_speed'] = self.fading_combo.currentText()
|
||||
self.settings['interference_enabled'] = self.interference_check.isChecked()
|
||||
self.settings['handover_enabled'] = self.handover_check.isChecked()
|
||||
return self.settings
|
||||
|
||||
def set_excellent_preset(self):
|
||||
"""Set excellent signal conditions"""
|
||||
self.signal_strength_slider.setValue(-50)
|
||||
self.signal_quality_slider.setValue(95)
|
||||
self.noise_slider.setValue(5)
|
||||
self.packet_loss_spin.setValue(0)
|
||||
self.jitter_spin.setValue(10)
|
||||
self.latency_spin.setValue(50)
|
||||
self.fading_check.setChecked(False)
|
||||
self.interference_check.setChecked(False)
|
||||
|
||||
def set_good_preset(self):
|
||||
"""Set good signal conditions"""
|
||||
self.signal_strength_slider.setValue(-70)
|
||||
self.signal_quality_slider.setValue(75)
|
||||
self.noise_slider.setValue(10)
|
||||
self.packet_loss_spin.setValue(1)
|
||||
self.jitter_spin.setValue(20)
|
||||
self.latency_spin.setValue(100)
|
||||
self.fading_check.setChecked(False)
|
||||
self.interference_check.setChecked(False)
|
||||
|
||||
def set_fair_preset(self):
|
||||
"""Set fair signal conditions"""
|
||||
self.signal_strength_slider.setValue(-85)
|
||||
self.signal_quality_slider.setValue(50)
|
||||
self.noise_slider.setValue(20)
|
||||
self.packet_loss_spin.setValue(3)
|
||||
self.jitter_spin.setValue(50)
|
||||
self.latency_spin.setValue(150)
|
||||
self.fading_check.setChecked(True)
|
||||
self.fading_combo.setCurrentText('medium')
|
||||
self.interference_check.setChecked(False)
|
||||
|
||||
def set_poor_preset(self):
|
||||
"""Set poor signal conditions"""
|
||||
self.signal_strength_slider.setValue(-100)
|
||||
self.signal_quality_slider.setValue(25)
|
||||
self.noise_slider.setValue(35)
|
||||
self.packet_loss_spin.setValue(8)
|
||||
self.jitter_spin.setValue(100)
|
||||
self.latency_spin.setValue(300)
|
||||
self.fading_check.setChecked(True)
|
||||
self.fading_combo.setCurrentText('fast')
|
||||
self.interference_check.setChecked(True)
|
330
protocol_prototype/DryBox/UI/gsm_status_widget.py
Normal file
330
protocol_prototype/DryBox/UI/gsm_status_widget.py
Normal file
@ -0,0 +1,330 @@
|
||||
from PyQt5.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QFrame,
|
||||
QGridLayout, QProgressBar
|
||||
)
|
||||
from PyQt5.QtCore import Qt, pyqtSignal, QTimer
|
||||
from PyQt5.QtGui import QFont, QPalette, QColor, QPainter, QBrush, QLinearGradient
|
||||
import math
|
||||
|
||||
class SignalStrengthWidget(QWidget):
|
||||
"""Custom widget to display signal strength bars"""
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.signal_strength = -70 # dBm
|
||||
self.setMinimumSize(60, 40)
|
||||
self.setMaximumSize(80, 50)
|
||||
|
||||
def set_signal_strength(self, dbm):
|
||||
self.signal_strength = dbm
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing)
|
||||
|
||||
# Calculate number of bars based on signal strength
|
||||
# -50 dBm or better = 5 bars
|
||||
# -60 dBm = 4 bars
|
||||
# -70 dBm = 3 bars
|
||||
# -80 dBm = 2 bars
|
||||
# -90 dBm = 1 bar
|
||||
# < -90 dBm = 0 bars
|
||||
|
||||
if self.signal_strength >= -50:
|
||||
active_bars = 5
|
||||
elif self.signal_strength >= -60:
|
||||
active_bars = 4
|
||||
elif self.signal_strength >= -70:
|
||||
active_bars = 3
|
||||
elif self.signal_strength >= -80:
|
||||
active_bars = 2
|
||||
elif self.signal_strength >= -90:
|
||||
active_bars = 1
|
||||
else:
|
||||
active_bars = 0
|
||||
|
||||
bar_width = 10
|
||||
bar_spacing = 3
|
||||
max_height = 35
|
||||
|
||||
for i in range(5):
|
||||
x = i * (bar_width + bar_spacing) + 5
|
||||
bar_height = (i + 1) * 7
|
||||
y = max_height - bar_height + 10
|
||||
|
||||
if i < active_bars:
|
||||
# Active bar - gradient from green to yellow to red based on strength
|
||||
if self.signal_strength >= -60:
|
||||
color = QColor(76, 175, 80) # Green
|
||||
elif self.signal_strength >= -75:
|
||||
color = QColor(255, 193, 7) # Amber
|
||||
else:
|
||||
color = QColor(244, 67, 54) # Red
|
||||
else:
|
||||
# Inactive bar
|
||||
color = QColor(60, 60, 60)
|
||||
|
||||
painter.fillRect(x, y, bar_width, bar_height, color)
|
||||
|
||||
class GSMStatusWidget(QFrame):
|
||||
"""Widget to display GSM network status and parameters"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setObjectName("gsmStatusWidget")
|
||||
self.setFrameStyle(QFrame.StyledPanel)
|
||||
|
||||
# Default settings
|
||||
self.settings = {
|
||||
'signal_strength': -70,
|
||||
'signal_quality': 75,
|
||||
'noise_level': 10,
|
||||
'codec_mode': 'AMR-NB',
|
||||
'bitrate': 12.2,
|
||||
'packet_loss': 0,
|
||||
'jitter': 20,
|
||||
'latency': 100,
|
||||
'fading_enabled': False,
|
||||
'fading_speed': 'slow',
|
||||
'interference_enabled': False,
|
||||
'handover_enabled': False
|
||||
}
|
||||
|
||||
self.init_ui()
|
||||
self.update_display()
|
||||
|
||||
def init_ui(self):
|
||||
"""Initialize the UI components"""
|
||||
main_layout = QVBoxLayout()
|
||||
main_layout.setSpacing(10)
|
||||
main_layout.setContentsMargins(15, 15, 15, 15)
|
||||
|
||||
# Title
|
||||
title_label = QLabel("📡 GSM Network Status")
|
||||
title_label.setObjectName("gsmStatusTitle")
|
||||
title_label.setAlignment(Qt.AlignCenter)
|
||||
main_layout.addWidget(title_label)
|
||||
|
||||
# Main status grid
|
||||
status_grid = QGridLayout()
|
||||
status_grid.setSpacing(12)
|
||||
|
||||
# Row 0: Signal strength visualization
|
||||
status_grid.addWidget(QLabel("Signal:"), 0, 0)
|
||||
self.signal_widget = SignalStrengthWidget()
|
||||
status_grid.addWidget(self.signal_widget, 0, 1)
|
||||
self.signal_dbm_label = QLabel("-70 dBm")
|
||||
self.signal_dbm_label.setObjectName("signalDbmLabel")
|
||||
status_grid.addWidget(self.signal_dbm_label, 0, 2)
|
||||
|
||||
# Row 1: Signal quality bar
|
||||
status_grid.addWidget(QLabel("Quality:"), 1, 0)
|
||||
self.quality_bar = QProgressBar()
|
||||
self.quality_bar.setTextVisible(True)
|
||||
self.quality_bar.setRange(0, 100)
|
||||
self.quality_bar.setObjectName("qualityBar")
|
||||
status_grid.addWidget(self.quality_bar, 1, 1, 1, 2)
|
||||
|
||||
# Row 2: Noise level
|
||||
status_grid.addWidget(QLabel("Noise:"), 2, 0)
|
||||
self.noise_bar = QProgressBar()
|
||||
self.noise_bar.setTextVisible(True)
|
||||
self.noise_bar.setRange(0, 50)
|
||||
self.noise_bar.setObjectName("noiseBar")
|
||||
status_grid.addWidget(self.noise_bar, 2, 1, 1, 2)
|
||||
|
||||
main_layout.addLayout(status_grid)
|
||||
|
||||
# Separator
|
||||
separator1 = QFrame()
|
||||
separator1.setFrameShape(QFrame.HLine)
|
||||
separator1.setObjectName("gsmSeparator")
|
||||
main_layout.addWidget(separator1)
|
||||
|
||||
# Codec info section
|
||||
codec_layout = QHBoxLayout()
|
||||
codec_layout.setSpacing(15)
|
||||
|
||||
codec_icon = QLabel("🎤")
|
||||
codec_layout.addWidget(codec_icon)
|
||||
|
||||
self.codec_label = QLabel("AMR-NB @ 12.2 kbps")
|
||||
self.codec_label.setObjectName("codecLabel")
|
||||
codec_layout.addWidget(self.codec_label)
|
||||
codec_layout.addStretch()
|
||||
|
||||
main_layout.addLayout(codec_layout)
|
||||
|
||||
# Network conditions section
|
||||
network_grid = QGridLayout()
|
||||
network_grid.setSpacing(8)
|
||||
|
||||
# Packet loss indicator
|
||||
network_grid.addWidget(QLabel("📉"), 0, 0)
|
||||
self.packet_loss_label = QLabel("Loss: 0%")
|
||||
self.packet_loss_label.setObjectName("networkParam")
|
||||
network_grid.addWidget(self.packet_loss_label, 0, 1)
|
||||
|
||||
# Jitter indicator
|
||||
network_grid.addWidget(QLabel("📊"), 0, 2)
|
||||
self.jitter_label = QLabel("Jitter: 20ms")
|
||||
self.jitter_label.setObjectName("networkParam")
|
||||
network_grid.addWidget(self.jitter_label, 0, 3)
|
||||
|
||||
# Latency indicator
|
||||
network_grid.addWidget(QLabel("⏱"), 1, 0)
|
||||
self.latency_label = QLabel("Latency: 100ms")
|
||||
self.latency_label.setObjectName("networkParam")
|
||||
network_grid.addWidget(self.latency_label, 1, 1)
|
||||
|
||||
# Features status
|
||||
network_grid.addWidget(QLabel("🌊"), 1, 2)
|
||||
self.features_label = QLabel("Standard")
|
||||
self.features_label.setObjectName("networkParam")
|
||||
network_grid.addWidget(self.features_label, 1, 3)
|
||||
|
||||
main_layout.addLayout(network_grid)
|
||||
|
||||
# Connection status
|
||||
separator2 = QFrame()
|
||||
separator2.setFrameShape(QFrame.HLine)
|
||||
separator2.setObjectName("gsmSeparator")
|
||||
main_layout.addWidget(separator2)
|
||||
|
||||
self.connection_status = QLabel("🟢 Connected to GSM Network")
|
||||
self.connection_status.setObjectName("connectionStatus")
|
||||
self.connection_status.setAlignment(Qt.AlignCenter)
|
||||
main_layout.addWidget(self.connection_status)
|
||||
|
||||
main_layout.addStretch()
|
||||
self.setLayout(main_layout)
|
||||
|
||||
# Apply styling
|
||||
self.setStyleSheet("""
|
||||
#gsmStatusWidget {
|
||||
background-color: #2A2A2A;
|
||||
border: 2px solid #0078D4;
|
||||
border-radius: 10px;
|
||||
}
|
||||
#gsmStatusTitle {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
color: #00A2E8;
|
||||
padding: 5px;
|
||||
}
|
||||
#signalDbmLabel {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
color: #4CAF50;
|
||||
}
|
||||
#qualityBar {
|
||||
background-color: #1E1E1E;
|
||||
border: 1px solid #444;
|
||||
border-radius: 3px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
#qualityBar::chunk {
|
||||
background-color: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #4CAF50, stop:0.5 #8BC34A, stop:1 #CDDC39);
|
||||
border-radius: 3px;
|
||||
}
|
||||
#noiseBar {
|
||||
background-color: #1E1E1E;
|
||||
border: 1px solid #444;
|
||||
border-radius: 3px;
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
#noiseBar::chunk {
|
||||
background-color: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #FFC107, stop:0.5 #FF9800, stop:1 #FF5722);
|
||||
border-radius: 3px;
|
||||
}
|
||||
#codecLabel {
|
||||
font-size: 13px;
|
||||
color: #E0E0E0;
|
||||
font-weight: bold;
|
||||
}
|
||||
#networkParam {
|
||||
font-size: 12px;
|
||||
color: #B0B0B0;
|
||||
}
|
||||
#gsmSeparator {
|
||||
background-color: #444;
|
||||
max-height: 1px;
|
||||
margin: 5px 0;
|
||||
}
|
||||
#connectionStatus {
|
||||
font-size: 13px;
|
||||
color: #4CAF50;
|
||||
font-weight: bold;
|
||||
padding: 5px;
|
||||
}
|
||||
QLabel {
|
||||
color: #E0E0E0;
|
||||
}
|
||||
""")
|
||||
|
||||
def update_settings(self, settings):
|
||||
"""Update the displayed settings"""
|
||||
self.settings = settings
|
||||
self.update_display()
|
||||
|
||||
def update_display(self):
|
||||
"""Update all display elements based on current settings"""
|
||||
# Update signal strength
|
||||
self.signal_widget.set_signal_strength(self.settings['signal_strength'])
|
||||
self.signal_dbm_label.setText(f"{self.settings['signal_strength']} dBm")
|
||||
|
||||
# Color code the dBm label
|
||||
if self.settings['signal_strength'] >= -60:
|
||||
self.signal_dbm_label.setStyleSheet("color: #4CAF50;") # Green
|
||||
elif self.settings['signal_strength'] >= -75:
|
||||
self.signal_dbm_label.setStyleSheet("color: #FFC107;") # Amber
|
||||
else:
|
||||
self.signal_dbm_label.setStyleSheet("color: #FF5722;") # Red
|
||||
|
||||
# Update quality bar
|
||||
self.quality_bar.setValue(self.settings['signal_quality'])
|
||||
self.quality_bar.setFormat(f"{self.settings['signal_quality']}%")
|
||||
|
||||
# Update noise bar
|
||||
self.noise_bar.setValue(self.settings['noise_level'])
|
||||
self.noise_bar.setFormat(f"{self.settings['noise_level']}%")
|
||||
|
||||
# Update codec info
|
||||
self.codec_label.setText(f"{self.settings['codec_mode']} @ {self.settings['bitrate']} kbps")
|
||||
|
||||
# Update network parameters
|
||||
self.packet_loss_label.setText(f"Loss: {self.settings['packet_loss']}%")
|
||||
self.jitter_label.setText(f"Jitter: {self.settings['jitter']}ms")
|
||||
self.latency_label.setText(f"Latency: {self.settings['latency']}ms")
|
||||
|
||||
# Update features
|
||||
features = []
|
||||
if self.settings['fading_enabled']:
|
||||
features.append(f"Fading({self.settings['fading_speed']})")
|
||||
if self.settings['interference_enabled']:
|
||||
features.append("Interference")
|
||||
if self.settings['handover_enabled']:
|
||||
features.append("Handover")
|
||||
|
||||
if features:
|
||||
self.features_label.setText(", ".join(features))
|
||||
else:
|
||||
self.features_label.setText("Standard")
|
||||
|
||||
# Update connection status based on signal quality
|
||||
if self.settings['signal_quality'] >= 80:
|
||||
self.connection_status.setText("🟢 Excellent Connection")
|
||||
self.connection_status.setStyleSheet("color: #4CAF50;")
|
||||
elif self.settings['signal_quality'] >= 60:
|
||||
self.connection_status.setText("🟡 Good Connection")
|
||||
self.connection_status.setStyleSheet("color: #FFC107;")
|
||||
elif self.settings['signal_quality'] >= 40:
|
||||
self.connection_status.setText("🟠 Fair Connection")
|
||||
self.connection_status.setStyleSheet("color: #FF9800;")
|
||||
else:
|
||||
self.connection_status.setText("🔴 Poor Connection")
|
||||
self.connection_status.setStyleSheet("color: #FF5722;")
|
@ -1,4 +1,7 @@
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
import atexit
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QPushButton, QLabel, QFrame, QSizePolicy, QStyle, QTextEdit, QSplitter,
|
||||
@ -11,68 +14,136 @@ import threading
|
||||
from phone_manager import PhoneManager
|
||||
from waveform_widget import WaveformWidget
|
||||
from phone_state import PhoneState
|
||||
from gsm_settings_dialog import GSMSettingsDialog
|
||||
from gsm_status_widget import GSMStatusWidget
|
||||
|
||||
class PhoneUI(QMainWindow):
|
||||
debug_signal = pyqtSignal(str)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("DryBox - Noise XK + Codec2 + 4FSK")
|
||||
self.setGeometry(100, 100, 1200, 900)
|
||||
self.setWindowTitle("DryBox - Noise XK + Codec2 (3200bps) + 4FSK")
|
||||
self.setGeometry(100, 100, 1400, 1000)
|
||||
|
||||
# Set minimum size to ensure window is resizable
|
||||
self.setMinimumSize(800, 600)
|
||||
self.setMinimumSize(1200, 800)
|
||||
|
||||
# Auto test state
|
||||
self.auto_test_running = False
|
||||
self.auto_test_timer = None
|
||||
self.test_step = 0
|
||||
|
||||
# GSM settings dialog (will be created on demand)
|
||||
self.gsm_settings_dialog = None
|
||||
self.gsm_settings = None
|
||||
|
||||
# GSM simulation timer
|
||||
self.gsm_simulation_timer = None
|
||||
|
||||
# GSM simulator process
|
||||
self.gsm_simulator_process = None
|
||||
self.start_gsm_simulator()
|
||||
|
||||
self.setStyleSheet("""
|
||||
QMainWindow { background-color: #333333; }
|
||||
QLabel { color: #E0E0E0; font-size: 14px; }
|
||||
QPushButton {
|
||||
background-color: #0078D4; color: white; border: none;
|
||||
padding: 10px 15px; border-radius: 5px; font-size: 14px;
|
||||
min-height: 30px;
|
||||
QMainWindow {
|
||||
background-color: #1a1a1a;
|
||||
}
|
||||
QLabel {
|
||||
color: #E0E0E0;
|
||||
font-size: 14px;
|
||||
}
|
||||
QPushButton {
|
||||
background-color: #0078D4;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 15px;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
min-height: 30px;
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #005A9E;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #003C6B;
|
||||
}
|
||||
QPushButton#settingsButton {
|
||||
background-color: #555555;
|
||||
}
|
||||
QPushButton#settingsButton:hover {
|
||||
background-color: #777777;
|
||||
}
|
||||
QPushButton:hover { background-color: #005A9E; }
|
||||
QPushButton:pressed { background-color: #003C6B; }
|
||||
QPushButton#settingsButton { background-color: #555555; }
|
||||
QPushButton#settingsButton:hover { background-color: #777777; }
|
||||
QFrame#phoneDisplay {
|
||||
background-color: #1E1E1E; border: 2px solid #0078D4;
|
||||
border-radius: 10px;
|
||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||
stop:0 #2A2A2A, stop:1 #1E1E1E);
|
||||
border: 2px solid #0078D4;
|
||||
border-radius: 12px;
|
||||
}
|
||||
QLabel#phoneTitleLabel {
|
||||
font-size: 18px; font-weight: bold; padding-bottom: 5px;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
padding: 10px;
|
||||
color: #FFFFFF;
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #0078D4, stop:0.5 #00A2E8, stop:1 #0078D4);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
QLabel#mainTitleLabel {
|
||||
font-size: 24px; font-weight: bold; color: #00A2E8;
|
||||
padding: 15px;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: #00A2E8;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
QWidget#phoneWidget {
|
||||
border: 2px solid #4A4A4A; border-radius: 10px;
|
||||
background-color: #3A3A3A;
|
||||
min-width: 250px;
|
||||
border: 2px solid #0078D4;
|
||||
border-radius: 15px;
|
||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||
stop:0 #3A3A3A, stop:0.5 #2F2F2F, stop:1 #252525);
|
||||
min-width: 400px;
|
||||
padding: 15px;
|
||||
margin: 5px;
|
||||
}
|
||||
QWidget#phoneWidget:hover {
|
||||
border: 2px solid #00A2E8;
|
||||
}
|
||||
QTextEdit#debugConsole {
|
||||
background-color: #1E1E1E; color: #00FF00;
|
||||
font-family: monospace; font-size: 12px;
|
||||
border: 2px solid #0078D4; border-radius: 5px;
|
||||
background-color: #0a0a0a;
|
||||
color: #00FF00;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
border: 2px solid #0078D4;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
QPushButton#autoTestButton {
|
||||
background-color: #FF8C00; min-height: 35px;
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #FF8C00, stop:1 #FFA500);
|
||||
min-height: 35px;
|
||||
font-size: 15px;
|
||||
}
|
||||
QPushButton#autoTestButton:hover {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #FF7F00, stop:1 #FF9500);
|
||||
}
|
||||
QSplitter::handle {
|
||||
background-color: #0078D4;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
}
|
||||
QSplitter::handle:hover {
|
||||
background-color: #00A2E8;
|
||||
}
|
||||
QPushButton#autoTestButton:hover { background-color: #FF7F00; }
|
||||
""")
|
||||
|
||||
# Setup debug signal early
|
||||
self.debug_signal.connect(self.append_debug)
|
||||
|
||||
self.manager = PhoneManager()
|
||||
self.manager.ui = self # Set UI reference for debug logging
|
||||
self.manager.initialize_phones()
|
||||
# Initialize phone manager after simulator starts
|
||||
QTimer.singleShot(1000, self.initialize_phone_manager)
|
||||
|
||||
# Main widget with splitter
|
||||
main_widget = QWidget()
|
||||
@ -80,9 +151,19 @@ class PhoneUI(QMainWindow):
|
||||
main_layout = QVBoxLayout()
|
||||
main_widget.setLayout(main_layout)
|
||||
|
||||
# Create splitter for phones and debug console
|
||||
# Create main horizontal splitter for GSM status and phones/debug
|
||||
self.main_h_splitter = QSplitter(Qt.Horizontal)
|
||||
main_layout.addWidget(self.main_h_splitter)
|
||||
|
||||
# GSM Status Panel (left side)
|
||||
self.gsm_status_widget = GSMStatusWidget()
|
||||
self.gsm_status_widget.setMinimumWidth(280)
|
||||
self.gsm_status_widget.setMaximumWidth(350)
|
||||
self.main_h_splitter.addWidget(self.gsm_status_widget)
|
||||
|
||||
# Create vertical splitter for phones and debug console (right side)
|
||||
self.splitter = QSplitter(Qt.Vertical)
|
||||
main_layout.addWidget(self.splitter)
|
||||
self.main_h_splitter.addWidget(self.splitter)
|
||||
|
||||
# Top widget for phones
|
||||
phones_widget = QWidget()
|
||||
@ -99,37 +180,16 @@ class PhoneUI(QMainWindow):
|
||||
phones_layout.addWidget(app_title_label)
|
||||
|
||||
# Protocol info
|
||||
protocol_info = QLabel("Noise XK + Codec2 (1200bps) + 4FSK")
|
||||
protocol_info = QLabel("Noise XK + Codec2 (3200bps) + 4FSK")
|
||||
protocol_info.setAlignment(Qt.AlignCenter)
|
||||
protocol_info.setStyleSheet("font-size: 12px; color: #00A2E8;")
|
||||
phones_layout.addWidget(protocol_info)
|
||||
|
||||
# Phone displays layout
|
||||
phone_controls_layout = QHBoxLayout()
|
||||
phone_controls_layout.setSpacing(20)
|
||||
phone_controls_layout.setContentsMargins(10, 0, 10, 0)
|
||||
phones_layout.addLayout(phone_controls_layout)
|
||||
|
||||
# Setup UI for phones
|
||||
for phone in self.manager.phones:
|
||||
phone_container_widget, phone_display_frame, phone_button, waveform_widget, sent_waveform_widget, phone_status_label, playback_button, record_button = self._create_phone_ui(
|
||||
f"Phone {phone['id']+1}", lambda checked, pid=phone['id']: self.manager.phone_action(pid, self)
|
||||
)
|
||||
phone['button'] = phone_button
|
||||
phone['waveform'] = waveform_widget
|
||||
phone['sent_waveform'] = sent_waveform_widget
|
||||
phone['status_label'] = phone_status_label
|
||||
phone['playback_button'] = playback_button
|
||||
phone['record_button'] = record_button
|
||||
|
||||
# Connect audio control buttons with proper closure
|
||||
playback_button.clicked.connect(lambda checked, pid=phone['id']: self.toggle_playback(pid))
|
||||
record_button.clicked.connect(lambda checked, pid=phone['id']: self.toggle_recording(pid))
|
||||
phone_controls_layout.addWidget(phone_container_widget)
|
||||
# Connect data_received signal - it emits (data, client_id)
|
||||
phone['client'].data_received.connect(lambda data, cid: self.manager.update_waveform(cid, data))
|
||||
phone['client'].state_changed.connect(lambda state, num, cid=phone['id']: self.set_phone_state(cid, state, num))
|
||||
phone['client'].start()
|
||||
self.phone_controls_layout = QHBoxLayout()
|
||||
self.phone_controls_layout.setSpacing(20)
|
||||
self.phone_controls_layout.setContentsMargins(10, 0, 10, 0)
|
||||
phones_layout.addLayout(self.phone_controls_layout)
|
||||
|
||||
# Control buttons layout
|
||||
control_layout = QHBoxLayout()
|
||||
@ -187,26 +247,135 @@ class PhoneUI(QMainWindow):
|
||||
self.debug_console.append(msg)
|
||||
del self._debug_queue
|
||||
|
||||
# Set splitter sizes (70% phones, 30% debug)
|
||||
self.splitter.setSizes([600, 300])
|
||||
|
||||
# Initialize UI
|
||||
for phone in self.manager.phones:
|
||||
self.update_phone_ui(phone['id'])
|
||||
# Set splitter sizes
|
||||
self.splitter.setSizes([600, 300]) # 70% phones, 30% debug
|
||||
self.main_h_splitter.setSizes([300, 1100]) # GSM panel: 300px, rest: 1100px
|
||||
|
||||
# Initial debug message
|
||||
QTimer.singleShot(100, lambda: self.debug("DryBox UI initialized with integrated protocol"))
|
||||
|
||||
# Setup keyboard shortcuts
|
||||
self.setup_shortcuts()
|
||||
|
||||
# Placeholder for manager (will be initialized after simulator starts)
|
||||
self.manager = None
|
||||
|
||||
def initialize_phone_manager(self):
|
||||
"""Initialize phone manager after GSM simulator is ready"""
|
||||
self.debug("Initializing phone manager...")
|
||||
self.manager = PhoneManager()
|
||||
self.manager.ui = self # Set UI reference for debug logging
|
||||
self.manager.initialize_phones()
|
||||
|
||||
# Now setup phone UIs
|
||||
self.setup_phone_uis()
|
||||
|
||||
# Initialize UI
|
||||
for phone in self.manager.phones:
|
||||
self.update_phone_ui(phone['id'])
|
||||
|
||||
def setup_phone_uis(self):
|
||||
"""Setup UI for phones after manager is initialized"""
|
||||
# Find the phone controls layout
|
||||
phone_controls_layout = self.phone_controls_layout
|
||||
|
||||
# Setup UI for phones
|
||||
for phone in self.manager.phones:
|
||||
phone_container_widget, phone_display_frame, phone_button, waveform_widget, sent_waveform_widget, phone_status_label, playback_button, record_button = self._create_phone_ui(
|
||||
f"Phone {phone['id']+1}", lambda checked, pid=phone['id']: self.manager.phone_action(pid, self)
|
||||
)
|
||||
phone['button'] = phone_button
|
||||
phone['waveform'] = waveform_widget
|
||||
phone['sent_waveform'] = sent_waveform_widget
|
||||
phone['status_label'] = phone_status_label
|
||||
phone['playback_button'] = playback_button
|
||||
phone['record_button'] = record_button
|
||||
|
||||
# Connect audio control buttons with proper closure
|
||||
playback_button.clicked.connect(lambda checked, pid=phone['id']: self.toggle_playback(pid))
|
||||
record_button.clicked.connect(lambda checked, pid=phone['id']: self.toggle_recording(pid))
|
||||
phone_controls_layout.addWidget(phone_container_widget)
|
||||
# Connect data_received signal - it emits (data, client_id)
|
||||
phone['client'].data_received.connect(lambda data, cid: self.manager.update_waveform(cid, data))
|
||||
phone['client'].state_changed.connect(lambda state, num, cid=phone['id']: self.set_phone_state(cid, state, num))
|
||||
phone['client'].start()
|
||||
|
||||
def start_gsm_simulator(self):
|
||||
"""Start the GSM simulator as a subprocess"""
|
||||
try:
|
||||
# First, try to kill any existing GSM simulator
|
||||
try:
|
||||
subprocess.run(['pkill', '-f', 'gsm_simulator.py'], capture_output=True)
|
||||
time.sleep(0.5) # Give it time to shut down
|
||||
except:
|
||||
pass # Ignore if pkill fails
|
||||
|
||||
# Get the path to the simulator script
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
simulator_path = os.path.join(os.path.dirname(current_dir), 'simulator', 'gsm_simulator.py')
|
||||
|
||||
if not os.path.exists(simulator_path):
|
||||
self.debug(f"ERROR: GSM simulator not found at {simulator_path}")
|
||||
return
|
||||
|
||||
# Start the simulator process
|
||||
self.gsm_simulator_process = subprocess.Popen(
|
||||
[sys.executable, simulator_path],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
universal_newlines=True,
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
# Start thread to read simulator output
|
||||
simulator_thread = threading.Thread(
|
||||
target=self.read_simulator_output,
|
||||
daemon=True
|
||||
)
|
||||
simulator_thread.start()
|
||||
|
||||
# Give simulator time to start
|
||||
time.sleep(0.5)
|
||||
self.debug("GSM simulator started successfully")
|
||||
|
||||
except Exception as e:
|
||||
self.debug(f"ERROR: Failed to start GSM simulator: {e}")
|
||||
|
||||
def read_simulator_output(self):
|
||||
"""Read output from GSM simulator subprocess"""
|
||||
if self.gsm_simulator_process:
|
||||
while True:
|
||||
line = self.gsm_simulator_process.stdout.readline()
|
||||
if not line:
|
||||
break
|
||||
line = line.strip()
|
||||
if line:
|
||||
self.debug(f"[GSM Simulator] {line}")
|
||||
|
||||
# Check for errors
|
||||
stderr = self.gsm_simulator_process.stderr.read()
|
||||
if stderr:
|
||||
self.debug(f"[GSM Simulator ERROR] {stderr}")
|
||||
|
||||
def stop_gsm_simulator(self):
|
||||
"""Stop the GSM simulator subprocess"""
|
||||
if self.gsm_simulator_process:
|
||||
self.debug("Stopping GSM simulator...")
|
||||
self.gsm_simulator_process.terminate()
|
||||
try:
|
||||
self.gsm_simulator_process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.gsm_simulator_process.kill()
|
||||
self.gsm_simulator_process = None
|
||||
self.debug("GSM simulator stopped")
|
||||
|
||||
def _create_phone_ui(self, title, action_slot):
|
||||
phone_container_widget = QWidget()
|
||||
phone_container_widget.setObjectName("phoneWidget")
|
||||
phone_container_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
|
||||
phone_layout = QVBoxLayout()
|
||||
phone_layout.setAlignment(Qt.AlignCenter)
|
||||
phone_layout.setSpacing(10)
|
||||
phone_layout.setAlignment(Qt.AlignTop)
|
||||
phone_layout.setSpacing(15)
|
||||
phone_layout.setContentsMargins(15, 15, 15, 15)
|
||||
phone_container_widget.setLayout(phone_layout)
|
||||
|
||||
@ -215,11 +384,18 @@ class PhoneUI(QMainWindow):
|
||||
phone_title_label.setAlignment(Qt.AlignCenter)
|
||||
phone_layout.addWidget(phone_title_label)
|
||||
|
||||
# Phone display section
|
||||
phone_display_section = QWidget()
|
||||
display_section_layout = QVBoxLayout()
|
||||
display_section_layout.setSpacing(10)
|
||||
display_section_layout.setContentsMargins(0, 0, 0, 0)
|
||||
phone_display_section.setLayout(display_section_layout)
|
||||
|
||||
phone_display_frame = QFrame()
|
||||
phone_display_frame.setObjectName("phoneDisplay")
|
||||
phone_display_frame.setMinimumSize(200, 250)
|
||||
phone_display_frame.setMaximumSize(300, 400)
|
||||
phone_display_frame.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
|
||||
phone_display_frame.setMinimumSize(250, 200)
|
||||
phone_display_frame.setMaximumSize(350, 250)
|
||||
phone_display_frame.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
|
||||
display_content_layout = QVBoxLayout(phone_display_frame)
|
||||
display_content_layout.setAlignment(Qt.AlignCenter)
|
||||
@ -227,36 +403,54 @@ class PhoneUI(QMainWindow):
|
||||
phone_status_label.setAlignment(Qt.AlignCenter)
|
||||
phone_status_label.setFont(QFont("Arial", 16))
|
||||
display_content_layout.addWidget(phone_status_label)
|
||||
phone_layout.addWidget(phone_display_frame, alignment=Qt.AlignCenter)
|
||||
display_section_layout.addWidget(phone_display_frame, alignment=Qt.AlignCenter)
|
||||
|
||||
phone_button = QPushButton()
|
||||
phone_button.setMinimumWidth(100)
|
||||
phone_button.setMaximumWidth(150)
|
||||
phone_button.setIconSize(QSize(20, 20))
|
||||
phone_button.clicked.connect(action_slot)
|
||||
phone_layout.addWidget(phone_button, alignment=Qt.AlignCenter)
|
||||
display_section_layout.addWidget(phone_button, alignment=Qt.AlignCenter)
|
||||
|
||||
phone_layout.addWidget(phone_display_section)
|
||||
|
||||
# Add separator
|
||||
separator = QFrame()
|
||||
separator.setFrameShape(QFrame.HLine)
|
||||
separator.setFrameShadow(QFrame.Sunken)
|
||||
separator.setStyleSheet("background-color: #4A4A4A; max-height: 2px;")
|
||||
phone_layout.addWidget(separator)
|
||||
|
||||
# Waveforms section
|
||||
waveforms_section = QWidget()
|
||||
waveforms_layout = QVBoxLayout()
|
||||
waveforms_layout.setSpacing(10)
|
||||
waveforms_layout.setContentsMargins(0, 0, 0, 0)
|
||||
waveforms_section.setLayout(waveforms_layout)
|
||||
|
||||
# Received waveform
|
||||
waveform_label = QLabel(f"{title} Received")
|
||||
waveform_label.setAlignment(Qt.AlignCenter)
|
||||
waveform_label.setStyleSheet("font-size: 12px; color: #E0E0E0;")
|
||||
phone_layout.addWidget(waveform_label)
|
||||
waveforms_layout.addWidget(waveform_label)
|
||||
waveform_widget = WaveformWidget(dynamic=False)
|
||||
waveform_widget.setMinimumSize(200, 50)
|
||||
waveform_widget.setMaximumSize(300, 80)
|
||||
waveform_widget.setMinimumSize(250, 50)
|
||||
waveform_widget.setMaximumSize(350, 60)
|
||||
waveform_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
phone_layout.addWidget(waveform_widget, alignment=Qt.AlignCenter)
|
||||
waveforms_layout.addWidget(waveform_widget, alignment=Qt.AlignCenter)
|
||||
|
||||
# Sent waveform
|
||||
sent_waveform_label = QLabel(f"{title} Sent")
|
||||
sent_waveform_label.setAlignment(Qt.AlignCenter)
|
||||
sent_waveform_label.setStyleSheet("font-size: 12px; color: #E0E0E0;")
|
||||
phone_layout.addWidget(sent_waveform_label)
|
||||
waveforms_layout.addWidget(sent_waveform_label)
|
||||
sent_waveform_widget = WaveformWidget(dynamic=False)
|
||||
sent_waveform_widget.setMinimumSize(200, 50)
|
||||
sent_waveform_widget.setMaximumSize(300, 80)
|
||||
sent_waveform_widget.setMinimumSize(250, 50)
|
||||
sent_waveform_widget.setMaximumSize(350, 60)
|
||||
sent_waveform_widget.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
phone_layout.addWidget(sent_waveform_widget, alignment=Qt.AlignCenter)
|
||||
waveforms_layout.addWidget(sent_waveform_widget, alignment=Qt.AlignCenter)
|
||||
|
||||
phone_layout.addWidget(waveforms_section)
|
||||
|
||||
# Audio control buttons
|
||||
audio_controls_layout = QHBoxLayout()
|
||||
@ -309,6 +503,8 @@ class PhoneUI(QMainWindow):
|
||||
return phone_container_widget, phone_display_frame, phone_button, waveform_widget, sent_waveform_widget, phone_status_label, playback_button, record_button
|
||||
|
||||
def update_phone_ui(self, phone_id):
|
||||
if not self.manager:
|
||||
return
|
||||
phone = self.manager.phones[phone_id]
|
||||
other_phone = self.manager.phones[1 - phone_id]
|
||||
state = phone['state']
|
||||
@ -317,45 +513,100 @@ class PhoneUI(QMainWindow):
|
||||
status_label = phone['status_label']
|
||||
|
||||
if state == PhoneState.IDLE:
|
||||
button.setText("Call")
|
||||
button.setText("📞 Call")
|
||||
button.setIcon(self.style().standardIcon(QStyle.SP_MediaPlay))
|
||||
status_label.setText("Idle")
|
||||
button.setStyleSheet("background-color: #0078D4;")
|
||||
status_label.setText("📱 Idle")
|
||||
status_label.setStyleSheet("font-size: 18px; color: #888888;")
|
||||
button.setStyleSheet("""
|
||||
QPushButton {
|
||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||
stop:0 #0078D4, stop:1 #005A9E);
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||
stop:0 #00A2E8, stop:1 #0078D4);
|
||||
}
|
||||
""")
|
||||
elif state == PhoneState.CALLING:
|
||||
button.setText("Cancel")
|
||||
button.setText("❌ Cancel")
|
||||
button.setIcon(self.style().standardIcon(QStyle.SP_MediaStop))
|
||||
status_label.setText(f"Calling {phone_number}...")
|
||||
button.setStyleSheet("background-color: #E81123;")
|
||||
status_label.setText(f"📲 Calling {phone_number}...")
|
||||
status_label.setStyleSheet("font-size: 18px; color: #FFC107;")
|
||||
button.setStyleSheet("""
|
||||
QPushButton {
|
||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||
stop:0 #E81123, stop:1 #B20F1F);
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||
stop:0 #FF1133, stop:1 #E81123);
|
||||
}
|
||||
""")
|
||||
elif state == PhoneState.IN_CALL:
|
||||
button.setText("Hang Up")
|
||||
button.setText("📵 Hang Up")
|
||||
button.setIcon(self.style().standardIcon(QStyle.SP_DialogCancelButton))
|
||||
status_label.setText(f"In Call with {phone_number}")
|
||||
button.setStyleSheet("background-color: #E81123;")
|
||||
status_label.setText(f"📞 In Call with {phone_number}")
|
||||
status_label.setStyleSheet("font-size: 18px; color: #4CAF50;")
|
||||
button.setStyleSheet("""
|
||||
QPushButton {
|
||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||
stop:0 #E81123, stop:1 #B20F1F);
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||
stop:0 #FF1133, stop:1 #E81123);
|
||||
}
|
||||
""")
|
||||
elif state == PhoneState.RINGING:
|
||||
button.setText("Answer")
|
||||
button.setText("✅ Answer")
|
||||
button.setIcon(self.style().standardIcon(QStyle.SP_DialogApplyButton))
|
||||
status_label.setText(f"Incoming Call from {phone_number}")
|
||||
button.setStyleSheet("background-color: #107C10;")
|
||||
status_label.setText(f"📱 Incoming Call from {phone_number}")
|
||||
status_label.setStyleSheet("font-size: 18px; color: #4CAF50; font-weight: bold;")
|
||||
button.setStyleSheet("""
|
||||
QPushButton {
|
||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||
stop:0 #107C10, stop:1 #0B5C0B);
|
||||
font-weight: bold;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||
stop:0 #20AC20, stop:1 #107C10);
|
||||
}
|
||||
""")
|
||||
|
||||
def set_phone_state(self, client_id, state_str, number):
|
||||
self.debug(f"Phone {client_id + 1} state change: {state_str}")
|
||||
|
||||
if not self.manager:
|
||||
return
|
||||
|
||||
# Handle protocol-specific states
|
||||
if state_str == "HANDSHAKE_COMPLETE":
|
||||
phone = self.manager.phones[client_id]
|
||||
phone['status_label'].setText("🔒 Secure Channel Established")
|
||||
phone['status_label'].setStyleSheet("font-size: 16px; color: #00A2E8; font-weight: bold;")
|
||||
self.debug(f"Phone {client_id + 1} secure channel established")
|
||||
self.manager.start_audio(client_id, parent=self)
|
||||
return
|
||||
elif state_str == "VOICE_START":
|
||||
phone = self.manager.phones[client_id]
|
||||
phone['status_label'].setText("🎤 Voice Active (Encrypted)")
|
||||
phone['status_label'].setStyleSheet("font-size: 16px; color: #4CAF50; font-weight: bold;")
|
||||
self.debug(f"Phone {client_id + 1} voice session started")
|
||||
# Start simulating real-time GSM parameter updates if we have settings
|
||||
if hasattr(self, 'gsm_settings') and self.gsm_settings:
|
||||
self.start_gsm_simulation()
|
||||
return
|
||||
elif state_str == "VOICE_END":
|
||||
phone = self.manager.phones[client_id]
|
||||
phone['status_label'].setText("🔒 Secure Channel")
|
||||
phone['status_label'].setStyleSheet("font-size: 16px; color: #00A2E8;")
|
||||
self.debug(f"Phone {client_id + 1} voice session ended")
|
||||
# Stop GSM simulation
|
||||
self.stop_gsm_simulation()
|
||||
return
|
||||
|
||||
# Handle regular states
|
||||
@ -383,8 +634,61 @@ class PhoneUI(QMainWindow):
|
||||
self.update_phone_ui(client_id)
|
||||
|
||||
def settings_action(self):
|
||||
print("Settings clicked")
|
||||
self.debug("Settings clicked")
|
||||
"""Show GSM settings dialog"""
|
||||
self.debug("Opening GSM settings dialog")
|
||||
|
||||
# Create dialog if not exists (lazy initialization)
|
||||
if self.gsm_settings_dialog is None:
|
||||
self.gsm_settings_dialog = GSMSettingsDialog(self)
|
||||
# Connect signal to handle settings changes
|
||||
self.gsm_settings_dialog.settings_changed.connect(self.on_gsm_settings_changed)
|
||||
|
||||
# Show dialog
|
||||
if self.gsm_settings_dialog.exec_():
|
||||
# User clicked OK, get the settings
|
||||
self.gsm_settings = self.gsm_settings_dialog.get_settings()
|
||||
self.debug("GSM settings updated:")
|
||||
self.debug(f" Signal strength: {self.gsm_settings['signal_strength']} dBm")
|
||||
self.debug(f" Signal quality: {self.gsm_settings['signal_quality']}%")
|
||||
self.debug(f" Noise level: {self.gsm_settings['noise_level']}%")
|
||||
self.debug(f" Codec: {self.gsm_settings['codec_mode']} @ {self.gsm_settings['bitrate']} kbps")
|
||||
self.debug(f" Network conditions - Packet loss: {self.gsm_settings['packet_loss']}%, Jitter: {self.gsm_settings['jitter']}ms, Latency: {self.gsm_settings['latency']}ms")
|
||||
|
||||
# Apply settings to phones if needed
|
||||
self.apply_gsm_settings()
|
||||
# Update GSM status widget
|
||||
self.gsm_status_widget.update_settings(self.gsm_settings)
|
||||
else:
|
||||
self.debug("GSM settings dialog cancelled")
|
||||
|
||||
def on_gsm_settings_changed(self, settings):
|
||||
"""Handle real-time settings changes if needed"""
|
||||
self.gsm_settings = settings
|
||||
self.debug("GSM settings changed (real-time update)")
|
||||
# Update GSM status widget in real-time
|
||||
self.gsm_status_widget.update_settings(settings)
|
||||
|
||||
def apply_gsm_settings(self):
|
||||
"""Apply GSM settings to the phone simulation"""
|
||||
if self.gsm_settings and self.manager:
|
||||
# Here you can apply the settings to your phone manager or simulation
|
||||
# For example, update signal quality indicators, adjust codec parameters, etc.
|
||||
self.debug("Applying GSM settings to simulation...")
|
||||
|
||||
# Update UI to reflect signal conditions
|
||||
for phone_id, phone in enumerate(self.manager.phones):
|
||||
signal_quality = self.gsm_settings['signal_quality']
|
||||
if signal_quality >= 80:
|
||||
signal_icon = "📶" # Full signal
|
||||
elif signal_quality >= 60:
|
||||
signal_icon = "📶" # Good signal
|
||||
elif signal_quality >= 40:
|
||||
signal_icon = "📵" # Fair signal
|
||||
else:
|
||||
signal_icon = "📵" # Poor signal
|
||||
|
||||
# You can update status labels or other UI elements here
|
||||
self.debug(f"Phone {phone_id + 1} signal quality: {signal_quality}% {signal_icon}")
|
||||
|
||||
def debug(self, message):
|
||||
"""Thread-safe debug logging to both console and UI"""
|
||||
@ -449,6 +753,10 @@ class PhoneUI(QMainWindow):
|
||||
|
||||
def execute_test_step(self):
|
||||
"""Execute next step in test sequence"""
|
||||
if not self.manager:
|
||||
self.debug("Test step skipped - manager not initialized")
|
||||
return
|
||||
|
||||
phone1 = self.manager.phones[0]
|
||||
phone2 = self.manager.phones[1]
|
||||
|
||||
@ -570,6 +878,8 @@ class PhoneUI(QMainWindow):
|
||||
|
||||
def toggle_playback(self, phone_id):
|
||||
"""Toggle audio playback for a phone"""
|
||||
if not self.manager:
|
||||
return
|
||||
is_enabled = self.manager.toggle_playback(phone_id)
|
||||
phone = self.manager.phones[phone_id]
|
||||
phone['playback_button'].setChecked(is_enabled)
|
||||
@ -581,6 +891,8 @@ class PhoneUI(QMainWindow):
|
||||
|
||||
def toggle_recording(self, phone_id):
|
||||
"""Toggle audio recording for a phone"""
|
||||
if not self.manager:
|
||||
return
|
||||
is_recording, save_path = self.manager.toggle_recording(phone_id)
|
||||
phone = self.manager.phones[phone_id]
|
||||
phone['record_button'].setChecked(is_recording)
|
||||
@ -643,6 +955,8 @@ class PhoneUI(QMainWindow):
|
||||
|
||||
def export_audio_buffer(self, phone_id):
|
||||
"""Export audio buffer for a phone"""
|
||||
if not self.manager:
|
||||
return
|
||||
save_path = self.manager.export_buffered_audio(phone_id)
|
||||
if save_path:
|
||||
self.debug(f"Phone {phone_id + 1}: Audio buffer exported to {save_path}")
|
||||
@ -651,10 +965,14 @@ class PhoneUI(QMainWindow):
|
||||
|
||||
def clear_audio_buffer(self, phone_id):
|
||||
"""Clear audio buffer for a phone"""
|
||||
if not self.manager:
|
||||
return
|
||||
self.manager.clear_audio_buffer(phone_id)
|
||||
|
||||
def process_audio(self, phone_id, processing_type):
|
||||
"""Process audio with specified type"""
|
||||
if not self.manager:
|
||||
return
|
||||
save_path = self.manager.process_audio(phone_id, processing_type)
|
||||
if save_path:
|
||||
self.debug(f"Phone {phone_id + 1}: Processed audio saved to {save_path}")
|
||||
@ -663,6 +981,8 @@ class PhoneUI(QMainWindow):
|
||||
|
||||
def apply_gain_dialog(self, phone_id):
|
||||
"""Show dialog to get gain value"""
|
||||
if not self.manager:
|
||||
return
|
||||
gain, ok = QInputDialog.getDouble(
|
||||
self, "Apply Gain", "Enter gain in dB:",
|
||||
0.0, -20.0, 20.0, 1
|
||||
@ -675,12 +995,12 @@ class PhoneUI(QMainWindow):
|
||||
def setup_shortcuts(self):
|
||||
"""Setup keyboard shortcuts"""
|
||||
# Phone 1 shortcuts
|
||||
QShortcut(QKeySequence("1"), self, lambda: self.manager.phone_action(0, self))
|
||||
QShortcut(QKeySequence("1"), self, lambda: self.manager.phone_action(0, self) if self.manager else None)
|
||||
QShortcut(QKeySequence("Ctrl+1"), self, lambda: self.toggle_playback(0))
|
||||
QShortcut(QKeySequence("Alt+1"), self, lambda: self.toggle_recording(0))
|
||||
|
||||
# Phone 2 shortcuts
|
||||
QShortcut(QKeySequence("2"), self, lambda: self.manager.phone_action(1, self))
|
||||
QShortcut(QKeySequence("2"), self, lambda: self.manager.phone_action(1, self) if self.manager else None)
|
||||
QShortcut(QKeySequence("Ctrl+2"), self, lambda: self.toggle_playback(1))
|
||||
QShortcut(QKeySequence("Alt+2"), self, lambda: self.toggle_recording(1))
|
||||
|
||||
@ -688,6 +1008,7 @@ class PhoneUI(QMainWindow):
|
||||
QShortcut(QKeySequence("Space"), self, self.toggle_auto_test)
|
||||
QShortcut(QKeySequence("Ctrl+L"), self, self.clear_debug)
|
||||
QShortcut(QKeySequence("Ctrl+A"), self, self.show_audio_menu)
|
||||
QShortcut(QKeySequence("Ctrl+G"), self, self.settings_action)
|
||||
|
||||
self.debug("Keyboard shortcuts enabled:")
|
||||
self.debug(" 1/2: Phone action (call/answer/hangup)")
|
||||
@ -696,15 +1017,88 @@ class PhoneUI(QMainWindow):
|
||||
self.debug(" Space: Toggle auto test")
|
||||
self.debug(" Ctrl+L: Clear debug")
|
||||
self.debug(" Ctrl+A: Audio options menu")
|
||||
self.debug(" Ctrl+G: GSM settings")
|
||||
|
||||
def start_gsm_simulation(self):
|
||||
"""Start simulating GSM parameter changes during call"""
|
||||
if not self.gsm_simulation_timer:
|
||||
self.gsm_simulation_timer = QTimer()
|
||||
self.gsm_simulation_timer.timeout.connect(self.update_gsm_simulation)
|
||||
self.gsm_simulation_timer.start(1000) # Update every second
|
||||
self.debug("Started GSM parameter simulation")
|
||||
|
||||
def stop_gsm_simulation(self):
|
||||
"""Stop GSM parameter simulation"""
|
||||
if self.gsm_simulation_timer:
|
||||
self.gsm_simulation_timer.stop()
|
||||
self.gsm_simulation_timer = None
|
||||
self.debug("Stopped GSM parameter simulation")
|
||||
|
||||
def update_gsm_simulation(self):
|
||||
"""Simulate realistic GSM parameter variations during call"""
|
||||
if not hasattr(self, 'gsm_settings') or not self.gsm_settings:
|
||||
return
|
||||
|
||||
import random
|
||||
|
||||
# Simulate signal strength variation (±2 dBm)
|
||||
current_strength = self.gsm_settings['signal_strength']
|
||||
variation = random.uniform(-2, 2)
|
||||
new_strength = max(-120, min(-40, current_strength + variation))
|
||||
self.gsm_settings['signal_strength'] = int(new_strength)
|
||||
|
||||
# Simulate signal quality variation (±3%)
|
||||
current_quality = self.gsm_settings['signal_quality']
|
||||
quality_variation = random.uniform(-3, 3)
|
||||
new_quality = max(0, min(100, current_quality + quality_variation))
|
||||
self.gsm_settings['signal_quality'] = int(new_quality)
|
||||
|
||||
# Simulate noise level variation (±1%)
|
||||
current_noise = self.gsm_settings['noise_level']
|
||||
noise_variation = random.uniform(-1, 2)
|
||||
new_noise = max(0, min(50, current_noise + noise_variation))
|
||||
self.gsm_settings['noise_level'] = int(new_noise)
|
||||
|
||||
# Simulate packet loss variation (occasional spikes)
|
||||
if random.random() < 0.1: # 10% chance of packet loss spike
|
||||
self.gsm_settings['packet_loss'] = random.randint(1, 5)
|
||||
else:
|
||||
self.gsm_settings['packet_loss'] = 0
|
||||
|
||||
# Simulate jitter variation (±5ms)
|
||||
current_jitter = self.gsm_settings['jitter']
|
||||
jitter_variation = random.uniform(-5, 5)
|
||||
new_jitter = max(0, min(200, current_jitter + jitter_variation))
|
||||
self.gsm_settings['jitter'] = int(new_jitter)
|
||||
|
||||
# Update the GSM status widget
|
||||
self.gsm_status_widget.update_settings(self.gsm_settings)
|
||||
|
||||
def closeEvent(self, event):
|
||||
if self.auto_test_running:
|
||||
self.stop_auto_test()
|
||||
|
||||
# Stop GSM simulation
|
||||
self.stop_gsm_simulation()
|
||||
|
||||
# Stop GSM simulator process
|
||||
self.stop_gsm_simulator()
|
||||
|
||||
# Clean up GSM settings dialog
|
||||
if self.gsm_settings_dialog is not None:
|
||||
self.gsm_settings_dialog.close()
|
||||
self.gsm_settings_dialog.deleteLater()
|
||||
self.gsm_settings_dialog = None
|
||||
|
||||
# Clean up audio player
|
||||
if hasattr(self.manager, 'audio_player'):
|
||||
if self.manager and hasattr(self.manager, 'audio_player'):
|
||||
self.manager.audio_player.cleanup()
|
||||
for phone in self.manager.phones:
|
||||
phone['client'].stop()
|
||||
|
||||
# Stop all phone clients
|
||||
if self.manager:
|
||||
for phone in self.manager.phones:
|
||||
phone['client'].stop()
|
||||
|
||||
event.accept()
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
@ -145,10 +145,10 @@ class PhoneManager:
|
||||
# Send through protocol (codec + 4FSK + encryption)
|
||||
phone['client'].send_voice_frame(frames)
|
||||
|
||||
# Update waveform
|
||||
if len(frames) >= 2:
|
||||
samples = struct.unpack(f'{len(frames)//2}h', frames)
|
||||
self.update_sent_waveform(phone_id, frames)
|
||||
# Update waveform only every 5 frames to reduce CPU usage
|
||||
if phone['frame_counter'] % 5 == 0:
|
||||
if len(frames) >= 2:
|
||||
self.update_sent_waveform(phone_id, frames)
|
||||
|
||||
# If playback is enabled on the sender, play the original audio
|
||||
if phone['playback_enabled']:
|
||||
@ -198,8 +198,6 @@ class PhoneManager:
|
||||
if len(data) < 320: # Less than 160 samples (too small for audio)
|
||||
self.debug(f"Phone {client_id + 1} received non-audio data: {len(data)} bytes (ignoring)")
|
||||
return
|
||||
|
||||
self.phones[client_id]['waveform'].set_data(data)
|
||||
|
||||
# Debug log audio data reception (only occasionally to avoid spam)
|
||||
if not hasattr(self, '_audio_frame_count'):
|
||||
@ -208,6 +206,10 @@ class PhoneManager:
|
||||
self._audio_frame_count[client_id] = 0
|
||||
self._audio_frame_count[client_id] += 1
|
||||
|
||||
# Update waveform only every 5 frames to reduce CPU usage
|
||||
if self._audio_frame_count[client_id] % 5 == 0:
|
||||
self.phones[client_id]['waveform'].set_data(data)
|
||||
|
||||
if self._audio_frame_count[client_id] == 1 or self._audio_frame_count[client_id] % 25 == 0:
|
||||
self.debug(f"Phone {client_id + 1} received audio frame #{self._audio_frame_count[client_id]}: {len(data)} bytes")
|
||||
|
||||
|
@ -11,7 +11,8 @@ from dissononce.dh.keypair import KeyPair
|
||||
from dissononce.dh.x25519.public import PublicKey
|
||||
import sys
|
||||
import os
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
# Add path to access voice_codec from Prototype directory
|
||||
sys.path.append(os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'Prototype', 'Protocol_Alpha_0'))
|
||||
from voice_codec import Codec2Wrapper, FSKModem, Codec2Mode
|
||||
# ChaCha20 removed - using only Noise XK encryption
|
||||
|
||||
@ -37,8 +38,8 @@ class ProtocolPhoneClient(QThread):
|
||||
|
||||
# No buffer needed with larger frame size
|
||||
|
||||
# Voice codec components
|
||||
self.codec = Codec2Wrapper(mode=Codec2Mode.MODE_1200)
|
||||
# Voice codec components - use higher quality mode
|
||||
self.codec = Codec2Wrapper(mode=Codec2Mode.MODE_3200) # Changed from 1200 to 3200 bps for better quality
|
||||
self.modem = FSKModem()
|
||||
|
||||
# Voice encryption handled by Noise XK
|
||||
@ -226,7 +227,7 @@ class ProtocolPhoneClient(QThread):
|
||||
# Create Codec2Frame from demodulated data
|
||||
from voice_codec import Codec2Frame, Codec2Mode
|
||||
frame = Codec2Frame(
|
||||
mode=Codec2Mode.MODE_1200,
|
||||
mode=Codec2Mode.MODE_3200, # Match the encoder mode
|
||||
bits=demodulated_data,
|
||||
timestamp=time.time(),
|
||||
frame_number=self.voice_frame_counter
|
||||
|
@ -10,15 +10,18 @@ class WaveformWidget(QWidget):
|
||||
self.dynamic = dynamic
|
||||
self.setMinimumSize(200, 60)
|
||||
self.setMaximumHeight(80)
|
||||
self.waveform_data = [random.randint(10, 90) for _ in range(50)]
|
||||
# Start with flat line instead of random data
|
||||
self.waveform_data = [50 for _ in range(50)]
|
||||
if self.dynamic:
|
||||
self.timer = QTimer(self)
|
||||
self.timer.timeout.connect(self.update_waveform)
|
||||
self.timer.start(100)
|
||||
|
||||
def update_waveform(self):
|
||||
self.waveform_data = self.waveform_data[1:] + [random.randint(10, 90)]
|
||||
self.update()
|
||||
# Only update with random data if dynamic mode is enabled
|
||||
if self.dynamic:
|
||||
self.waveform_data = self.waveform_data[1:] + [random.randint(10, 90)]
|
||||
self.update()
|
||||
|
||||
def set_data(self, data):
|
||||
# Convert audio data to visual amplitude
|
||||
|
@ -1,11 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Run DryBox UI with proper Wayland support on Fedora
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Use native Wayland if available
|
||||
export QT_QPA_PLATFORM=wayland
|
||||
|
||||
# Run the UI
|
||||
cd UI
|
||||
python3 main.py
|
@ -1,14 +0,0 @@
|
||||
# Use official Python image
|
||||
FROM python:3.9-slim
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the simulator script
|
||||
COPY gsm_simulator.py .
|
||||
|
||||
# Expose the port
|
||||
EXPOSE 12345
|
||||
|
||||
# Run the simulator
|
||||
CMD ["python", "gsm_simulator.py"]
|
@ -1,68 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to launch the GSM Simulator in Docker
|
||||
|
||||
# Variables
|
||||
IMAGE_NAME="gsm-simulator"
|
||||
CONTAINER_NAME="gsm-sim"
|
||||
PORT="12345"
|
||||
LOG_FILE="gsm_simulator.log"
|
||||
|
||||
# Check if Docker is installed
|
||||
if ! command -v docker &> /dev/null; then
|
||||
echo "Error: Docker is not installed. Please install Docker and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if gsm_simulator.py exists
|
||||
if [ ! -f "gsm_simulator.py" ]; then
|
||||
echo "Error: gsm_simulator.py not found in the current directory."
|
||||
echo "Please ensure gsm_simulator.py is present and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create Dockerfile if it doesn't exist
|
||||
if [ ! -f "Dockerfile" ]; then
|
||||
echo "Creating Dockerfile..."
|
||||
cat <<EOF > Dockerfile
|
||||
FROM python:3.9-slim
|
||||
WORKDIR /app
|
||||
COPY gsm_simulator.py .
|
||||
EXPOSE 12345
|
||||
CMD ["python", "gsm_simulator.py"]
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Ensure log file is writable
|
||||
touch $LOG_FILE
|
||||
chmod 666 $LOG_FILE
|
||||
|
||||
# Build the Docker image
|
||||
echo "Building Docker image: $IMAGE_NAME..."
|
||||
docker build -t $IMAGE_NAME .
|
||||
|
||||
# Check if the build was successful
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: Failed to build Docker image."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Stop and remove any existing container
|
||||
if [ "$(docker ps -q -f name=$CONTAINER_NAME)" ]; then
|
||||
echo "Stopping existing container: $CONTAINER_NAME..."
|
||||
docker stop $CONTAINER_NAME
|
||||
fi
|
||||
if [ "$(docker ps -aq -f name=$CONTAINER_NAME)" ]; then
|
||||
echo "Removing existing container: $CONTAINER_NAME..."
|
||||
docker rm $CONTAINER_NAME
|
||||
fi
|
||||
|
||||
# Clean up dangling images
|
||||
docker image prune -f
|
||||
|
||||
# Run the Docker container interactively
|
||||
echo "Launching GSM Simulator in Docker container: $CONTAINER_NAME..."
|
||||
docker run -it --rm -p $PORT:$PORT --name $CONTAINER_NAME $IMAGE_NAME | tee $LOG_FILE
|
||||
|
||||
# Note: Script will block here until container exits
|
||||
echo "GSM Simulator stopped. Logs saved to $LOG_FILE."
|
Binary file not shown.
Binary file not shown.
@ -0,0 +1,566 @@
|
||||
<mxfile host="app.diagrams.net" agent="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36" version="26.1.3" pages="2">
|
||||
<diagram id="C5RBs43oDa-KdzZeNtuy" name="Logique">
|
||||
<mxGraphModel dx="735" dy="407" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="WIyWlLk6GJQsqaUBKTNV-0" />
|
||||
<mxCell id="WIyWlLk6GJQsqaUBKTNV-1" parent="WIyWlLk6GJQsqaUBKTNV-0" />
|
||||
<mxCell id="WIyWlLk6GJQsqaUBKTNV-2" value="" style="rounded=0;html=1;jettySize=auto;orthogonalLoop=1;fontSize=11;endArrow=block;endFill=0;endSize=8;strokeWidth=1;shadow=0;labelBackgroundColor=none;edgeStyle=orthogonalEdgeStyle;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="WIyWlLk6GJQsqaUBKTNV-3" target="WIyWlLk6GJQsqaUBKTNV-6" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="WIyWlLk6GJQsqaUBKTNV-3" value="Alice appelle Bob" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=3;shadow=0;strokeColor=light-dark(#000000,#370FFF);" parent="WIyWlLk6GJQsqaUBKTNV-1" vertex="1">
|
||||
<mxGeometry x="300" y="90" width="120" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="WIyWlLk6GJQsqaUBKTNV-4" value="Yes" style="rounded=0;html=1;jettySize=auto;orthogonalLoop=1;fontSize=11;endArrow=block;endFill=0;endSize=8;strokeWidth=1;shadow=0;labelBackgroundColor=none;edgeStyle=orthogonalEdgeStyle;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="FXGPDhTRSO2FZSW48CnP-0" target="WIyWlLk6GJQsqaUBKTNV-10" edge="1">
|
||||
<mxGeometry y="20" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="WIyWlLk6GJQsqaUBKTNV-5" value="No" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;jettySize=auto;orthogonalLoop=1;fontSize=11;endArrow=block;endFill=0;endSize=8;strokeWidth=1;shadow=0;labelBackgroundColor=none;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="WIyWlLk6GJQsqaUBKTNV-6" target="WIyWlLk6GJQsqaUBKTNV-7" edge="1">
|
||||
<mxGeometry y="10" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="WIyWlLk6GJQsqaUBKTNV-6" value="Bob répond ?" style="rhombus;whiteSpace=wrap;html=1;shadow=0;fontFamily=Helvetica;fontSize=12;align=center;strokeWidth=1;spacing=6;spacingTop=-4;" parent="WIyWlLk6GJQsqaUBKTNV-1" vertex="1">
|
||||
<mxGeometry x="310" y="180" width="100" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="WIyWlLk6GJQsqaUBKTNV-7" value="Rien ne se passe" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;" parent="WIyWlLk6GJQsqaUBKTNV-1" vertex="1">
|
||||
<mxGeometry x="460" y="200" width="120" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="WIyWlLk6GJQsqaUBKTNV-8" value="Négativement" style="rounded=0;html=1;jettySize=auto;orthogonalLoop=1;fontSize=11;endArrow=block;endFill=0;endSize=8;strokeWidth=1;shadow=0;labelBackgroundColor=none;edgeStyle=orthogonalEdgeStyle;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="WIyWlLk6GJQsqaUBKTNV-10" target="WIyWlLk6GJQsqaUBKTNV-11" edge="1">
|
||||
<mxGeometry y="40" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="WIyWlLk6GJQsqaUBKTNV-9" value="Positivement" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;jettySize=auto;orthogonalLoop=1;fontSize=11;endArrow=block;endFill=0;endSize=8;strokeWidth=1;shadow=0;labelBackgroundColor=none;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="WIyWlLk6GJQsqaUBKTNV-10" target="WIyWlLk6GJQsqaUBKTNV-12" edge="1">
|
||||
<mxGeometry y="10" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="WIyWlLk6GJQsqaUBKTNV-10" value="Bob ping..." style="whiteSpace=wrap;html=1;shadow=0;fontFamily=Helvetica;fontSize=12;align=center;strokeWidth=1;spacing=6;spacingTop=-4;shape=hexagon;perimeter=hexagonPerimeter2;fixedSize=1;" parent="WIyWlLk6GJQsqaUBKTNV-1" vertex="1">
|
||||
<mxGeometry x="310" y="390" width="100" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="WIyWlLk6GJQsqaUBKTNV-11" value="Protocole échoué<div>-</div><div>Passage en clair</div>" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=3;shadow=0;strokeColor=light-dark(#000000,#FF1414);" parent="WIyWlLk6GJQsqaUBKTNV-1" vertex="1">
|
||||
<mxGeometry x="300" y="520" width="120" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-7" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;endArrow=block;endFill=0;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="WIyWlLk6GJQsqaUBKTNV-12" target="FXGPDhTRSO2FZSW48CnP-8" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="599.9999999999998" y="500" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="WIyWlLk6GJQsqaUBKTNV-12" value="Alice envoi son<span style="background-color: transparent; color: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));">&nbsp;handshake a Bob</span>" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;" parent="WIyWlLk6GJQsqaUBKTNV-1" vertex="1">
|
||||
<mxGeometry x="540" y="410" width="120" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-1" value="" style="rounded=0;html=1;jettySize=auto;orthogonalLoop=1;fontSize=11;endArrow=block;endFill=0;endSize=8;strokeWidth=1;shadow=0;labelBackgroundColor=none;edgeStyle=orthogonalEdgeStyle;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="WIyWlLk6GJQsqaUBKTNV-6" target="FXGPDhTRSO2FZSW48CnP-0" edge="1">
|
||||
<mxGeometry y="20" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
<mxPoint x="360" y="260" as="sourcePoint" />
|
||||
<mxPoint x="360" y="390" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-0" value="Le dialer d'Alice envoi un PING a Bob" style="rounded=1;whiteSpace=wrap;html=1;fontSize=12;glass=0;strokeWidth=1;shadow=0;" parent="WIyWlLk6GJQsqaUBKTNV-1" vertex="1">
|
||||
<mxGeometry x="300" y="300" width="120" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-2" value="" style="endArrow=block;html=1;rounded=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;endFill=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;edgeStyle=orthogonalEdgeStyle;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="WIyWlLk6GJQsqaUBKTNV-10" target="FXGPDhTRSO2FZSW48CnP-4" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="480" y="450" as="sourcePoint" />
|
||||
<mxPoint x="190" y="430" as="targetPoint" />
|
||||
<Array as="points">
|
||||
<mxPoint x="280" y="430" />
|
||||
<mxPoint x="280" y="460" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-3" value="Ne ping pas" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="FXGPDhTRSO2FZSW48CnP-2" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.2695" relative="1" as="geometry">
|
||||
<mxPoint x="-16" y="-10" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-5" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="FXGPDhTRSO2FZSW48CnP-4" target="FXGPDhTRSO2FZSW48CnP-0" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="130" y="320" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-6" value="Reping" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="FXGPDhTRSO2FZSW48CnP-5" vertex="1" connectable="0">
|
||||
<mxGeometry x="0.0817" relative="1" as="geometry">
|
||||
<mxPoint x="23" y="-10" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-4" value="Attendre 1s ? 0.5s ?" style="rounded=1;whiteSpace=wrap;html=1;" parent="WIyWlLk6GJQsqaUBKTNV-1" vertex="1">
|
||||
<mxGeometry x="70" y="440" width="120" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-12" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;endArrow=block;endFill=0;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="FXGPDhTRSO2FZSW48CnP-8" target="FXGPDhTRSO2FZSW48CnP-11" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="530.0380952380951" y="585.0038095238094" as="sourcePoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-13" value="Non" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="FXGPDhTRSO2FZSW48CnP-12" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.4964" y="-1" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-43" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0.5;exitY=1;exitDx=0;exitDy=0;endArrow=block;endFill=0;strokeColor=light-dark(#000000,#FF0000);" parent="WIyWlLk6GJQsqaUBKTNV-1" source="FXGPDhTRSO2FZSW48CnP-8" target="FXGPDhTRSO2FZSW48CnP-27" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="600" y="800" as="targetPoint" />
|
||||
<Array as="points">
|
||||
<mxPoint x="600" y="660" />
|
||||
<mxPoint x="570" y="660" />
|
||||
<mxPoint x="570" y="700" />
|
||||
<mxPoint x="210" y="700" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-44" value="Oui" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="FXGPDhTRSO2FZSW48CnP-43" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.8049" y="1" relative="1" as="geometry">
|
||||
<mxPoint x="8" y="-25" as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-8" value="Bob reconnait la clé publique d'Alice ?" style="rhombus;whiteSpace=wrap;html=1;fontSize=10;" parent="WIyWlLk6GJQsqaUBKTNV-1" vertex="1">
|
||||
<mxGeometry x="540" y="545" width="120" height="75" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-16" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=block;endFill=0;entryX=0.5;entryY=1;entryDx=0;entryDy=0;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="FXGPDhTRSO2FZSW48CnP-11" target="WIyWlLk6GJQsqaUBKTNV-11" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="360" y="600" as="targetPoint" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-17" value="Non" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="FXGPDhTRSO2FZSW48CnP-16" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.1233" y="-2" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-20" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=block;endFill=0;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="FXGPDhTRSO2FZSW48CnP-11" target="FXGPDhTRSO2FZSW48CnP-19" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-21" value="Oui" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="FXGPDhTRSO2FZSW48CnP-20" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.275" y="1" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-11" value="Bob accepte la clé d'Alice?" style="rhombus;whiteSpace=wrap;html=1;fontSize=10;" parent="WIyWlLk6GJQsqaUBKTNV-1" vertex="1">
|
||||
<mxGeometry x="354" y="620" width="120" height="75" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-23" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=block;endFill=0;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="FXGPDhTRSO2FZSW48CnP-19" target="FXGPDhTRSO2FZSW48CnP-22" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-19" value="Bob envoi sa clé publique en handshake" style="whiteSpace=wrap;html=1;fontSize=10;rounded=1;" parent="WIyWlLk6GJQsqaUBKTNV-1" vertex="1">
|
||||
<mxGeometry x="160" y="636.25" width="120" height="42.5" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-24" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;endArrow=block;endFill=0;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="FXGPDhTRSO2FZSW48CnP-22" target="WIyWlLk6GJQsqaUBKTNV-11" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="70" y="545" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-25" value="Non" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="FXGPDhTRSO2FZSW48CnP-24" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.7543" y="3" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-28" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;endArrow=block;endFill=0;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="FXGPDhTRSO2FZSW48CnP-22" target="FXGPDhTRSO2FZSW48CnP-27" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="70" y="750" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-22" value="Alice accepte la clé publique de Bob<span style="background-color: transparent; color: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));">&nbsp;?</span>" style="rhombus;whiteSpace=wrap;html=1;fontSize=10;rounded=0;" parent="WIyWlLk6GJQsqaUBKTNV-1" vertex="1">
|
||||
<mxGeometry x="30" y="617.5" width="80" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-47" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="FXGPDhTRSO2FZSW48CnP-26" target="FXGPDhTRSO2FZSW48CnP-46" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-26" value="Alice et Bob sont d'accord sur la clé symmétrique a utiliser" style="rounded=0;whiteSpace=wrap;html=1;" parent="WIyWlLk6GJQsqaUBKTNV-1" vertex="1">
|
||||
<mxGeometry x="340" y="820" width="120" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-31" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;endArrow=block;endFill=0;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="FXGPDhTRSO2FZSW48CnP-27" target="FXGPDhTRSO2FZSW48CnP-30" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-45" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;strokeColor=light-dark(#000000,#FF1616);endArrow=block;endFill=0;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="FXGPDhTRSO2FZSW48CnP-27" target="FXGPDhTRSO2FZSW48CnP-26" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="210" y="850" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-27" value="Alice et Bob calculent le secret partagé de leur côté" style="whiteSpace=wrap;html=1;fontSize=10;rounded=1;" parent="WIyWlLk6GJQsqaUBKTNV-1" vertex="1">
|
||||
<mxGeometry x="150" y="720" width="120" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-33" value="" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=1;entryDx=0;entryDy=0;jumpStyle=sharp;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="FXGPDhTRSO2FZSW48CnP-29" target="FXGPDhTRSO2FZSW48CnP-34" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<mxPoint x="640" y="680" as="targetPoint" />
|
||||
<Array as="points">
|
||||
<mxPoint x="700" y="745" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-36" value="Non" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="FXGPDhTRSO2FZSW48CnP-33" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.1536" y="1" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-41" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="FXGPDhTRSO2FZSW48CnP-29" target="FXGPDhTRSO2FZSW48CnP-26" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="525" y="798" />
|
||||
<mxPoint x="510" y="798" />
|
||||
<mxPoint x="510" y="850" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-42" value="Oui" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="FXGPDhTRSO2FZSW48CnP-41" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.7774" y="1" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-29" value="Ils sont d'accord ?" style="rhombus;whiteSpace=wrap;html=1;" parent="WIyWlLk6GJQsqaUBKTNV-1" vertex="1">
|
||||
<mxGeometry x="460" y="715" width="130" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-32" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="FXGPDhTRSO2FZSW48CnP-30" target="FXGPDhTRSO2FZSW48CnP-29" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-30" value="Alice et Bob lisent à haute voix la phrase de sécurité" style="whiteSpace=wrap;html=1;fontSize=10;rounded=1;" parent="WIyWlLk6GJQsqaUBKTNV-1" vertex="1">
|
||||
<mxGeometry x="300" y="720" width="120" height="50" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-37" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=1;entryY=0.5;entryDx=0;entryDy=0;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="FXGPDhTRSO2FZSW48CnP-34" target="WIyWlLk6GJQsqaUBKTNV-12" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-38" value="Oui" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="FXGPDhTRSO2FZSW48CnP-37" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.3086" y="-2" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-39" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=1;entryY=0.5;entryDx=0;entryDy=0;jumpStyle=sharp;jumpSize=8;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="FXGPDhTRSO2FZSW48CnP-34" target="WIyWlLk6GJQsqaUBKTNV-11" edge="1">
|
||||
<mxGeometry relative="1" as="geometry">
|
||||
<Array as="points">
|
||||
<mxPoint x="530" y="690" />
|
||||
<mxPoint x="530" y="545" />
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-40" value="Non" style="edgeLabel;html=1;align=center;verticalAlign=middle;resizable=0;points=[];" parent="FXGPDhTRSO2FZSW48CnP-39" vertex="1" connectable="0">
|
||||
<mxGeometry x="-0.7617" relative="1" as="geometry">
|
||||
<mxPoint as="offset" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-34" value="Ré-essayer ?" style="rhombus;whiteSpace=wrap;html=1;" parent="WIyWlLk6GJQsqaUBKTNV-1" vertex="1">
|
||||
<mxGeometry x="660" y="650" width="80" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-49" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;entryX=0.5;entryY=0;entryDx=0;entryDy=0;" parent="WIyWlLk6GJQsqaUBKTNV-1" source="FXGPDhTRSO2FZSW48CnP-46" target="FXGPDhTRSO2FZSW48CnP-48" edge="1">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-46" value="Alice et Bob utilisent la clé symmétrique pour chiffrer leurs transmissions" style="whiteSpace=wrap;html=1;rounded=0;" parent="WIyWlLk6GJQsqaUBKTNV-1" vertex="1">
|
||||
<mxGeometry x="340" y="920" width="120" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="FXGPDhTRSO2FZSW48CnP-48" value="" style="rhombus;whiteSpace=wrap;html=1;" parent="WIyWlLk6GJQsqaUBKTNV-1" vertex="1">
|
||||
<mxGeometry x="360" y="1020" width="80" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
<diagram id="4Sb7mgJDpsadGym-U4wz" name="Echanges">
|
||||
<mxGraphModel dx="1195" dy="683" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
<mxCell id="b_xV4iUWIxmdZCAYY4YR-1" value="" style="html=1;shadow=0;dashed=0;align=center;verticalAlign=middle;shape=mxgraph.arrows2.arrow;dy=0;dx=10;notch=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="160" y="120" width="440" height="120" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="O_eM33N56VtHnDaMz1H4-1" value="ALICE" style="shape=umlLifeline;perimeter=lifelinePerimeter;whiteSpace=wrap;html=1;container=1;dropTarget=0;collapsible=0;recursiveResize=0;outlineConnect=0;portConstraint=eastwest;newEdgeStyle={"curved":0,"rounded":0};participant=umlEntity;strokeWidth=2;" parent="1" vertex="1">
|
||||
<mxGeometry x="120" y="40" width="40" height="3110" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="O_eM33N56VtHnDaMz1H4-2" value="BOB" style="shape=umlLifeline;perimeter=lifelinePerimeter;whiteSpace=wrap;html=1;container=1;dropTarget=0;collapsible=0;recursiveResize=0;outlineConnect=0;portConstraint=eastwest;newEdgeStyle={"curved":0,"rounded":0};participant=umlEntity;strokeWidth=2;" parent="1" vertex="1">
|
||||
<mxGeometry x="690" y="40" width="40" height="3110" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="b_xV4iUWIxmdZCAYY4YR-2" value="PING" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontSize=23;" parent="1" vertex="1">
|
||||
<mxGeometry x="385" y="65" width="80" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="n3lF8vaYaHAhAfaeaFZn-1" value="Nonce" style="swimlane;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="180" y="130" width="105" height="80" as="geometry">
|
||||
<mxRectangle x="210" y="130" width="80" height="30" as="alternateBounds" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="n3lF8vaYaHAhAfaeaFZn-6" value="<div>sha256 (</div><div>numéro alice +</div><div>numéro bob +</div><div>timestamp +</div><div>random<br></div><div>) / ~2 (left part)<br></div>" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=7;" parent="n3lF8vaYaHAhAfaeaFZn-1" vertex="1">
|
||||
<mxGeometry y="25" width="100" height="55" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="n3lF8vaYaHAhAfaeaFZn-2" value="Version" style="swimlane;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="305" y="130" width="58.75" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-1" value="(0-128)" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="n3lF8vaYaHAhAfaeaFZn-2">
|
||||
<mxGeometry x="3.75" y="30" width="51.25" height="25" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="n3lF8vaYaHAhAfaeaFZn-4" value="Checksum" style="swimlane;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="455" y="130" width="90" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="n3lF8vaYaHAhAfaeaFZn-7" value="CRC-32" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="n3lF8vaYaHAhAfaeaFZn-4" vertex="1">
|
||||
<mxGeometry x="15" y="25" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="XvZTtdEB18xY6m2a5fJO-1" value="" style="html=1;shadow=0;dashed=0;align=center;verticalAlign=middle;shape=mxgraph.arrows2.arrow;dy=0;dx=10;notch=0;rotation=-180;" parent="1" vertex="1">
|
||||
<mxGeometry x="280" y="280" width="410" height="190" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="XvZTtdEB18xY6m2a5fJO-2" value="Timestamp" style="swimlane;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="300" y="290" width="90" height="60" as="geometry">
|
||||
<mxRectangle x="210" y="130" width="80" height="30" as="alternateBounds" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="XvZTtdEB18xY6m2a5fJO-3" value="timestamp" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;strokeWidth=1;" parent="XvZTtdEB18xY6m2a5fJO-2" vertex="1">
|
||||
<mxGeometry x="11.25" y="27.5" width="67.5" height="25" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="XvZTtdEB18xY6m2a5fJO-4" value="Version" style="swimlane;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="405.63" y="290" width="58.75" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="XvZTtdEB18xY6m2a5fJO-11" value="0" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="XvZTtdEB18xY6m2a5fJO-4" vertex="1">
|
||||
<mxGeometry y="25" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="XvZTtdEB18xY6m2a5fJO-5" value="Checksum" style="swimlane;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="590" y="380" width="85" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="XvZTtdEB18xY6m2a5fJO-6" value="CRC-32" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="XvZTtdEB18xY6m2a5fJO-5" vertex="1">
|
||||
<mxGeometry x="12.5" y="25" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="XvZTtdEB18xY6m2a5fJO-9" value="Answer" style="swimlane;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="482.5" y="290" width="57.5" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="XvZTtdEB18xY6m2a5fJO-10" value="<div>YES</div><div>NO<br></div>" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="484.38" y="315" width="53.75" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="XvZTtdEB18xY6m2a5fJO-13" value="HANDSHAKE" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontSize=23;" parent="1" vertex="1">
|
||||
<mxGeometry x="350" y="510" width="170" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="XvZTtdEB18xY6m2a5fJO-14" value="" style="html=1;shadow=0;dashed=0;align=center;verticalAlign=middle;shape=mxgraph.arrows2.arrow;dy=0;dx=10;notch=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="160" y="570" width="410" height="220" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="XvZTtdEB18xY6m2a5fJO-15" value="Clé éphémère" style="swimlane;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="170" y="580" width="105" height="80" as="geometry">
|
||||
<mxRectangle x="210" y="130" width="80" height="30" as="alternateBounds" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-13" value="Clé (publique) générée aléatoirement" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;" parent="XvZTtdEB18xY6m2a5fJO-15" vertex="1">
|
||||
<mxGeometry y="30" width="100" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="XvZTtdEB18xY6m2a5fJO-17" value="Signature" style="swimlane;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="285" y="580" width="105" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-14" value="PubkeyFixe. sign(clé éphémère)" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="XvZTtdEB18xY6m2a5fJO-17" vertex="1">
|
||||
<mxGeometry y="20" width="100" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="XvZTtdEB18xY6m2a5fJO-18" value="Checksum" style="swimlane;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="486.25" y="580" width="65" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="XvZTtdEB18xY6m2a5fJO-19" value="CRC-32" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="XvZTtdEB18xY6m2a5fJO-18" vertex="1">
|
||||
<mxGeometry x="2.5" y="30" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-15" value="PFS" style="swimlane;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="402.81" y="580" width="71.88" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-16" value="hash( preuve de convo précédente)" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;" parent="pP7SjZfcCiBg3d1TCkzP-15" vertex="1">
|
||||
<mxGeometry x="6.57" y="30" width="60" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-17" value="" style="html=1;shadow=0;dashed=0;align=center;verticalAlign=middle;shape=mxgraph.arrows2.arrow;dy=0;dx=10;notch=0;rotation=-180;" parent="1" vertex="1">
|
||||
<mxGeometry x="285" y="830" width="410" height="180" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-43" value="Clé éphémère" style="swimlane;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="305" y="840" width="105" height="80" as="geometry">
|
||||
<mxRectangle x="210" y="130" width="80" height="30" as="alternateBounds" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-44" value="Clé (publique) générée aléatoirement" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=11;" parent="pP7SjZfcCiBg3d1TCkzP-43" vertex="1">
|
||||
<mxGeometry y="30" width="100" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-45" value="Signature" style="swimlane;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="420" y="840" width="105" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-46" value="PubkeyFixe. sign(clé éphémère)" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="pP7SjZfcCiBg3d1TCkzP-45" vertex="1">
|
||||
<mxGeometry y="20" width="100" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-47" value="Checksum" style="swimlane;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="621.25" y="840" width="65" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-48" value="CRC-32" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="pP7SjZfcCiBg3d1TCkzP-47" vertex="1">
|
||||
<mxGeometry x="2.5" y="30" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-49" value="PFS" style="swimlane;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="537.81" y="840" width="71.88" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-50" value="hash( preuve de convo précédente )" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;" parent="pP7SjZfcCiBg3d1TCkzP-49" vertex="1">
|
||||
<mxGeometry x="6.57" y="30" width="60" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-54" value="Timestamp" style="swimlane;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="182.5" y="690" width="80" height="70" as="geometry">
|
||||
<mxRectangle x="210" y="130" width="80" height="30" as="alternateBounds" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-55" value="timestamp" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;strokeWidth=1;" parent="pP7SjZfcCiBg3d1TCkzP-54" vertex="1">
|
||||
<mxGeometry x="6.25" y="32.5" width="67.5" height="25" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-56" value="Timestamp" style="swimlane;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="606.25" y="930" width="80" height="70" as="geometry">
|
||||
<mxRectangle x="210" y="130" width="80" height="30" as="alternateBounds" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-57" value="timestamp" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=10;strokeWidth=1;" parent="pP7SjZfcCiBg3d1TCkzP-56" vertex="1">
|
||||
<mxGeometry x="6.25" y="32.5" width="67.5" height="25" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-58" value="" style="html=1;shadow=0;dashed=0;align=center;verticalAlign=middle;shape=mxgraph.arrows2.arrow;dy=0;dx=10;notch=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="160" y="1160" width="450" height="200" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-59" value="ENCRYPTED COMS" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontSize=23;" parent="1" vertex="1">
|
||||
<mxGeometry x="305" y="1100" width="240" height="40" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-60" value="129b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="200" y="210" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-61" value="7b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="303.75" y="210" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-62" value="32b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="470" y="210" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-63" value="= 172b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="530" y="210" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-66" value="32b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="313" y="350" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-67" value="7b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="406.75" y="350" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-68" value="1b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="479.25" y="350" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-69" value="32b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="600.5" y="440" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-70" value="= 76b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="426.25" y="420" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-71" value="264b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="193" y="660" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-72" value="512b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="307.5" y="660" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-73" value="256b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="409.38" y="660" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-74" value="32b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="488.75" y="660" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-75" value="32b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="192.5" y="760" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-76" value="=1096b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="315" y="750" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pP7SjZfcCiBg3d1TCkzP-77" value="=1096b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="327.5" y="970" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="_H5URFloX_BVB2BL7kO6-1" value="CRC ?" style="swimlane;whiteSpace=wrap;html=1;fillColor=#008a00;fontColor=#ffffff;strokeColor=#005700;" parent="1" vertex="1">
|
||||
<mxGeometry x="375" y="1270" width="63.25" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="_H5URFloX_BVB2BL7kO6-2" value="CRC-32" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="_H5URFloX_BVB2BL7kO6-1" vertex="1">
|
||||
<mxGeometry x="1.6199999999999992" y="25" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="_H5URFloX_BVB2BL7kO6-3" value="Flag" style="swimlane;whiteSpace=wrap;html=1;fillColor=#008a00;fontColor=#ffffff;strokeColor=#005700;" parent="1" vertex="1">
|
||||
<mxGeometry x="180" y="1170" width="65" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="_H5URFloX_BVB2BL7kO6-4" value="To determine" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="_H5URFloX_BVB2BL7kO6-3" vertex="1">
|
||||
<mxGeometry x="2.5" y="25" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="_H5URFloX_BVB2BL7kO6-5" value="nbretry" style="swimlane;whiteSpace=wrap;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="344.38" y="1170" width="65" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="_H5URFloX_BVB2BL7kO6-6" value="y" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="_H5URFloX_BVB2BL7kO6-5" vertex="1">
|
||||
<mxGeometry x="2.5" y="25" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="_H5URFloX_BVB2BL7kO6-7" value="msg_len" style="swimlane;whiteSpace=wrap;html=1;fillColor=#008a00;fontColor=#ffffff;strokeColor=#005700;" parent="1" vertex="1">
|
||||
<mxGeometry x="262.5" y="1170" width="65" height="60" as="geometry">
|
||||
<mxRectangle x="262.5" y="1170" width="90" height="30" as="alternateBounds" />
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="_H5URFloX_BVB2BL7kO6-8" value="XXX" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="_H5URFloX_BVB2BL7kO6-7" vertex="1">
|
||||
<mxGeometry x="2.5" y="25" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="_H5URFloX_BVB2BL7kO6-9" value="msg" style="swimlane;whiteSpace=wrap;html=1;fillColor=#0050ef;fontColor=#ffffff;strokeColor=#001DBC;" parent="1" vertex="1">
|
||||
<mxGeometry x="187.5" y="1270" width="65" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="_H5URFloX_BVB2BL7kO6-10" value="BBB" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="_H5URFloX_BVB2BL7kO6-9" vertex="1">
|
||||
<mxGeometry x="2.5" y="30" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="_H5URFloX_BVB2BL7kO6-11" value="16b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="180" y="1230" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="_H5URFloX_BVB2BL7kO6-12" value="8b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="349.38" y="1230" width="55" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="_H5URFloX_BVB2BL7kO6-13" value="16b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="267.5" y="1230" width="55" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="_H5URFloX_BVB2BL7kO6-14" value="96b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="510" y="1230" width="55" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="_H5URFloX_BVB2BL7kO6-15" value="32b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="379.12" y="1330" width="55" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="_H5URFloX_BVB2BL7kO6-16" value="= (180b ~ 212b) + yyy" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" parent="1" vertex="1">
|
||||
<mxGeometry x="465" y="1285" width="130" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-2" value="Cypher" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="375" y="130" width="58.75" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-3" value="(0-16)" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="pWkGvNQAXuiST1IiWYlx-2">
|
||||
<mxGeometry x="3.75" y="30" width="51.25" height="25" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-4" value="4b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
|
||||
<mxGeometry x="375" y="210" width="55" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-5" value="Cypher" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="600" y="290" width="58.75" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-6" value="(0-16)" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="pWkGvNQAXuiST1IiWYlx-5">
|
||||
<mxGeometry x="3.75" y="30" width="51.25" height="25" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-7" value="4b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
|
||||
<mxGeometry x="601.88" y="350" width="55" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-8" value="status" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="425" y="1170" width="65" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-9" value="CRC ?" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="pWkGvNQAXuiST1IiWYlx-8">
|
||||
<mxGeometry x="2.5" y="25" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-10" value="4b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
|
||||
<mxGeometry x="428.75" y="1230" width="55" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-11" value="iv" style="swimlane;whiteSpace=wrap;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="505" y="1170" width="65" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-12" value="random<div>(+Z)</div>" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="pWkGvNQAXuiST1IiWYlx-11">
|
||||
<mxGeometry x="2.5" y="25" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-13" value="BBB b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
|
||||
<mxGeometry x="193" y="1330" width="55" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-14" value="MAC" style="swimlane;whiteSpace=wrap;html=1;fillColor=#008a00;fontColor=#ffffff;strokeColor=#005700;" vertex="1" parent="1">
|
||||
<mxGeometry x="286.13" y="1270" width="63.25" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-15" value="AEAD" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="pWkGvNQAXuiST1IiWYlx-14">
|
||||
<mxGeometry x="1.6199999999999992" y="25" width="60" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-16" value="128b" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;" vertex="1" parent="1">
|
||||
<mxGeometry x="290.25" y="1330" width="55" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-17" value="Green = clear data" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fillColor=#008a00;fontColor=#ffffff;strokeColor=#005700;" vertex="1" parent="1">
|
||||
<mxGeometry x="10" y="1170" width="110" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-18" value="<font style="color: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));">White = additional data</font>" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fillColor=none;strokeColor=light-dark(#6C8EBF,#FFFFFF);" vertex="1" parent="1">
|
||||
<mxGeometry y="1220" width="130" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
<mxCell id="pWkGvNQAXuiST1IiWYlx-19" value="Blue = encrypted data" style="text;html=1;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fillColor=#0050ef;fontColor=#ffffff;strokeColor=#001DBC;" vertex="1" parent="1">
|
||||
<mxGeometry x="10" y="1270" width="110" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
@ -0,0 +1,119 @@
|
||||
# Voice-over-GSM Protocol Implementation
|
||||
|
||||
This implementation provides encrypted voice communication over standard GSM voice channels without requiring CSD/HSCSD.
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Voice Codec (`voice_codec.py`)
|
||||
- **Codec2Wrapper**: Simulates Codec2 compression
|
||||
- Supports multiple bitrates (700-3200 bps)
|
||||
- Default: 1200 bps for GSM robustness
|
||||
- 40ms frames (48 bits/frame at 1200 bps)
|
||||
|
||||
- **FSKModem**: 4-FSK modulation for voice channels
|
||||
- Frequency band: 300-3400 Hz (GSM compatible)
|
||||
- Symbol rate: 600 baud
|
||||
- 4 frequencies: 600, 1200, 1800, 2400 Hz
|
||||
- Preamble: 800 Hz for 100ms
|
||||
|
||||
- **VoiceProtocol**: Integration layer
|
||||
- Manages codec and modem
|
||||
- Handles encryption with ChaCha20-CTR
|
||||
- Frame-based processing
|
||||
|
||||
### 2. Protocol Messages (`messages.py`)
|
||||
- **VoiceStart** (20 bytes): Initiates voice call
|
||||
- Version, codec mode, FEC type
|
||||
- Session ID (64 bits)
|
||||
- Initial sequence number
|
||||
|
||||
- **VoiceAck** (16 bytes): Accepts/rejects call
|
||||
- Status (accept/reject)
|
||||
- Negotiated codec and FEC
|
||||
|
||||
- **VoiceEnd** (12 bytes): Terminates call
|
||||
- Session ID for confirmation
|
||||
|
||||
- **VoiceSync** (20 bytes): Synchronization
|
||||
- Sequence number and timestamp
|
||||
- For jitter buffer management
|
||||
|
||||
### 3. Encryption (`encryption.py`)
|
||||
- **ChaCha20-CTR**: Stream cipher for voice
|
||||
- No authentication overhead (HMAC per second)
|
||||
- 12-byte nonce with frame counter
|
||||
- Uses HKDF-derived key from main protocol
|
||||
|
||||
### 4. Protocol Integration (`protocol.py`)
|
||||
- Voice session management
|
||||
- Message handlers for all voice messages
|
||||
- Methods:
|
||||
- `start_voice_call()`: Initiate call
|
||||
- `accept_voice_call()`: Accept incoming
|
||||
- `end_voice_call()`: Terminate
|
||||
- `send_voice_audio()`: Process audio
|
||||
|
||||
## Usage Example
|
||||
|
||||
```python
|
||||
# After key exchange is complete
|
||||
alice.start_voice_call(codec_mode=5, fec_type=0)
|
||||
|
||||
# Bob automatically accepts if in auto mode
|
||||
# Or manually: bob.accept_voice_call(session_id, codec_mode, fec_type)
|
||||
|
||||
# Send audio
|
||||
audio_samples = generate_audio() # 8kHz, 16-bit PCM
|
||||
alice.send_voice_audio(audio_samples)
|
||||
|
||||
# End call
|
||||
alice.end_voice_call()
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
1. **Codec2 @ 1200 bps**
|
||||
- Optimal for GSM vocoder survival
|
||||
- Intelligible but "robotic" quality
|
||||
|
||||
2. **4-FSK Modulation**
|
||||
- Survives GSM/AMR/EVS vocoders
|
||||
- 2400 baud with FEC
|
||||
|
||||
3. **ChaCha20-CTR Encryption**
|
||||
- Low latency stream cipher
|
||||
- Frame-based IV management
|
||||
|
||||
4. **Forward Error Correction**
|
||||
- Repetition code (3x)
|
||||
- Future: Convolutional or LDPC
|
||||
|
||||
5. **No Special Requirements**
|
||||
- Works over standard voice calls
|
||||
- Compatible with any phone
|
||||
- Software-only solution
|
||||
|
||||
## Testing
|
||||
|
||||
Run the test scripts:
|
||||
- `test_voice_simple.py`: Basic voice call setup
|
||||
- `test_voice_protocol.py`: Full test with audio simulation (requires numpy)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
1. Message disambiguation: VoiceStart sets high bit in flags field to distinguish from VoiceSync (both 20 bytes)
|
||||
|
||||
2. The actual Codec2 library would need to be integrated for production use
|
||||
|
||||
3. FEC implementation is simplified (repetition code) - production would use convolutional codes
|
||||
|
||||
4. Audio I/O integration needed for real voice calls
|
||||
|
||||
5. Jitter buffer and timing recovery needed for production
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Voice frames use ChaCha20-CTR without per-frame authentication
|
||||
- HMAC computed over 1-second blocks for efficiency
|
||||
- Session binding through encrypted session ID
|
||||
- PFS maintained through main protocol key rotation
|
430
protocol_prototype/Prototype/Protocol_Alpha_0/auto_mode.py
Normal file
430
protocol_prototype/Prototype/Protocol_Alpha_0/auto_mode.py
Normal file
@ -0,0 +1,430 @@
|
||||
import time
|
||||
import threading
|
||||
import queue
|
||||
from typing import Optional, Dict, Any, List, Callable, Tuple
|
||||
|
||||
# ANSI colors for logging
|
||||
RED = "\033[91m"
|
||||
GREEN = "\033[92m"
|
||||
YELLOW = "\033[93m"
|
||||
BLUE = "\033[94m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
class AutoModeConfig:
|
||||
"""Configuration parameters for the automatic mode behavior."""
|
||||
def __init__(self):
|
||||
# Ping behavior
|
||||
self.ping_response_accept = True # Whether to accept incoming pings
|
||||
self.ping_auto_initiate = False # Whether to initiate pings when connected
|
||||
self.ping_retry_count = 3 # Number of ping retries
|
||||
self.ping_retry_delay = 5.0 # Seconds between ping retries
|
||||
self.ping_timeout = 10.0 # Seconds to wait for ping response
|
||||
self.preferred_cipher = 0 # 0=AES-GCM, 1=ChaCha20-Poly1305
|
||||
|
||||
# Handshake behavior
|
||||
self.handshake_retry_count = 3 # Number of handshake retries
|
||||
self.handshake_retry_delay = 5.0 # Seconds between handshake retries
|
||||
self.handshake_timeout = 10.0 # Seconds to wait for handshake
|
||||
|
||||
# Messaging behavior
|
||||
self.auto_message_enabled = False # Whether to auto-send messages
|
||||
self.message_interval = 10.0 # Seconds between auto messages
|
||||
self.message_content = "Hello, secure world!" # Default message
|
||||
|
||||
# General behavior
|
||||
self.active_mode = False # If true, initiates protocol instead of waiting
|
||||
|
||||
|
||||
class AutoMode:
|
||||
"""
|
||||
Manages automated behavior for the Icing protocol.
|
||||
Handles automatic progression through the protocol stages:
|
||||
1. Connection setup
|
||||
2. Ping/discovery
|
||||
3. Key exchange
|
||||
4. Encrypted communication
|
||||
"""
|
||||
|
||||
def __init__(self, protocol_interface):
|
||||
"""
|
||||
Initialize the AutoMode manager.
|
||||
|
||||
Args:
|
||||
protocol_interface: An object implementing the required protocol methods
|
||||
"""
|
||||
self.protocol = protocol_interface
|
||||
self.config = AutoModeConfig()
|
||||
self.active = False
|
||||
self.state = "idle"
|
||||
|
||||
# Message queue for automated sending
|
||||
self.message_queue = queue.Queue()
|
||||
|
||||
# Tracking variables
|
||||
self.ping_attempts = 0
|
||||
self.handshake_attempts = 0
|
||||
self.last_action_time = 0
|
||||
self.timer_tasks = [] # List of active timer tasks (for cleanup)
|
||||
|
||||
def start(self):
|
||||
"""Start the automatic mode."""
|
||||
if self.active:
|
||||
return
|
||||
|
||||
self.active = True
|
||||
self.state = "idle"
|
||||
self.ping_attempts = 0
|
||||
self.handshake_attempts = 0
|
||||
self.last_action_time = time.time()
|
||||
|
||||
self._log_info("Automatic mode started")
|
||||
|
||||
# Start in active mode if configured
|
||||
if self.config.active_mode and self.protocol.connections:
|
||||
self._start_ping_sequence()
|
||||
|
||||
def stop(self):
|
||||
"""Stop the automatic mode and clean up any pending tasks."""
|
||||
if not self.active:
|
||||
return
|
||||
|
||||
# Cancel any pending timers
|
||||
for timer in self.timer_tasks:
|
||||
if timer.is_alive():
|
||||
timer.cancel()
|
||||
self.timer_tasks = []
|
||||
|
||||
self.active = False
|
||||
self.state = "idle"
|
||||
self._log_info("Automatic mode stopped")
|
||||
|
||||
def handle_connection_established(self):
|
||||
"""Called when a new connection is established."""
|
||||
if not self.active:
|
||||
return
|
||||
|
||||
self._log_info("Connection established")
|
||||
|
||||
# If in active mode, start pinging
|
||||
if self.config.active_mode:
|
||||
self._start_ping_sequence()
|
||||
|
||||
def handle_ping_received(self, index: int):
|
||||
"""
|
||||
Handle a received ping request.
|
||||
|
||||
Args:
|
||||
index: Index of the ping request in the protocol's inbound message queue
|
||||
"""
|
||||
if not self.active or not self._is_valid_message_index(index):
|
||||
return
|
||||
|
||||
self._log_info(f"Ping request received (index={index})")
|
||||
|
||||
# Automatically respond to ping if configured to accept
|
||||
if self.config.ping_response_accept:
|
||||
self._log_info(f"Auto-responding to ping with accept={self.config.ping_response_accept}")
|
||||
try:
|
||||
# Schedule the response with a small delay to simulate real behavior
|
||||
timer = threading.Timer(0.5, self._respond_to_ping, args=[index])
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
self.timer_tasks.append(timer)
|
||||
except Exception as e:
|
||||
self._log_error(f"Failed to auto-respond to ping: {e}")
|
||||
|
||||
def handle_ping_response_received(self, accepted: bool):
|
||||
"""
|
||||
Handle a received ping response.
|
||||
|
||||
Args:
|
||||
accepted: Whether the ping was accepted
|
||||
"""
|
||||
if not self.active:
|
||||
return
|
||||
|
||||
self.ping_attempts = 0 # Reset ping attempts counter
|
||||
|
||||
if accepted:
|
||||
self._log_info("Ping accepted! Proceeding with handshake")
|
||||
# Send handshake if not already done
|
||||
if self.state != "handshake_sent":
|
||||
self._ensure_ephemeral_keys()
|
||||
self._start_handshake_sequence()
|
||||
else:
|
||||
self._log_info("Ping rejected by peer. Stopping auto-protocol sequence.")
|
||||
self.state = "idle"
|
||||
|
||||
def handle_handshake_received(self, index: int):
|
||||
"""
|
||||
Handle a received handshake.
|
||||
|
||||
Args:
|
||||
index: Index of the handshake in the protocol's inbound message queue
|
||||
"""
|
||||
if not self.active or not self._is_valid_message_index(index):
|
||||
return
|
||||
|
||||
self._log_info(f"Handshake received (index={index})")
|
||||
|
||||
try:
|
||||
# Ensure we have ephemeral keys
|
||||
self._ensure_ephemeral_keys()
|
||||
|
||||
# Process the handshake (compute ECDH)
|
||||
self.protocol.generate_ecdhe(index)
|
||||
|
||||
# Derive HKDF key
|
||||
self.protocol.derive_hkdf()
|
||||
|
||||
# If we haven't sent our handshake yet, send it
|
||||
if self.state != "handshake_sent":
|
||||
timer = threading.Timer(0.5, self.protocol.send_handshake)
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
self.timer_tasks.append(timer)
|
||||
self.state = "handshake_sent"
|
||||
else:
|
||||
self.state = "key_exchange_complete"
|
||||
|
||||
# Start sending queued messages if auto messaging is enabled
|
||||
if self.config.auto_message_enabled:
|
||||
self._start_message_sequence()
|
||||
|
||||
except Exception as e:
|
||||
self._log_error(f"Failed to process handshake: {e}")
|
||||
|
||||
def handle_encrypted_received(self, index: int):
|
||||
"""
|
||||
Handle a received encrypted message.
|
||||
|
||||
Args:
|
||||
index: Index of the encrypted message in the protocol's inbound message queue
|
||||
"""
|
||||
if not self.active or not self._is_valid_message_index(index):
|
||||
return
|
||||
|
||||
# Try to decrypt automatically
|
||||
try:
|
||||
plaintext = self.protocol.decrypt_received_message(index)
|
||||
self._log_info(f"Auto-decrypted message: {plaintext}")
|
||||
except Exception as e:
|
||||
self._log_error(f"Failed to auto-decrypt message: {e}")
|
||||
|
||||
def queue_message(self, message: str):
|
||||
"""
|
||||
Add a message to the auto-send queue.
|
||||
|
||||
Args:
|
||||
message: Message text to send
|
||||
"""
|
||||
self.message_queue.put(message)
|
||||
self._log_info(f"Message queued for sending: {message}")
|
||||
|
||||
# If we're in the right state, start sending messages
|
||||
if self.active and self.state == "key_exchange_complete" and self.config.auto_message_enabled:
|
||||
self._process_message_queue()
|
||||
|
||||
def _start_ping_sequence(self):
|
||||
"""Start the ping sequence to discover the peer."""
|
||||
if self.ping_attempts >= self.config.ping_retry_count:
|
||||
self._log_warning(f"Maximum ping attempts ({self.config.ping_retry_count}) reached")
|
||||
self.state = "idle"
|
||||
return
|
||||
|
||||
self.state = "pinging"
|
||||
self.ping_attempts += 1
|
||||
|
||||
self._log_info(f"Sending ping request (attempt {self.ping_attempts}/{self.config.ping_retry_count})")
|
||||
try:
|
||||
self.protocol.send_ping_request(self.config.preferred_cipher)
|
||||
self.last_action_time = time.time()
|
||||
|
||||
# Schedule next ping attempt if needed
|
||||
timer = threading.Timer(
|
||||
self.config.ping_retry_delay,
|
||||
self._check_ping_response
|
||||
)
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
self.timer_tasks.append(timer)
|
||||
|
||||
except Exception as e:
|
||||
self._log_error(f"Failed to send ping: {e}")
|
||||
|
||||
def _check_ping_response(self):
|
||||
"""Check if we got a ping response, retry if not."""
|
||||
if not self.active or self.state != "pinging":
|
||||
return
|
||||
|
||||
# If we've waited long enough for a response, retry
|
||||
if time.time() - self.last_action_time >= self.config.ping_timeout:
|
||||
self._log_warning("No ping response received, retrying")
|
||||
self._start_ping_sequence()
|
||||
|
||||
def _respond_to_ping(self, index: int):
|
||||
"""
|
||||
Respond to a ping request.
|
||||
|
||||
Args:
|
||||
index: Index of the ping request in the inbound messages
|
||||
"""
|
||||
if not self.active or not self._is_valid_message_index(index):
|
||||
return
|
||||
|
||||
try:
|
||||
answer = 1 if self.config.ping_response_accept else 0
|
||||
self.protocol.respond_to_ping(index, answer)
|
||||
|
||||
if answer == 1:
|
||||
# If we accepted, we should expect a handshake
|
||||
self.state = "accepted_ping"
|
||||
self._ensure_ephemeral_keys()
|
||||
|
||||
# Set a timer to send our handshake if we don't receive one
|
||||
timer = threading.Timer(
|
||||
self.config.handshake_timeout,
|
||||
self._check_handshake_received
|
||||
)
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
self.timer_tasks.append(timer)
|
||||
self.last_action_time = time.time()
|
||||
|
||||
except Exception as e:
|
||||
self._log_error(f"Failed to respond to ping: {e}")
|
||||
|
||||
def _check_handshake_received(self):
|
||||
"""Check if we've received a handshake after accepting a ping."""
|
||||
if not self.active or self.state != "accepted_ping":
|
||||
return
|
||||
|
||||
# If we've waited long enough and haven't received a handshake, initiate one
|
||||
if time.time() - self.last_action_time >= self.config.handshake_timeout:
|
||||
self._log_warning("No handshake received after accepting ping, initiating handshake")
|
||||
self._start_handshake_sequence()
|
||||
|
||||
def _start_handshake_sequence(self):
|
||||
"""Start the handshake sequence."""
|
||||
if self.handshake_attempts >= self.config.handshake_retry_count:
|
||||
self._log_warning(f"Maximum handshake attempts ({self.config.handshake_retry_count}) reached")
|
||||
self.state = "idle"
|
||||
return
|
||||
|
||||
self.state = "handshake_sent"
|
||||
self.handshake_attempts += 1
|
||||
|
||||
self._log_info(f"Sending handshake (attempt {self.handshake_attempts}/{self.config.handshake_retry_count})")
|
||||
try:
|
||||
self.protocol.send_handshake()
|
||||
self.last_action_time = time.time()
|
||||
|
||||
# Schedule handshake retry check
|
||||
timer = threading.Timer(
|
||||
self.config.handshake_retry_delay,
|
||||
self._check_handshake_response
|
||||
)
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
self.timer_tasks.append(timer)
|
||||
|
||||
except Exception as e:
|
||||
self._log_error(f"Failed to send handshake: {e}")
|
||||
|
||||
def _check_handshake_response(self):
|
||||
"""Check if we've completed the key exchange, retry handshake if not."""
|
||||
if not self.active or self.state != "handshake_sent":
|
||||
return
|
||||
|
||||
# If we've waited long enough for a response, retry
|
||||
if time.time() - self.last_action_time >= self.config.handshake_timeout:
|
||||
self._log_warning("No handshake response received, retrying")
|
||||
self._start_handshake_sequence()
|
||||
|
||||
def _start_message_sequence(self):
|
||||
"""Start the automated message sending sequence."""
|
||||
if not self.config.auto_message_enabled:
|
||||
return
|
||||
|
||||
self._log_info("Starting automated message sequence")
|
||||
|
||||
# Add the default message if queue is empty
|
||||
if self.message_queue.empty():
|
||||
self.message_queue.put(self.config.message_content)
|
||||
|
||||
# Start processing the queue
|
||||
self._process_message_queue()
|
||||
|
||||
def _process_message_queue(self):
|
||||
"""Process messages in the queue and send them."""
|
||||
if not self.active or self.state != "key_exchange_complete" or not self.config.auto_message_enabled:
|
||||
return
|
||||
|
||||
if not self.message_queue.empty():
|
||||
message = self.message_queue.get()
|
||||
self._log_info(f"Sending queued message: {message}")
|
||||
|
||||
try:
|
||||
self.protocol.send_encrypted_message(message)
|
||||
|
||||
# Schedule next message send
|
||||
timer = threading.Timer(
|
||||
self.config.message_interval,
|
||||
self._process_message_queue
|
||||
)
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
self.timer_tasks.append(timer)
|
||||
|
||||
except Exception as e:
|
||||
self._log_error(f"Failed to send queued message: {e}")
|
||||
# Put the message back in the queue
|
||||
self.message_queue.put(message)
|
||||
|
||||
def _ensure_ephemeral_keys(self):
|
||||
"""Ensure ephemeral keys are generated if needed."""
|
||||
if not hasattr(self.protocol, 'ephemeral_pubkey') or self.protocol.ephemeral_pubkey is None:
|
||||
self._log_info("Generating ephemeral keys")
|
||||
self.protocol.generate_ephemeral_keys()
|
||||
|
||||
def _is_valid_message_index(self, index: int) -> bool:
|
||||
"""
|
||||
Check if a message index is valid in the protocol's inbound_messages queue.
|
||||
|
||||
Args:
|
||||
index: The index to check
|
||||
|
||||
Returns:
|
||||
bool: True if the index is valid, False otherwise
|
||||
"""
|
||||
if not hasattr(self.protocol, 'inbound_messages'):
|
||||
self._log_error("Protocol has no inbound_messages attribute")
|
||||
return False
|
||||
|
||||
if index < 0 or index >= len(self.protocol.inbound_messages):
|
||||
self._log_error(f"Invalid message index: {index}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
# Helper methods for logging
|
||||
def _log_info(self, message: str):
|
||||
print(f"{BLUE}[AUTO]{RESET} {message}")
|
||||
if hasattr(self, 'verbose_logging') and self.verbose_logging:
|
||||
state_info = f"(state={self.state})"
|
||||
if 'pinging' in self.state and hasattr(self, 'ping_attempts'):
|
||||
state_info += f", attempts={self.ping_attempts}/{self.config.ping_retry_count}"
|
||||
elif 'handshake' in self.state and hasattr(self, 'handshake_attempts'):
|
||||
state_info += f", attempts={self.handshake_attempts}/{self.config.handshake_retry_count}"
|
||||
print(f"{BLUE}[AUTO-DETAIL]{RESET} {state_info}")
|
||||
|
||||
def _log_warning(self, message: str):
|
||||
print(f"{YELLOW}[AUTO-WARN]{RESET} {message}")
|
||||
if hasattr(self, 'verbose_logging') and self.verbose_logging:
|
||||
timer_info = f"Active timers: {len(self.timer_tasks)}"
|
||||
print(f"{YELLOW}[AUTO-WARN-DETAIL]{RESET} {timer_info}")
|
||||
|
||||
def _log_error(self, message: str):
|
||||
print(f"{RED}[AUTO-ERROR]{RESET} {message}")
|
||||
if hasattr(self, 'verbose_logging') and self.verbose_logging:
|
||||
print(f"{RED}[AUTO-ERROR-DETAIL]{RESET} Current state: {self.state}, Active: {self.active}")
|
328
protocol_prototype/Prototype/Protocol_Alpha_0/cli.py
Normal file
328
protocol_prototype/Prototype/Protocol_Alpha_0/cli.py
Normal file
@ -0,0 +1,328 @@
|
||||
import sys
|
||||
import argparse
|
||||
import shlex
|
||||
from protocol import IcingProtocol
|
||||
|
||||
RED = "\033[91m"
|
||||
GREEN = "\033[92m"
|
||||
YELLOW = "\033[93m"
|
||||
BLUE = "\033[94m"
|
||||
MAGENTA = "\033[95m"
|
||||
CYAN = "\033[96m"
|
||||
RESET = "\033[0m"
|
||||
|
||||
def print_help():
|
||||
"""Display all available commands."""
|
||||
print(f"\n{YELLOW}=== Available Commands ==={RESET}")
|
||||
print(f"\n{CYAN}Basic Protocol Commands:{RESET}")
|
||||
print(" help - Show this help message")
|
||||
print(" peer_id <hex_pubkey> - Set peer identity public key")
|
||||
print(" connect <port> - Connect to a peer at the specified port")
|
||||
print(" show_state - Display current protocol state")
|
||||
print(" exit - Exit the program")
|
||||
|
||||
print(f"\n{CYAN}Manual Protocol Operation:{RESET}")
|
||||
print(" generate_ephemeral_keys - Generate ephemeral ECDH keys")
|
||||
print(" send_ping [cipher] - Send PING request (cipher: 0=AES-GCM, 1=ChaCha20-Poly1305, default: 0)")
|
||||
print(" respond_ping <index> <0|1> - Respond to a PING (0=reject, 1=accept)")
|
||||
print(" send_handshake - Send handshake with ephemeral keys")
|
||||
print(" generate_ecdhe <index> - Process handshake at specified index")
|
||||
print(" derive_hkdf - Derive encryption key using HKDF")
|
||||
print(" send_encrypted <plaintext> - Encrypt and send a message")
|
||||
print(" decrypt <index> - Decrypt received message at index")
|
||||
|
||||
print(f"\n{CYAN}Automatic Mode Commands:{RESET}")
|
||||
print(" auto start - Start automatic mode")
|
||||
print(" auto stop - Stop automatic mode")
|
||||
print(" auto status - Show current auto mode status and configuration")
|
||||
print(" auto config <param> <value> - Configure auto mode parameters")
|
||||
print(" auto config list - Show all configurable parameters")
|
||||
print(" auto message <text> - Queue message for automatic sending")
|
||||
print(" auto passive - Configure as passive peer (responds to pings but doesn't initiate)")
|
||||
print(" auto active - Configure as active peer (initiates protocol)")
|
||||
print(" auto log - Toggle detailed logging for auto mode")
|
||||
|
||||
print(f"\n{CYAN}Debugging Commands:{RESET}")
|
||||
print(" debug_message <index> - Display detailed information about a message in the queue")
|
||||
|
||||
print(f"\n{CYAN}Legacy Commands:{RESET}")
|
||||
print(" auto_responder <on|off> - Enable/disable legacy auto responder (deprecated)")
|
||||
|
||||
|
||||
def main():
|
||||
protocol = IcingProtocol()
|
||||
|
||||
print(f"{YELLOW}\n======================================")
|
||||
print(" Icing Protocol - Secure Communication ")
|
||||
print("======================================\n" + RESET)
|
||||
print(f"Listening on port: {protocol.local_port}")
|
||||
print(f"Your identity public key (hex): {protocol.identity_pubkey.hex()}")
|
||||
print_help()
|
||||
|
||||
while True:
|
||||
try:
|
||||
line = input(f"{MAGENTA}Cmd>{RESET} ").strip()
|
||||
except EOFError:
|
||||
break
|
||||
if not line:
|
||||
continue
|
||||
|
||||
parts = shlex.split(line) # Handle quoted arguments properly
|
||||
cmd = parts[0].lower()
|
||||
|
||||
try:
|
||||
# Basic commands
|
||||
if cmd == "exit":
|
||||
protocol.stop()
|
||||
break
|
||||
|
||||
elif cmd == "help":
|
||||
print_help()
|
||||
|
||||
elif cmd == "show_state":
|
||||
protocol.show_state()
|
||||
|
||||
elif cmd == "peer_id":
|
||||
if len(parts) != 2:
|
||||
print(f"{RED}[ERROR]{RESET} Usage: peer_id <hex_pubkey>")
|
||||
continue
|
||||
try:
|
||||
protocol.set_peer_identity(parts[1])
|
||||
except ValueError as e:
|
||||
print(f"{RED}[ERROR]{RESET} Invalid public key: {e}")
|
||||
|
||||
elif cmd == "connect":
|
||||
if len(parts) != 2:
|
||||
print(f"{RED}[ERROR]{RESET} Usage: connect <port>")
|
||||
continue
|
||||
try:
|
||||
port = int(parts[1])
|
||||
protocol.connect_to_peer(port)
|
||||
except ValueError:
|
||||
print(f"{RED}[ERROR]{RESET} Invalid port number.")
|
||||
except Exception as e:
|
||||
print(f"{RED}[ERROR]{RESET} Connection failed: {e}")
|
||||
|
||||
# Manual protocol operation
|
||||
elif cmd == "generate_ephemeral_keys":
|
||||
protocol.generate_ephemeral_keys()
|
||||
|
||||
elif cmd == "send_ping":
|
||||
# Optional cipher parameter (0 = AES-GCM, 1 = ChaCha20-Poly1305)
|
||||
cipher = 0 # Default to AES-GCM
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
cipher = int(parts[1])
|
||||
if cipher not in (0, 1):
|
||||
print(f"{YELLOW}[WARNING]{RESET} Unsupported cipher code {cipher}. Using AES-GCM (0).")
|
||||
cipher = 0
|
||||
except ValueError:
|
||||
print(f"{YELLOW}[WARNING]{RESET} Invalid cipher code. Using AES-GCM (0).")
|
||||
protocol.send_ping_request(cipher)
|
||||
|
||||
elif cmd == "send_handshake":
|
||||
protocol.send_handshake()
|
||||
|
||||
elif cmd == "respond_ping":
|
||||
if len(parts) != 3:
|
||||
print(f"{RED}[ERROR]{RESET} Usage: respond_ping <index> <0|1>")
|
||||
continue
|
||||
try:
|
||||
idx = int(parts[1])
|
||||
answer = int(parts[2])
|
||||
if answer not in (0, 1):
|
||||
print(f"{RED}[ERROR]{RESET} Answer must be 0 (reject) or 1 (accept).")
|
||||
continue
|
||||
protocol.respond_to_ping(idx, answer)
|
||||
except ValueError:
|
||||
print(f"{RED}[ERROR]{RESET} Index and answer must be integers.")
|
||||
except Exception as e:
|
||||
print(f"{RED}[ERROR]{RESET} Failed to respond to ping: {e}")
|
||||
|
||||
elif cmd == "generate_ecdhe":
|
||||
if len(parts) != 2:
|
||||
print(f"{RED}[ERROR]{RESET} Usage: generate_ecdhe <index>")
|
||||
continue
|
||||
try:
|
||||
idx = int(parts[1])
|
||||
protocol.generate_ecdhe(idx)
|
||||
except ValueError:
|
||||
print(f"{RED}[ERROR]{RESET} Index must be an integer.")
|
||||
except Exception as e:
|
||||
print(f"{RED}[ERROR]{RESET} Failed to process handshake: {e}")
|
||||
|
||||
elif cmd == "derive_hkdf":
|
||||
try:
|
||||
protocol.derive_hkdf()
|
||||
except Exception as e:
|
||||
print(f"{RED}[ERROR]{RESET} Failed to derive HKDF key: {e}")
|
||||
|
||||
elif cmd == "send_encrypted":
|
||||
if len(parts) < 2:
|
||||
print(f"{RED}[ERROR]{RESET} Usage: send_encrypted <plaintext>")
|
||||
continue
|
||||
plaintext = " ".join(parts[1:])
|
||||
try:
|
||||
protocol.send_encrypted_message(plaintext)
|
||||
except Exception as e:
|
||||
print(f"{RED}[ERROR]{RESET} Failed to send encrypted message: {e}")
|
||||
|
||||
elif cmd == "decrypt":
|
||||
if len(parts) != 2:
|
||||
print(f"{RED}[ERROR]{RESET} Usage: decrypt <index>")
|
||||
continue
|
||||
try:
|
||||
idx = int(parts[1])
|
||||
protocol.decrypt_received_message(idx)
|
||||
except ValueError:
|
||||
print(f"{RED}[ERROR]{RESET} Index must be an integer.")
|
||||
except Exception as e:
|
||||
print(f"{RED}[ERROR]{RESET} Failed to decrypt message: {e}")
|
||||
|
||||
# Debugging commands
|
||||
elif cmd == "debug_message":
|
||||
if len(parts) != 2:
|
||||
print(f"{RED}[ERROR]{RESET} Usage: debug_message <index>")
|
||||
continue
|
||||
try:
|
||||
idx = int(parts[1])
|
||||
protocol.debug_message(idx)
|
||||
except ValueError:
|
||||
print(f"{RED}[ERROR]{RESET} Index must be an integer.")
|
||||
except Exception as e:
|
||||
print(f"{RED}[ERROR]{RESET} Failed to debug message: {e}")
|
||||
|
||||
# Automatic mode commands
|
||||
elif cmd == "auto":
|
||||
if len(parts) < 2:
|
||||
print(f"{RED}[ERROR]{RESET} Usage: auto <command> [options]")
|
||||
print("Available commands: start, stop, status, config, message, passive, active")
|
||||
continue
|
||||
|
||||
subcmd = parts[1].lower()
|
||||
|
||||
if subcmd == "start":
|
||||
protocol.start_auto_mode()
|
||||
print(f"{GREEN}[AUTO]{RESET} Automatic mode started")
|
||||
|
||||
elif subcmd == "stop":
|
||||
protocol.stop_auto_mode()
|
||||
print(f"{GREEN}[AUTO]{RESET} Automatic mode stopped")
|
||||
|
||||
elif subcmd == "status":
|
||||
config = protocol.get_auto_mode_config()
|
||||
print(f"{YELLOW}=== Auto Mode Status ==={RESET}")
|
||||
print(f"Active: {protocol.auto_mode.active}")
|
||||
print(f"State: {protocol.auto_mode.state}")
|
||||
print(f"\n{YELLOW}--- Configuration ---{RESET}")
|
||||
for key, value in vars(config).items():
|
||||
print(f" {key}: {value}")
|
||||
|
||||
elif subcmd == "config":
|
||||
if len(parts) < 3:
|
||||
print(f"{RED}[ERROR]{RESET} Usage: auto config <param> <value> or auto config list")
|
||||
continue
|
||||
|
||||
if parts[2].lower() == "list":
|
||||
config = protocol.get_auto_mode_config()
|
||||
print(f"{YELLOW}=== Auto Mode Configuration Parameters ==={RESET}")
|
||||
for key, value in vars(config).items():
|
||||
print(f" {key} ({type(value).__name__}): {value}")
|
||||
continue
|
||||
|
||||
if len(parts) != 4:
|
||||
print(f"{RED}[ERROR]{RESET} Usage: auto config <param> <value>")
|
||||
continue
|
||||
|
||||
param = parts[2]
|
||||
value_str = parts[3]
|
||||
|
||||
# Convert the string value to the appropriate type
|
||||
config = protocol.get_auto_mode_config()
|
||||
if not hasattr(config, param):
|
||||
print(f"{RED}[ERROR]{RESET} Unknown parameter: {param}")
|
||||
print("Use 'auto config list' to see all available parameters")
|
||||
continue
|
||||
|
||||
current_value = getattr(config, param)
|
||||
try:
|
||||
if isinstance(current_value, bool):
|
||||
if value_str.lower() in ("true", "yes", "on", "1"):
|
||||
value = True
|
||||
elif value_str.lower() in ("false", "no", "off", "0"):
|
||||
value = False
|
||||
else:
|
||||
raise ValueError(f"Boolean value must be true/false/yes/no/on/off/1/0")
|
||||
elif isinstance(current_value, int):
|
||||
value = int(value_str)
|
||||
elif isinstance(current_value, float):
|
||||
value = float(value_str)
|
||||
elif isinstance(current_value, str):
|
||||
value = value_str
|
||||
else:
|
||||
value = value_str # Default to string
|
||||
|
||||
protocol.configure_auto_mode(**{param: value})
|
||||
print(f"{GREEN}[AUTO]{RESET} Set {param} = {value}")
|
||||
|
||||
except ValueError as e:
|
||||
print(f"{RED}[ERROR]{RESET} Invalid value for {param}: {e}")
|
||||
|
||||
elif subcmd == "message":
|
||||
if len(parts) < 3:
|
||||
print(f"{RED}[ERROR]{RESET} Usage: auto message <text>")
|
||||
continue
|
||||
|
||||
message = " ".join(parts[2:])
|
||||
protocol.queue_auto_message(message)
|
||||
print(f"{GREEN}[AUTO]{RESET} Message queued for sending: {message}")
|
||||
|
||||
elif subcmd == "passive":
|
||||
# Configure as passive peer (responds but doesn't initiate)
|
||||
protocol.configure_auto_mode(
|
||||
ping_response_accept=True,
|
||||
ping_auto_initiate=False,
|
||||
active_mode=False
|
||||
)
|
||||
print(f"{GREEN}[AUTO]{RESET} Configured as passive peer")
|
||||
|
||||
elif subcmd == "active":
|
||||
# Configure as active peer (initiates protocol)
|
||||
protocol.configure_auto_mode(
|
||||
ping_response_accept=True,
|
||||
ping_auto_initiate=True,
|
||||
active_mode=True
|
||||
)
|
||||
print(f"{GREEN}[AUTO]{RESET} Configured as active peer")
|
||||
|
||||
else:
|
||||
print(f"{RED}[ERROR]{RESET} Unknown auto mode command: {subcmd}")
|
||||
print("Available commands: start, stop, status, config, message, passive, active")
|
||||
|
||||
# Legacy commands
|
||||
elif cmd == "auto_responder":
|
||||
if len(parts) != 2:
|
||||
print(f"{RED}[ERROR]{RESET} Usage: auto_responder <on|off>")
|
||||
continue
|
||||
val = parts[1].lower()
|
||||
if val not in ("on", "off"):
|
||||
print(f"{RED}[ERROR]{RESET} Value must be 'on' or 'off'.")
|
||||
continue
|
||||
protocol.enable_auto_responder(val == "on")
|
||||
print(f"{YELLOW}[WARNING]{RESET} Using legacy auto responder. Consider using 'auto' commands instead.")
|
||||
|
||||
else:
|
||||
print(f"{RED}[ERROR]{RESET} Unknown command: {cmd}")
|
||||
print("Type 'help' for a list of available commands.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"{RED}[ERROR]{RESET} Command failed: {e}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print("\nExiting...")
|
||||
except Exception as e:
|
||||
print(f"{RED}[FATAL ERROR]{RESET} {e}")
|
||||
sys.exit(1)
|
165
protocol_prototype/Prototype/Protocol_Alpha_0/crypto_utils.py
Normal file
165
protocol_prototype/Prototype/Protocol_Alpha_0/crypto_utils.py
Normal file
@ -0,0 +1,165 @@
|
||||
import os
|
||||
from typing import Tuple
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec, utils
|
||||
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature, encode_dss_signature
|
||||
|
||||
def generate_identity_keys() -> Tuple[ec.EllipticCurvePrivateKey, bytes]:
|
||||
"""
|
||||
Generate an ECDSA (P-256) identity key pair.
|
||||
|
||||
Returns:
|
||||
Tuple containing:
|
||||
- private_key: EllipticCurvePrivateKey object
|
||||
- public_key_bytes: Raw x||y format (64 bytes, 512 bits)
|
||||
"""
|
||||
private_key = ec.generate_private_key(ec.SECP256R1())
|
||||
public_numbers = private_key.public_key().public_numbers()
|
||||
|
||||
x_bytes = public_numbers.x.to_bytes(32, byteorder='big')
|
||||
y_bytes = public_numbers.y.to_bytes(32, byteorder='big')
|
||||
pubkey_bytes = x_bytes + y_bytes # 64 bytes total
|
||||
|
||||
return private_key, pubkey_bytes
|
||||
|
||||
|
||||
def load_peer_identity_key(pubkey_bytes: bytes) -> ec.EllipticCurvePublicKey:
|
||||
"""
|
||||
Convert a raw public key (64 bytes, x||y format) to a cryptography public key object.
|
||||
|
||||
Args:
|
||||
pubkey_bytes: Raw 64-byte public key (x||y format)
|
||||
|
||||
Returns:
|
||||
EllipticCurvePublicKey object
|
||||
|
||||
Raises:
|
||||
ValueError: If the pubkey_bytes is not exactly 64 bytes
|
||||
"""
|
||||
if len(pubkey_bytes) != 64:
|
||||
raise ValueError("Peer identity pubkey must be exactly 64 bytes (x||y).")
|
||||
|
||||
x_int = int.from_bytes(pubkey_bytes[:32], byteorder='big')
|
||||
y_int = int.from_bytes(pubkey_bytes[32:], byteorder='big')
|
||||
|
||||
public_numbers = ec.EllipticCurvePublicNumbers(x_int, y_int, ec.SECP256R1())
|
||||
return public_numbers.public_key()
|
||||
|
||||
|
||||
def sign_data(private_key: ec.EllipticCurvePrivateKey, data: bytes) -> bytes:
|
||||
"""
|
||||
Sign data with ECDSA using a P-256 private key.
|
||||
|
||||
Args:
|
||||
private_key: EllipticCurvePrivateKey for signing
|
||||
data: Bytes to sign
|
||||
|
||||
Returns:
|
||||
DER-encoded signature (variable length, up to ~70-72 bytes)
|
||||
"""
|
||||
signature = private_key.sign(data, ec.ECDSA(hashes.SHA256()))
|
||||
return signature
|
||||
|
||||
|
||||
def verify_signature(public_key: ec.EllipticCurvePublicKey, signature: bytes, data: bytes) -> bool:
|
||||
"""
|
||||
Verify a DER-encoded ECDSA signature.
|
||||
|
||||
Args:
|
||||
public_key: EllipticCurvePublicKey for verification
|
||||
signature: DER-encoded signature
|
||||
data: Original signed data
|
||||
|
||||
Returns:
|
||||
True if signature is valid, False otherwise
|
||||
"""
|
||||
try:
|
||||
public_key.verify(signature, data, ec.ECDSA(hashes.SHA256()))
|
||||
return True
|
||||
except InvalidSignature:
|
||||
return False
|
||||
|
||||
|
||||
def get_ephemeral_keypair() -> Tuple[ec.EllipticCurvePrivateKey, bytes]:
|
||||
"""
|
||||
Generate an ephemeral ECDH key pair (P-256).
|
||||
|
||||
Returns:
|
||||
Tuple containing:
|
||||
- private_key: EllipticCurvePrivateKey object
|
||||
- pubkey_bytes: Raw x||y format (64 bytes, 512 bits)
|
||||
"""
|
||||
private_key = ec.generate_private_key(ec.SECP256R1())
|
||||
numbers = private_key.public_key().public_numbers()
|
||||
|
||||
x_bytes = numbers.x.to_bytes(32, 'big')
|
||||
y_bytes = numbers.y.to_bytes(32, 'big')
|
||||
|
||||
return private_key, x_bytes + y_bytes # 64 bytes total
|
||||
|
||||
|
||||
def compute_ecdh_shared_key(private_key: ec.EllipticCurvePrivateKey, peer_pubkey_bytes: bytes) -> bytes:
|
||||
"""
|
||||
Compute a shared secret using ECDH.
|
||||
|
||||
Args:
|
||||
private_key: Local ECDH private key
|
||||
peer_pubkey_bytes: Peer's ephemeral public key (64 bytes, raw x||y format)
|
||||
|
||||
Returns:
|
||||
Shared secret bytes
|
||||
|
||||
Raises:
|
||||
ValueError: If peer_pubkey_bytes is not 64 bytes
|
||||
"""
|
||||
if len(peer_pubkey_bytes) != 64:
|
||||
raise ValueError("Peer public key must be 64 bytes (x||y format)")
|
||||
|
||||
x_int = int.from_bytes(peer_pubkey_bytes[:32], 'big')
|
||||
y_int = int.from_bytes(peer_pubkey_bytes[32:], 'big')
|
||||
|
||||
# Create public key object from raw components
|
||||
peer_public_numbers = ec.EllipticCurvePublicNumbers(x_int, y_int, ec.SECP256R1())
|
||||
peer_public_key = peer_public_numbers.public_key()
|
||||
|
||||
# Perform key exchange
|
||||
shared_key = private_key.exchange(ec.ECDH(), peer_public_key)
|
||||
return shared_key
|
||||
|
||||
|
||||
def der_to_raw(der_sig: bytes) -> bytes:
|
||||
"""
|
||||
Convert a DER-encoded ECDSA signature to a raw 64-byte signature (r||s).
|
||||
|
||||
Args:
|
||||
der_sig: DER-encoded signature
|
||||
|
||||
Returns:
|
||||
Raw 64-byte signature (r||s format), with each component padded to 32 bytes
|
||||
"""
|
||||
r, s = decode_dss_signature(der_sig)
|
||||
r_bytes = r.to_bytes(32, byteorder='big')
|
||||
s_bytes = s.to_bytes(32, byteorder='big')
|
||||
return r_bytes + s_bytes
|
||||
|
||||
|
||||
def raw_signature_to_der(raw_sig: bytes) -> bytes:
|
||||
"""
|
||||
Convert a raw signature (64 bytes, concatenated r||s) to DER-encoded signature.
|
||||
|
||||
Args:
|
||||
raw_sig: Raw 64-byte signature (r||s format)
|
||||
|
||||
Returns:
|
||||
DER-encoded signature
|
||||
|
||||
Raises:
|
||||
ValueError: If raw_sig is not 64 bytes
|
||||
"""
|
||||
if len(raw_sig) != 64:
|
||||
raise ValueError("Raw signature must be 64 bytes (r||s).")
|
||||
|
||||
r = int.from_bytes(raw_sig[:32], 'big')
|
||||
s = int.from_bytes(raw_sig[32:], 'big')
|
||||
return encode_dss_signature(r, s)
|
307
protocol_prototype/Prototype/Protocol_Alpha_0/encryption.py
Normal file
307
protocol_prototype/Prototype/Protocol_Alpha_0/encryption.py
Normal file
@ -0,0 +1,307 @@
|
||||
import os
|
||||
import struct
|
||||
from typing import Optional, Tuple
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM, ChaCha20Poly1305
|
||||
|
||||
class MessageHeader:
|
||||
"""
|
||||
Header of an encrypted message (18 bytes total):
|
||||
|
||||
Clear Text Section (4 bytes):
|
||||
- flag: 16 bits (0xBEEF by default)
|
||||
- data_len: 16 bits (length of encrypted payload excluding tag)
|
||||
|
||||
Associated Data (14 bytes):
|
||||
- retry: 8 bits (retry counter)
|
||||
- connection_status: 4 bits (e.g., CRC required) + 4 bits padding
|
||||
- iv/messageID: 96 bits (12 bytes)
|
||||
"""
|
||||
def __init__(self, flag: int, data_len: int, retry: int, connection_status: int, iv: bytes):
|
||||
if not (0 <= flag < 65536):
|
||||
raise ValueError("Flag must fit in 16 bits (0..65535)")
|
||||
if not (0 <= data_len < 65536):
|
||||
raise ValueError("Data length must fit in 16 bits (0..65535)")
|
||||
if not (0 <= retry < 256):
|
||||
raise ValueError("Retry must fit in 8 bits (0..255)")
|
||||
if not (0 <= connection_status < 16):
|
||||
raise ValueError("Connection status must fit in 4 bits (0..15)")
|
||||
if len(iv) != 12:
|
||||
raise ValueError("IV must be 12 bytes (96 bits)")
|
||||
|
||||
self.flag = flag # 16 bits
|
||||
self.data_len = data_len # 16 bits
|
||||
self.retry = retry # 8 bits
|
||||
self.connection_status = connection_status # 4 bits
|
||||
self.iv = iv # 96 bits (12 bytes)
|
||||
|
||||
def pack(self) -> bytes:
|
||||
"""Pack header into 18 bytes."""
|
||||
# Pack flag and data_len (4 bytes)
|
||||
header = struct.pack('>H H', self.flag, self.data_len)
|
||||
|
||||
# Pack retry and connection_status (2 bytes)
|
||||
# connection_status in high 4 bits of second byte, 4 bits padding as zero
|
||||
ad_byte = (self.connection_status & 0x0F) << 4
|
||||
ad_packed = struct.pack('>B B', self.retry, ad_byte)
|
||||
|
||||
# Append IV (12 bytes)
|
||||
return header + ad_packed + self.iv
|
||||
|
||||
def get_associated_data(self) -> bytes:
|
||||
"""Get the associated data for AEAD encryption (retry, conn_status, iv)."""
|
||||
# Pack retry and connection_status
|
||||
ad_byte = (self.connection_status & 0x0F) << 4
|
||||
ad_packed = struct.pack('>B B', self.retry, ad_byte)
|
||||
|
||||
# Append IV
|
||||
return ad_packed + self.iv
|
||||
|
||||
@classmethod
|
||||
def unpack(cls, data: bytes) -> 'MessageHeader':
|
||||
"""Unpack 18 bytes into a MessageHeader object."""
|
||||
if len(data) < 18:
|
||||
raise ValueError(f"Header data too short: {len(data)} bytes, expected 18")
|
||||
|
||||
flag, data_len = struct.unpack('>H H', data[:4])
|
||||
retry, ad_byte = struct.unpack('>B B', data[4:6])
|
||||
connection_status = (ad_byte >> 4) & 0x0F
|
||||
iv = data[6:18]
|
||||
|
||||
return cls(flag, data_len, retry, connection_status, iv)
|
||||
|
||||
class EncryptedMessage:
|
||||
"""
|
||||
Encrypted message packet format:
|
||||
|
||||
- Header (18 bytes):
|
||||
* flag: 16 bits
|
||||
* data_len: 16 bits
|
||||
* retry: 8 bits
|
||||
* connection_status: 4 bits (+ 4 bits padding)
|
||||
* iv/messageID: 96 bits (12 bytes)
|
||||
|
||||
- Payload: variable length encrypted data
|
||||
|
||||
- Footer:
|
||||
* Authentication tag: 128 bits (16 bytes)
|
||||
* CRC32: 32 bits (4 bytes) - optional, based on connection_status
|
||||
"""
|
||||
def __init__(self, plaintext: bytes, key: bytes, flag: int = 0xBEEF,
|
||||
retry: int = 0, connection_status: int = 0, iv: bytes = None,
|
||||
cipher_type: int = 0):
|
||||
self.plaintext = plaintext
|
||||
self.key = key
|
||||
self.flag = flag
|
||||
self.retry = retry
|
||||
self.connection_status = connection_status
|
||||
self.iv = iv or generate_iv(initial=True)
|
||||
self.cipher_type = cipher_type # 0 = AES-256-GCM, 1 = ChaCha20-Poly1305
|
||||
|
||||
# Will be set after encryption
|
||||
self.ciphertext = None
|
||||
self.tag = None
|
||||
self.header = None
|
||||
|
||||
def encrypt(self) -> bytes:
|
||||
"""Encrypt the plaintext and return the full encrypted message."""
|
||||
# Create header with correct data_len (which will be set after encryption)
|
||||
self.header = MessageHeader(
|
||||
flag=self.flag,
|
||||
data_len=0, # Will be updated after encryption
|
||||
retry=self.retry,
|
||||
connection_status=self.connection_status,
|
||||
iv=self.iv
|
||||
)
|
||||
|
||||
# Get associated data for AEAD
|
||||
aad = self.header.get_associated_data()
|
||||
|
||||
# Encrypt using the appropriate cipher
|
||||
if self.cipher_type == 0: # AES-256-GCM
|
||||
cipher = AESGCM(self.key)
|
||||
ciphertext_with_tag = cipher.encrypt(self.iv, self.plaintext, aad)
|
||||
elif self.cipher_type == 1: # ChaCha20-Poly1305
|
||||
cipher = ChaCha20Poly1305(self.key)
|
||||
ciphertext_with_tag = cipher.encrypt(self.iv, self.plaintext, aad)
|
||||
else:
|
||||
raise ValueError(f"Unsupported cipher type: {self.cipher_type}")
|
||||
|
||||
# Extract ciphertext and tag
|
||||
self.tag = ciphertext_with_tag[-16:]
|
||||
self.ciphertext = ciphertext_with_tag[:-16]
|
||||
|
||||
# Update header with actual data length
|
||||
self.header.data_len = len(self.ciphertext)
|
||||
|
||||
# Pack everything together
|
||||
packed_header = self.header.pack()
|
||||
|
||||
# Check if CRC is required (based on connection_status)
|
||||
if self.connection_status & 0x01: # Lowest bit indicates CRC required
|
||||
import zlib
|
||||
# Compute CRC32 of header + ciphertext + tag
|
||||
crc = zlib.crc32(packed_header + self.ciphertext + self.tag) & 0xffffffff
|
||||
crc_bytes = struct.pack('>I', crc)
|
||||
return packed_header + self.ciphertext + self.tag + crc_bytes
|
||||
else:
|
||||
return packed_header + self.ciphertext + self.tag
|
||||
|
||||
@classmethod
|
||||
def decrypt(cls, data: bytes, key: bytes, cipher_type: int = 0) -> Tuple[bytes, MessageHeader]:
|
||||
"""
|
||||
Decrypt an encrypted message and return the plaintext and header.
|
||||
|
||||
Args:
|
||||
data: The full encrypted message
|
||||
key: The encryption key
|
||||
cipher_type: 0 for AES-256-GCM, 1 for ChaCha20-Poly1305
|
||||
|
||||
Returns:
|
||||
Tuple of (plaintext, header)
|
||||
"""
|
||||
if len(data) < 18 + 16: # Header + minimum tag size
|
||||
raise ValueError("Message too short")
|
||||
|
||||
# Extract header
|
||||
header_bytes = data[:18]
|
||||
header = MessageHeader.unpack(header_bytes)
|
||||
|
||||
# Get ciphertext and tag
|
||||
data_len = header.data_len
|
||||
ciphertext_start = 18
|
||||
ciphertext_end = ciphertext_start + data_len
|
||||
|
||||
if ciphertext_end + 16 > len(data):
|
||||
raise ValueError("Message length does not match header's data_len")
|
||||
|
||||
ciphertext = data[ciphertext_start:ciphertext_end]
|
||||
tag = data[ciphertext_end:ciphertext_end + 16]
|
||||
|
||||
# Get associated data for AEAD
|
||||
aad = header.get_associated_data()
|
||||
|
||||
# Combine ciphertext and tag for decryption
|
||||
ciphertext_with_tag = ciphertext + tag
|
||||
|
||||
# Decrypt using the appropriate cipher
|
||||
try:
|
||||
if cipher_type == 0: # AES-256-GCM
|
||||
cipher = AESGCM(key)
|
||||
plaintext = cipher.decrypt(header.iv, ciphertext_with_tag, aad)
|
||||
elif cipher_type == 1: # ChaCha20-Poly1305
|
||||
cipher = ChaCha20Poly1305(key)
|
||||
plaintext = cipher.decrypt(header.iv, ciphertext_with_tag, aad)
|
||||
else:
|
||||
raise ValueError(f"Unsupported cipher type: {cipher_type}")
|
||||
|
||||
return plaintext, header
|
||||
except Exception as e:
|
||||
raise ValueError(f"Decryption failed: {e}")
|
||||
|
||||
def generate_iv(initial: bool = False, previous_iv: bytes = None) -> bytes:
|
||||
"""
|
||||
Generate a 96-bit IV (12 bytes).
|
||||
|
||||
Args:
|
||||
initial: If True, return a random IV
|
||||
previous_iv: The previous IV to increment
|
||||
|
||||
Returns:
|
||||
A new IV
|
||||
"""
|
||||
if initial or previous_iv is None:
|
||||
return os.urandom(12) # 96 bits
|
||||
else:
|
||||
# Increment the previous IV by 1 modulo 2^96
|
||||
iv_int = int.from_bytes(previous_iv, 'big')
|
||||
iv_int = (iv_int + 1) % (1 << 96)
|
||||
return iv_int.to_bytes(12, 'big')
|
||||
|
||||
# Convenience functions to match original API
|
||||
def encrypt_message(plaintext: bytes, key: bytes, flag: int = 0xBEEF,
|
||||
retry: int = 0, connection_status: int = 0,
|
||||
iv: bytes = None, cipher_type: int = 0) -> bytes:
|
||||
"""
|
||||
Encrypt a message using the specified parameters.
|
||||
|
||||
Args:
|
||||
plaintext: The data to encrypt
|
||||
key: The encryption key (32 bytes for AES-256-GCM, 32 bytes for ChaCha20-Poly1305)
|
||||
flag: 16-bit flag value (default: 0xBEEF)
|
||||
retry: 8-bit retry counter
|
||||
connection_status: 4-bit connection status
|
||||
iv: Optional 96-bit IV (if None, a random one will be generated)
|
||||
cipher_type: 0 for AES-256-GCM, 1 for ChaCha20-Poly1305
|
||||
|
||||
Returns:
|
||||
The full encrypted message
|
||||
"""
|
||||
message = EncryptedMessage(
|
||||
plaintext=plaintext,
|
||||
key=key,
|
||||
flag=flag,
|
||||
retry=retry,
|
||||
connection_status=connection_status,
|
||||
iv=iv,
|
||||
cipher_type=cipher_type
|
||||
)
|
||||
return message.encrypt()
|
||||
|
||||
def decrypt_message(message: bytes, key: bytes, cipher_type: int = 0) -> bytes:
|
||||
"""
|
||||
Decrypt a message.
|
||||
|
||||
Args:
|
||||
message: The full encrypted message
|
||||
key: The encryption key
|
||||
cipher_type: 0 for AES-256-GCM, 1 for ChaCha20-Poly1305
|
||||
|
||||
Returns:
|
||||
The decrypted plaintext
|
||||
"""
|
||||
plaintext, _ = EncryptedMessage.decrypt(message, key, cipher_type)
|
||||
return plaintext
|
||||
|
||||
# ChaCha20-CTR functions for voice streaming (without authentication)
|
||||
def chacha20_encrypt(plaintext: bytes, key: bytes, nonce: bytes) -> bytes:
|
||||
"""
|
||||
Encrypt plaintext using ChaCha20 in CTR mode (no authentication).
|
||||
|
||||
Args:
|
||||
plaintext: Data to encrypt
|
||||
key: 32-byte key
|
||||
nonce: 16-byte nonce (for ChaCha20 in cryptography library)
|
||||
|
||||
Returns:
|
||||
Ciphertext
|
||||
"""
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from cryptography.hazmat.backends import default_backend
|
||||
|
||||
if len(key) != 32:
|
||||
raise ValueError("ChaCha20 key must be 32 bytes")
|
||||
if len(nonce) != 16:
|
||||
raise ValueError("ChaCha20 nonce must be 16 bytes")
|
||||
|
||||
cipher = Cipher(
|
||||
algorithms.ChaCha20(key, nonce),
|
||||
mode=None,
|
||||
backend=default_backend()
|
||||
)
|
||||
encryptor = cipher.encryptor()
|
||||
return encryptor.update(plaintext) + encryptor.finalize()
|
||||
|
||||
def chacha20_decrypt(ciphertext: bytes, key: bytes, nonce: bytes) -> bytes:
|
||||
"""
|
||||
Decrypt ciphertext using ChaCha20 in CTR mode (no authentication).
|
||||
|
||||
Args:
|
||||
ciphertext: Data to decrypt
|
||||
key: 32-byte key
|
||||
nonce: 12-byte nonce
|
||||
|
||||
Returns:
|
||||
Plaintext
|
||||
"""
|
||||
# ChaCha20 is symmetrical - encryption and decryption are the same
|
||||
return chacha20_encrypt(ciphertext, key, nonce)
|
463
protocol_prototype/Prototype/Protocol_Alpha_0/messages.py
Normal file
463
protocol_prototype/Prototype/Protocol_Alpha_0/messages.py
Normal file
@ -0,0 +1,463 @@
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
import zlib
|
||||
import hashlib
|
||||
from typing import Tuple, Optional
|
||||
|
||||
def crc32_of(data: bytes) -> int:
|
||||
"""
|
||||
Compute CRC-32 of 'data'.
|
||||
"""
|
||||
return zlib.crc32(data) & 0xffffffff
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PING REQUEST (new format)
|
||||
# Fields (in order):
|
||||
# - session_nonce: 129 bits (from the top 129 bits of 17 random bytes)
|
||||
# - version: 7 bits
|
||||
# - cipher: 4 bits (0 = AES-256-GCM, 1 = ChaCha20-poly1305; for now only 0 is used)
|
||||
# - CRC: 32 bits
|
||||
#
|
||||
# Total bits: 129 + 7 + 4 + 32 = 172 bits. We pack into 22 bytes (176 bits) with 4 spare bits.
|
||||
# ---------------------------------------------------------------------------
|
||||
class PingRequest:
|
||||
"""
|
||||
PING REQUEST format (172 bits / 22 bytes):
|
||||
- session_nonce: 129 bits (from top 129 bits of 17 random bytes)
|
||||
- version: 7 bits
|
||||
- cipher: 4 bits (0 = AES-256-GCM, 1 = ChaCha20-poly1305)
|
||||
- CRC: 32 bits
|
||||
"""
|
||||
def __init__(self, version: int, cipher: int, session_nonce: bytes = None):
|
||||
if not (0 <= version < 128):
|
||||
raise ValueError("Version must fit in 7 bits (0..127)")
|
||||
if not (0 <= cipher < 16):
|
||||
raise ValueError("Cipher must fit in 4 bits (0..15)")
|
||||
|
||||
self.version = version
|
||||
self.cipher = cipher
|
||||
|
||||
# Generate session nonce if not provided
|
||||
if session_nonce is None:
|
||||
# Generate 17 random bytes
|
||||
nonce_full = os.urandom(17)
|
||||
# Use top 129 bits
|
||||
nonce_int_full = int.from_bytes(nonce_full, 'big')
|
||||
nonce_129_int = nonce_int_full >> 7 # drop lowest 7 bits
|
||||
self.session_nonce = nonce_129_int.to_bytes(17, 'big')
|
||||
else:
|
||||
if len(session_nonce) != 17:
|
||||
raise ValueError("Session nonce must be 17 bytes (136 bits)")
|
||||
self.session_nonce = session_nonce
|
||||
|
||||
def serialize(self) -> bytes:
|
||||
"""Serialize the ping request into a 22-byte packet."""
|
||||
# Convert session_nonce to integer (129 bits)
|
||||
nonce_int = int.from_bytes(self.session_nonce, 'big')
|
||||
|
||||
# Pack fields: shift nonce left by 11 bits, add version and cipher
|
||||
partial_int = (nonce_int << 11) | (self.version << 4) | (self.cipher & 0x0F)
|
||||
# This creates 129+7+4 = 140 bits; pack into 18 bytes
|
||||
partial_bytes = partial_int.to_bytes(18, 'big')
|
||||
|
||||
# Compute CRC over these 18 bytes
|
||||
cval = crc32_of(partial_bytes)
|
||||
|
||||
# Combine partial data with 32-bit CRC
|
||||
final_int = (int.from_bytes(partial_bytes, 'big') << 32) | cval
|
||||
return final_int.to_bytes(22, 'big')
|
||||
|
||||
@classmethod
|
||||
def deserialize(cls, data: bytes) -> Optional['PingRequest']:
|
||||
"""Deserialize a 22-byte packet into a PingRequest object."""
|
||||
if len(data) != 22:
|
||||
return None
|
||||
|
||||
# Extract 176-bit integer
|
||||
final_int = int.from_bytes(data, 'big')
|
||||
|
||||
# Extract CRC and verify
|
||||
crc_in = final_int & 0xffffffff
|
||||
partial_int = final_int >> 32 # 140 bits
|
||||
partial_bytes = partial_int.to_bytes(18, 'big')
|
||||
crc_calc = crc32_of(partial_bytes)
|
||||
|
||||
if crc_calc != crc_in:
|
||||
return None
|
||||
|
||||
# Extract fields
|
||||
cipher = partial_int & 0x0F
|
||||
version = (partial_int >> 4) & 0x7F
|
||||
nonce_129_int = partial_int >> 11 # 129 bits
|
||||
session_nonce = nonce_129_int.to_bytes(17, 'big')
|
||||
|
||||
return cls(version, cipher, session_nonce)
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PING RESPONSE (new format)
|
||||
# Fields:
|
||||
# - timestamp: 32 bits (we take the lower 32 bits of the time in ms)
|
||||
# - version: 7 bits
|
||||
# - cipher: 4 bits
|
||||
# - answer: 1 bit
|
||||
# - CRC: 32 bits
|
||||
#
|
||||
# Total bits: 32 + 7 + 4 + 1 + 32 = 76 bits; pack into 10 bytes (80 bits) with 4 spare bits.
|
||||
# ---------------------------------------------------------------------------
|
||||
class PingResponse:
|
||||
"""
|
||||
PING RESPONSE format (76 bits / 10 bytes):
|
||||
- timestamp: 32 bits (milliseconds since epoch, lower 32 bits)
|
||||
- version: 7 bits
|
||||
- cipher: 4 bits
|
||||
- answer: 1 bit (0 = no, 1 = yes)
|
||||
- CRC: 32 bits
|
||||
"""
|
||||
def __init__(self, version: int, cipher: int, answer: int, timestamp: int = None):
|
||||
if not (0 <= version < 128):
|
||||
raise ValueError("Version must fit in 7 bits")
|
||||
if not (0 <= cipher < 16):
|
||||
raise ValueError("Cipher must fit in 4 bits")
|
||||
if answer not in (0, 1):
|
||||
raise ValueError("Answer must be 0 or 1")
|
||||
|
||||
self.version = version
|
||||
self.cipher = cipher
|
||||
self.answer = answer
|
||||
self.timestamp = timestamp or (int(time.time() * 1000) & 0xffffffff)
|
||||
|
||||
def serialize(self) -> bytes:
|
||||
"""Serialize the ping response into a 10-byte packet."""
|
||||
# Pack timestamp, version, cipher, answer: 32+7+4+1 = 44 bits
|
||||
# Shift left by 4 to put spare bits at the end
|
||||
partial_val = (self.timestamp << (7+4+1)) | (self.version << (4+1)) | (self.cipher << 1) | self.answer
|
||||
partial_val_shifted = partial_val << 4 # Add 4 spare bits at the end
|
||||
partial_bytes = partial_val_shifted.to_bytes(6, 'big') # 6 bytes = 48 bits
|
||||
|
||||
# Compute CRC
|
||||
cval = crc32_of(partial_bytes)
|
||||
|
||||
# Combine with CRC
|
||||
final_val = (int.from_bytes(partial_bytes, 'big') << 32) | cval
|
||||
return final_val.to_bytes(10, 'big')
|
||||
|
||||
@classmethod
|
||||
def deserialize(cls, data: bytes) -> Optional['PingResponse']:
|
||||
"""Deserialize a 10-byte packet into a PingResponse object."""
|
||||
if len(data) != 10:
|
||||
return None
|
||||
|
||||
# Extract 80-bit integer
|
||||
final_int = int.from_bytes(data, 'big')
|
||||
|
||||
# Extract CRC and verify
|
||||
crc_in = final_int & 0xffffffff
|
||||
partial_int = final_int >> 32 # 48 bits
|
||||
partial_bytes = partial_int.to_bytes(6, 'big')
|
||||
crc_calc = crc32_of(partial_bytes)
|
||||
|
||||
if crc_calc != crc_in:
|
||||
return None
|
||||
|
||||
# Extract fields (discard 4 spare bits)
|
||||
partial_int >>= 4 # now 44 bits
|
||||
answer = partial_int & 0x01
|
||||
cipher = (partial_int >> 1) & 0x0F
|
||||
version = (partial_int >> (1+4)) & 0x7F
|
||||
timestamp = partial_int >> (1+4+7)
|
||||
|
||||
return cls(version, cipher, answer, timestamp)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 3) Handshake
|
||||
# - 32-bit timestamp
|
||||
# - 64-byte ephemeral pubkey (raw x||y = 512 bits)
|
||||
# - 64-byte ephemeral signature (raw r||s = 512 bits)
|
||||
# - 32-byte PFS hash (256 bits)
|
||||
# - 32-bit CRC
|
||||
# => total 4 + 64 + 64 + 32 + 4 = 168 bytes = 1344 bits
|
||||
# =============================================================================
|
||||
|
||||
class Handshake:
|
||||
"""
|
||||
HANDSHAKE format (1344 bits / 168 bytes):
|
||||
- timestamp: 32 bits
|
||||
- ephemeral_pubkey: 512 bits (64 bytes, raw x||y format)
|
||||
- ephemeral_signature: 512 bits (64 bytes, raw r||s format)
|
||||
- pfs_hash: 256 bits (32 bytes)
|
||||
- CRC: 32 bits
|
||||
"""
|
||||
def __init__(self, ephemeral_pubkey: bytes, ephemeral_signature: bytes, pfs_hash: bytes, timestamp: int = None):
|
||||
if len(ephemeral_pubkey) != 64:
|
||||
raise ValueError("ephemeral_pubkey must be 64 bytes (raw x||y)")
|
||||
if len(ephemeral_signature) != 64:
|
||||
raise ValueError("ephemeral_signature must be 64 bytes (raw r||s)")
|
||||
if len(pfs_hash) != 32:
|
||||
raise ValueError("pfs_hash must be 32 bytes")
|
||||
|
||||
self.ephemeral_pubkey = ephemeral_pubkey
|
||||
self.ephemeral_signature = ephemeral_signature
|
||||
self.pfs_hash = pfs_hash
|
||||
self.timestamp = timestamp or (int(time.time() * 1000) & 0xffffffff)
|
||||
|
||||
def serialize(self) -> bytes:
|
||||
"""Serialize the handshake into a 168-byte packet."""
|
||||
# Pack timestamp and other fields
|
||||
partial = struct.pack("!I", self.timestamp) + self.ephemeral_pubkey + self.ephemeral_signature + self.pfs_hash
|
||||
|
||||
# Compute CRC
|
||||
cval = crc32_of(partial)
|
||||
|
||||
# Append CRC
|
||||
return partial + struct.pack("!I", cval)
|
||||
|
||||
@classmethod
|
||||
def deserialize(cls, data: bytes) -> Optional['Handshake']:
|
||||
"""Deserialize a 168-byte packet into a Handshake object."""
|
||||
if len(data) != 168:
|
||||
return None
|
||||
|
||||
# Extract and verify CRC
|
||||
partial = data[:-4]
|
||||
crc_in = struct.unpack("!I", data[-4:])[0]
|
||||
crc_calc = crc32_of(partial)
|
||||
|
||||
if crc_calc != crc_in:
|
||||
return None
|
||||
|
||||
# Extract fields
|
||||
timestamp = struct.unpack("!I", partial[:4])[0]
|
||||
ephemeral_pubkey = partial[4:4+64]
|
||||
ephemeral_signature = partial[68:68+64]
|
||||
pfs_hash = partial[132:132+32]
|
||||
|
||||
return cls(ephemeral_pubkey, ephemeral_signature, pfs_hash, timestamp)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 4) PFS Hash Helper
|
||||
# If no previous session, return 32 zero bytes
|
||||
# Otherwise, compute sha256(session_number || last_shared_secret).
|
||||
# =============================================================================
|
||||
|
||||
def compute_pfs_hash(session_number: int, shared_secret_hex: str) -> bytes:
|
||||
"""
|
||||
Compute the PFS hash field for handshake messages:
|
||||
- If no previous session (session_number < 0), return 32 zero bytes
|
||||
- Otherwise, compute sha256(session_number || shared_secret)
|
||||
"""
|
||||
if session_number < 0:
|
||||
return b"\x00" * 32
|
||||
|
||||
# Convert shared_secret_hex to raw bytes
|
||||
secret_bytes = bytes.fromhex(shared_secret_hex)
|
||||
|
||||
# Pack session_number as 4 bytes
|
||||
sn_bytes = struct.pack("!I", session_number)
|
||||
|
||||
# Compute hash
|
||||
return hashlib.sha256(sn_bytes + secret_bytes).digest()
|
||||
|
||||
|
||||
# Helper function for CRC32 calculations
|
||||
def compute_crc32(data: bytes) -> int:
|
||||
"""Compute CRC32 of data (for consistency with crc32_of)."""
|
||||
return zlib.crc32(data) & 0xffffffff
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Voice Protocol Messages
|
||||
# =============================================================================
|
||||
|
||||
class VoiceStart:
|
||||
"""
|
||||
Voice call initiation message (20 bytes).
|
||||
|
||||
Fields:
|
||||
- version: 8 bits (protocol version)
|
||||
- codec_mode: 8 bits (Codec2 mode)
|
||||
- fec_type: 8 bits (0=repetition, 1=convolutional, 2=LDPC)
|
||||
- flags: 8 bits (reserved for future use)
|
||||
- session_id: 64 bits (unique voice session identifier)
|
||||
- initial_sequence: 32 bits (starting sequence number)
|
||||
- crc32: 32 bits
|
||||
"""
|
||||
|
||||
def __init__(self, version: int = 0, codec_mode: int = 5, fec_type: int = 0,
|
||||
flags: int = 0, session_id: int = None, initial_sequence: int = 0):
|
||||
self.version = version
|
||||
self.codec_mode = codec_mode
|
||||
self.fec_type = fec_type
|
||||
self.flags = flags | 0x80 # Set high bit to distinguish from VoiceSync
|
||||
self.session_id = session_id or int.from_bytes(os.urandom(8), 'big')
|
||||
self.initial_sequence = initial_sequence
|
||||
|
||||
def serialize(self) -> bytes:
|
||||
"""Serialize to 20 bytes."""
|
||||
# Pack all fields except CRC
|
||||
data = struct.pack('>BBBBQII',
|
||||
self.version,
|
||||
self.codec_mode,
|
||||
self.fec_type,
|
||||
self.flags,
|
||||
self.session_id,
|
||||
self.initial_sequence,
|
||||
0 # CRC placeholder
|
||||
)
|
||||
|
||||
# Calculate and append CRC
|
||||
crc = compute_crc32(data[:-4])
|
||||
return data[:-4] + struct.pack('>I', crc)
|
||||
|
||||
@classmethod
|
||||
def deserialize(cls, data: bytes) -> Optional['VoiceStart']:
|
||||
"""Deserialize from bytes."""
|
||||
if len(data) != 20:
|
||||
return None
|
||||
|
||||
try:
|
||||
version, codec_mode, fec_type, flags, session_id, initial_seq, crc = struct.unpack('>BBBBQII', data)
|
||||
|
||||
# Verify CRC
|
||||
expected_crc = compute_crc32(data[:-4])
|
||||
if crc != expected_crc:
|
||||
return None
|
||||
|
||||
return cls(version, codec_mode, fec_type, flags, session_id, initial_seq)
|
||||
except struct.error:
|
||||
return None
|
||||
|
||||
|
||||
class VoiceAck:
|
||||
"""
|
||||
Voice call acknowledgment message (16 bytes).
|
||||
|
||||
Fields:
|
||||
- version: 8 bits
|
||||
- status: 8 bits (0=reject, 1=accept)
|
||||
- codec_mode: 8 bits (negotiated codec mode)
|
||||
- fec_type: 8 bits (negotiated FEC type)
|
||||
- session_id: 64 bits (echo of received session_id)
|
||||
- crc32: 32 bits
|
||||
"""
|
||||
|
||||
def __init__(self, version: int = 0, status: int = 1, codec_mode: int = 5,
|
||||
fec_type: int = 0, session_id: int = 0):
|
||||
self.version = version
|
||||
self.status = status
|
||||
self.codec_mode = codec_mode
|
||||
self.fec_type = fec_type
|
||||
self.session_id = session_id
|
||||
|
||||
def serialize(self) -> bytes:
|
||||
"""Serialize to 16 bytes."""
|
||||
data = struct.pack('>BBBBQI',
|
||||
self.version,
|
||||
self.status,
|
||||
self.codec_mode,
|
||||
self.fec_type,
|
||||
self.session_id,
|
||||
0 # CRC placeholder
|
||||
)
|
||||
|
||||
crc = compute_crc32(data[:-4])
|
||||
return data[:-4] + struct.pack('>I', crc)
|
||||
|
||||
@classmethod
|
||||
def deserialize(cls, data: bytes) -> Optional['VoiceAck']:
|
||||
"""Deserialize from bytes."""
|
||||
if len(data) != 16:
|
||||
return None
|
||||
|
||||
try:
|
||||
version, status, codec_mode, fec_type, session_id, crc = struct.unpack('>BBBBQI', data)
|
||||
|
||||
expected_crc = compute_crc32(data[:-4])
|
||||
if crc != expected_crc:
|
||||
return None
|
||||
|
||||
return cls(version, status, codec_mode, fec_type, session_id)
|
||||
except struct.error:
|
||||
return None
|
||||
|
||||
|
||||
class VoiceEnd:
|
||||
"""
|
||||
Voice call termination message (12 bytes).
|
||||
|
||||
Fields:
|
||||
- session_id: 64 bits
|
||||
- crc32: 32 bits
|
||||
"""
|
||||
|
||||
def __init__(self, session_id: int):
|
||||
self.session_id = session_id
|
||||
|
||||
def serialize(self) -> bytes:
|
||||
"""Serialize to 12 bytes."""
|
||||
data = struct.pack('>QI', self.session_id, 0)
|
||||
crc = compute_crc32(data[:-4])
|
||||
return data[:-4] + struct.pack('>I', crc)
|
||||
|
||||
@classmethod
|
||||
def deserialize(cls, data: bytes) -> Optional['VoiceEnd']:
|
||||
"""Deserialize from bytes."""
|
||||
if len(data) != 12:
|
||||
return None
|
||||
|
||||
try:
|
||||
session_id, crc = struct.unpack('>QI', data)
|
||||
|
||||
expected_crc = compute_crc32(data[:-4])
|
||||
if crc != expected_crc:
|
||||
return None
|
||||
|
||||
return cls(session_id)
|
||||
except struct.error:
|
||||
return None
|
||||
|
||||
|
||||
class VoiceSync:
|
||||
"""
|
||||
Voice synchronization frame (20 bytes).
|
||||
Used for maintaining sync and providing timing information.
|
||||
|
||||
Fields:
|
||||
- session_id: 64 bits
|
||||
- sequence: 32 bits
|
||||
- timestamp: 32 bits (milliseconds since voice start)
|
||||
- crc32: 32 bits
|
||||
"""
|
||||
|
||||
def __init__(self, session_id: int, sequence: int, timestamp: int):
|
||||
self.session_id = session_id
|
||||
self.sequence = sequence
|
||||
self.timestamp = timestamp
|
||||
|
||||
def serialize(self) -> bytes:
|
||||
"""Serialize to 20 bytes."""
|
||||
data = struct.pack('>QIII', self.session_id, self.sequence, self.timestamp, 0)
|
||||
crc = compute_crc32(data[:-4])
|
||||
return data[:-4] + struct.pack('>I', crc)
|
||||
|
||||
@classmethod
|
||||
def deserialize(cls, data: bytes) -> Optional['VoiceSync']:
|
||||
"""Deserialize from bytes."""
|
||||
if len(data) != 20:
|
||||
return None
|
||||
|
||||
try:
|
||||
session_id, sequence, timestamp, crc = struct.unpack('>QIII', data)
|
||||
|
||||
expected_crc = compute_crc32(data[:-4])
|
||||
if crc != expected_crc:
|
||||
return None
|
||||
|
||||
return cls(session_id, sequence, timestamp)
|
||||
except struct.error:
|
||||
return None
|
1069
protocol_prototype/Prototype/Protocol_Alpha_0/protocol.py
Normal file
1069
protocol_prototype/Prototype/Protocol_Alpha_0/protocol.py
Normal file
File diff suppressed because it is too large
Load Diff
100
protocol_prototype/Prototype/Protocol_Alpha_0/transmission.py
Normal file
100
protocol_prototype/Prototype/Protocol_Alpha_0/transmission.py
Normal file
@ -0,0 +1,100 @@
|
||||
import socket
|
||||
import threading
|
||||
from typing import Callable
|
||||
|
||||
class PeerConnection:
|
||||
"""
|
||||
Represents a live, two-way connection to a peer.
|
||||
We keep a socket open, read data in a background thread,
|
||||
and can send data from the main thread at any time.
|
||||
"""
|
||||
def __init__(self, sock: socket.socket, on_data_received: Callable[['PeerConnection', bytes], None]):
|
||||
self.sock = sock
|
||||
self.on_data_received = on_data_received
|
||||
self.alive = True
|
||||
|
||||
self.read_thread = threading.Thread(target=self.read_loop, daemon=True)
|
||||
self.read_thread.start()
|
||||
|
||||
def read_loop(self):
|
||||
while self.alive:
|
||||
try:
|
||||
data = self.sock.recv(4096)
|
||||
if not data:
|
||||
break
|
||||
self.on_data_received(self, data)
|
||||
except OSError:
|
||||
break
|
||||
self.alive = False
|
||||
self.sock.close()
|
||||
print("[PeerConnection] Connection closed.")
|
||||
|
||||
def send(self, data: bytes):
|
||||
if not self.alive:
|
||||
print("[PeerConnection.send] Cannot send, connection not alive.")
|
||||
return
|
||||
try:
|
||||
self.sock.sendall(data)
|
||||
except OSError:
|
||||
print("[PeerConnection.send] Send failed, connection might be closed.")
|
||||
self.alive = False
|
||||
|
||||
def close(self):
|
||||
self.alive = False
|
||||
try:
|
||||
self.sock.shutdown(socket.SHUT_RDWR)
|
||||
except OSError:
|
||||
pass
|
||||
self.sock.close()
|
||||
|
||||
|
||||
class ServerListener(threading.Thread):
|
||||
"""
|
||||
A thread that listens on a given port. When a new client connects,
|
||||
it creates a PeerConnection for that client.
|
||||
"""
|
||||
def __init__(self, host: str, port: int,
|
||||
on_new_connection: Callable[[PeerConnection], None],
|
||||
on_data_received: Callable[[PeerConnection, bytes], None]):
|
||||
super().__init__(daemon=True)
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.on_new_connection = on_new_connection
|
||||
self.on_data_received = on_data_received
|
||||
self.server_socket = None
|
||||
self.stop_event = threading.Event()
|
||||
|
||||
def run(self):
|
||||
self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
self.server_socket.bind((self.host, self.port))
|
||||
self.server_socket.listen(5)
|
||||
self.server_socket.settimeout(1.0)
|
||||
print(f"[ServerListener] Listening on {self.host}:{self.port}")
|
||||
|
||||
while not self.stop_event.is_set():
|
||||
try:
|
||||
client_sock, addr = self.server_socket.accept()
|
||||
print(f"[ServerListener] Accepted connection from {addr}")
|
||||
conn = PeerConnection(client_sock, self.on_data_received)
|
||||
self.on_new_connection(conn)
|
||||
except socket.timeout:
|
||||
pass
|
||||
except OSError:
|
||||
break
|
||||
|
||||
if self.server_socket:
|
||||
self.server_socket.close()
|
||||
|
||||
def stop(self):
|
||||
self.stop_event.set()
|
||||
if self.server_socket:
|
||||
self.server_socket.close()
|
||||
|
||||
|
||||
def connect_to_peer(host: str, port: int,
|
||||
on_data_received: Callable[[PeerConnection, bytes], None]) -> PeerConnection:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.connect((host, port))
|
||||
print(f"[connect_to_peer] Connected to {host}:{port}")
|
||||
conn = PeerConnection(sock, on_data_received)
|
||||
return conn
|
@ -28,7 +28,7 @@ RESET = "\033[0m"
|
||||
class Codec2Mode(IntEnum):
|
||||
"""Codec2 bitrate modes."""
|
||||
MODE_3200 = 0 # 3200 bps
|
||||
MODE_2400 = 1 # 2400 bps
|
||||
MODE_2400 = 1 # 2400 bps
|
||||
MODE_1600 = 2 # 1600 bps
|
||||
MODE_1400 = 3 # 1400 bps
|
||||
MODE_1300 = 4 # 1300 bps
|
||||
@ -51,7 +51,7 @@ class Codec2Wrapper:
|
||||
In production, this would use py_codec2 or ctypes bindings to libcodec2.
|
||||
This is a simulation interface for protocol development.
|
||||
"""
|
||||
|
||||
|
||||
# Frame sizes in bits for each mode
|
||||
FRAME_BITS = {
|
||||
Codec2Mode.MODE_3200: 64,
|
||||
@ -62,7 +62,7 @@ class Codec2Wrapper:
|
||||
Codec2Mode.MODE_1200: 48,
|
||||
Codec2Mode.MODE_700C: 28
|
||||
}
|
||||
|
||||
|
||||
# Frame duration in ms
|
||||
FRAME_MS = {
|
||||
Codec2Mode.MODE_3200: 20,
|
||||
@ -73,11 +73,11 @@ class Codec2Wrapper:
|
||||
Codec2Mode.MODE_1200: 40,
|
||||
Codec2Mode.MODE_700C: 40
|
||||
}
|
||||
|
||||
|
||||
def __init__(self, mode: Codec2Mode = Codec2Mode.MODE_1200):
|
||||
"""
|
||||
Initialize Codec2 wrapper.
|
||||
|
||||
|
||||
Args:
|
||||
mode: Codec2 bitrate mode (default 1200 bps for robustness)
|
||||
"""
|
||||
@ -87,53 +87,54 @@ class Codec2Wrapper:
|
||||
self.frame_ms = self.FRAME_MS[mode]
|
||||
self.frame_samples = int(8000 * self.frame_ms / 1000) # 8kHz sampling
|
||||
self.frame_counter = 0
|
||||
|
||||
# Quiet initialization - no print
|
||||
|
||||
|
||||
print(f"{GREEN}[CODEC2]{RESET} Initialized in mode {mode.name} "
|
||||
f"({self.frame_bits} bits/frame, {self.frame_ms}ms duration)")
|
||||
|
||||
def encode(self, audio_samples) -> Optional[Codec2Frame]:
|
||||
"""
|
||||
Encode PCM audio samples to Codec2 frame.
|
||||
|
||||
|
||||
Args:
|
||||
audio_samples: PCM samples (8kHz, 16-bit signed)
|
||||
|
||||
|
||||
Returns:
|
||||
Codec2Frame or None if insufficient samples
|
||||
"""
|
||||
if len(audio_samples) < self.frame_samples:
|
||||
return None
|
||||
|
||||
|
||||
# In production: call codec2_encode(state, bits, samples)
|
||||
# Simulation: create pseudo-compressed data
|
||||
compressed = self._simulate_compression(audio_samples[:self.frame_samples])
|
||||
|
||||
|
||||
frame = Codec2Frame(
|
||||
mode=self.mode,
|
||||
bits=compressed,
|
||||
timestamp=self.frame_counter * self.frame_ms / 1000.0,
|
||||
frame_number=self.frame_counter
|
||||
)
|
||||
|
||||
|
||||
self.frame_counter += 1
|
||||
return frame
|
||||
|
||||
|
||||
def decode(self, frame: Codec2Frame):
|
||||
"""
|
||||
Decode Codec2 frame to PCM audio samples.
|
||||
|
||||
|
||||
Args:
|
||||
frame: Codec2 compressed frame
|
||||
|
||||
|
||||
Returns:
|
||||
PCM samples (8kHz, 16-bit signed)
|
||||
"""
|
||||
if frame.mode != self.mode:
|
||||
raise ValueError(f"Frame mode {frame.mode} doesn't match decoder mode {self.mode}")
|
||||
|
||||
|
||||
# In production: call codec2_decode(state, samples, bits)
|
||||
# Simulation: decompress to audio
|
||||
return self._simulate_decompression(frame.bits)
|
||||
|
||||
|
||||
def _simulate_compression(self, samples) -> bytes:
|
||||
"""Simulate Codec2 compression (for testing)."""
|
||||
# Convert to list if needed
|
||||
@ -143,7 +144,7 @@ class Codec2Wrapper:
|
||||
sample_list = list(samples)
|
||||
else:
|
||||
sample_list = samples
|
||||
|
||||
|
||||
# Extract basic features for simulation
|
||||
if HAS_NUMPY and hasattr(samples, '__array__'):
|
||||
# Convert to numpy array if needed
|
||||
@ -159,23 +160,23 @@ class Codec2Wrapper:
|
||||
# Manual calculation without numpy
|
||||
if sample_list and len(sample_list) > 0:
|
||||
energy = math.sqrt(sum(s**2 for s in sample_list) / len(sample_list))
|
||||
zero_crossings = sum(1 for i in range(1, len(sample_list))
|
||||
zero_crossings = sum(1 for i in range(1, len(sample_list))
|
||||
if (sample_list[i-1] >= 0) != (sample_list[i] >= 0))
|
||||
else:
|
||||
energy = 0.0
|
||||
zero_crossings = 0
|
||||
|
||||
|
||||
# Pack into bytes (simplified)
|
||||
# Ensure values are valid
|
||||
energy_int = max(0, min(65535, int(energy)))
|
||||
zc_int = max(0, min(65535, int(zero_crossings)))
|
||||
data = struct.pack('<HH', energy_int, zc_int)
|
||||
|
||||
|
||||
# Pad to expected frame size
|
||||
data += b'\x00' * (self.frame_bytes - len(data))
|
||||
|
||||
|
||||
return data[:self.frame_bytes]
|
||||
|
||||
|
||||
def _simulate_decompression(self, compressed: bytes):
|
||||
"""Simulate Codec2 decompression (for testing)."""
|
||||
# Unpack features
|
||||
@ -183,41 +184,41 @@ class Codec2Wrapper:
|
||||
energy, zero_crossings = struct.unpack('<HH', compressed[:4])
|
||||
else:
|
||||
energy, zero_crossings = 1000, 100
|
||||
|
||||
|
||||
# Generate synthetic speech-like signal
|
||||
if HAS_NUMPY:
|
||||
t = np.linspace(0, self.frame_ms/1000, self.frame_samples)
|
||||
|
||||
|
||||
# Base frequency from zero crossings
|
||||
freq = zero_crossings * 10 # Simplified mapping
|
||||
|
||||
|
||||
# Generate harmonics
|
||||
signal = np.zeros(self.frame_samples)
|
||||
for harmonic in range(1, 4):
|
||||
signal += np.sin(2 * np.pi * freq * harmonic * t) / harmonic
|
||||
|
||||
|
||||
# Apply energy envelope
|
||||
signal *= energy / 10000.0
|
||||
|
||||
|
||||
# Convert to 16-bit PCM
|
||||
return (signal * 32767).astype(np.int16)
|
||||
else:
|
||||
# Manual generation without numpy
|
||||
samples = []
|
||||
freq = zero_crossings * 10
|
||||
|
||||
|
||||
for i in range(self.frame_samples):
|
||||
t = i / 8000.0 # 8kHz sample rate
|
||||
value = 0
|
||||
for harmonic in range(1, 4):
|
||||
value += math.sin(2 * math.pi * freq * harmonic * t) / harmonic
|
||||
|
||||
|
||||
value *= energy / 10000.0
|
||||
# Clamp to 16-bit range
|
||||
sample = int(value * 32767)
|
||||
sample = max(-32768, min(32767, sample))
|
||||
samples.append(sample)
|
||||
|
||||
|
||||
return array.array('h', samples)
|
||||
|
||||
|
||||
@ -226,11 +227,11 @@ class FSKModem:
|
||||
4-FSK modem for transmitting digital data over voice channels.
|
||||
Designed to survive GSM/AMR/EVS vocoders.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, sample_rate: int = 8000, baud_rate: int = 600):
|
||||
"""
|
||||
Initialize FSK modem.
|
||||
|
||||
|
||||
Args:
|
||||
sample_rate: Audio sample rate (Hz)
|
||||
baud_rate: Symbol rate (baud)
|
||||
@ -238,29 +239,30 @@ class FSKModem:
|
||||
self.sample_rate = sample_rate
|
||||
self.baud_rate = baud_rate
|
||||
self.samples_per_symbol = int(sample_rate / baud_rate)
|
||||
|
||||
|
||||
# 4-FSK frequencies (300-3400 Hz band)
|
||||
self.frequencies = [
|
||||
600, # 00
|
||||
1200, # 01
|
||||
1200, # 01
|
||||
1800, # 10
|
||||
2400 # 11
|
||||
]
|
||||
|
||||
|
||||
# Preamble for synchronization (800 Hz, 100ms)
|
||||
self.preamble_freq = 800
|
||||
self.preamble_duration = 0.1 # seconds
|
||||
|
||||
# Quiet initialization - no print
|
||||
|
||||
|
||||
print(f"{GREEN}[FSK]{RESET} Initialized 4-FSK modem "
|
||||
f"({baud_rate} baud, frequencies: {self.frequencies})")
|
||||
|
||||
def modulate(self, data: bytes, add_preamble: bool = True):
|
||||
"""
|
||||
Modulate binary data to FSK audio signal.
|
||||
|
||||
|
||||
Args:
|
||||
data: Binary data to modulate
|
||||
add_preamble: Whether to add synchronization preamble
|
||||
|
||||
|
||||
Returns:
|
||||
Audio signal (normalized float32 array or list)
|
||||
"""
|
||||
@ -273,10 +275,10 @@ class FSKModem:
|
||||
(byte >> 2) & 0x03,
|
||||
byte & 0x03
|
||||
])
|
||||
|
||||
|
||||
# Generate audio signal
|
||||
signal = []
|
||||
|
||||
|
||||
# Add preamble
|
||||
if add_preamble:
|
||||
preamble_samples = int(self.preamble_duration * self.sample_rate)
|
||||
@ -289,7 +291,7 @@ class FSKModem:
|
||||
t = i / self.sample_rate
|
||||
value = math.sin(2 * math.pi * self.preamble_freq * t)
|
||||
signal.append(value)
|
||||
|
||||
|
||||
# Modulate symbols
|
||||
for symbol in symbols:
|
||||
freq = self.frequencies[symbol]
|
||||
@ -302,23 +304,23 @@ class FSKModem:
|
||||
t = i / self.sample_rate
|
||||
value = math.sin(2 * math.pi * freq * t)
|
||||
signal.append(value)
|
||||
|
||||
|
||||
# Apply smoothing to reduce clicks
|
||||
if HAS_NUMPY:
|
||||
audio = np.array(signal, dtype=np.float32)
|
||||
else:
|
||||
audio = array.array('f', signal)
|
||||
audio = self._apply_envelope(audio)
|
||||
|
||||
|
||||
return audio
|
||||
|
||||
|
||||
def demodulate(self, audio) -> Tuple[bytes, float]:
|
||||
"""
|
||||
Demodulate FSK audio signal to binary data.
|
||||
|
||||
|
||||
Args:
|
||||
audio: Audio signal
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (demodulated data, confidence score)
|
||||
"""
|
||||
@ -326,14 +328,14 @@ class FSKModem:
|
||||
preamble_start = self._find_preamble(audio)
|
||||
if preamble_start < 0:
|
||||
return b'', 0.0
|
||||
|
||||
|
||||
# Skip preamble
|
||||
data_start = preamble_start + int(self.preamble_duration * self.sample_rate)
|
||||
|
||||
|
||||
# Demodulate symbols
|
||||
symbols = []
|
||||
confidence_scores = []
|
||||
|
||||
|
||||
pos = data_start
|
||||
while pos + self.samples_per_symbol <= len(audio):
|
||||
symbol_audio = audio[pos:pos + self.samples_per_symbol]
|
||||
@ -341,58 +343,58 @@ class FSKModem:
|
||||
symbols.append(symbol)
|
||||
confidence_scores.append(confidence)
|
||||
pos += self.samples_per_symbol
|
||||
|
||||
|
||||
# Convert symbols to bytes
|
||||
data = bytearray()
|
||||
for i in range(0, len(symbols), 4):
|
||||
if i + 3 < len(symbols):
|
||||
byte = (symbols[i] << 6) | (symbols[i+1] << 4) | (symbols[i+2] << 2) | symbols[i+3]
|
||||
data.append(byte)
|
||||
|
||||
|
||||
if HAS_NUMPY and confidence_scores:
|
||||
avg_confidence = np.mean(confidence_scores)
|
||||
else:
|
||||
avg_confidence = sum(confidence_scores) / len(confidence_scores) if confidence_scores else 0.0
|
||||
return bytes(data), avg_confidence
|
||||
|
||||
|
||||
def _find_preamble(self, audio) -> int:
|
||||
"""Find preamble in audio signal."""
|
||||
# Simple energy-based detection
|
||||
window_size = int(0.01 * self.sample_rate) # 10ms window
|
||||
|
||||
|
||||
if HAS_NUMPY:
|
||||
for i in range(0, len(audio) - window_size, window_size // 2):
|
||||
window = audio[i:i + window_size]
|
||||
|
||||
|
||||
# Check for preamble frequency
|
||||
fft = np.fft.fft(window)
|
||||
freqs = np.fft.fftfreq(len(window), 1/self.sample_rate)
|
||||
|
||||
|
||||
# Find peak near preamble frequency
|
||||
idx = np.argmax(np.abs(fft[:len(fft)//2]))
|
||||
peak_freq = abs(freqs[idx])
|
||||
|
||||
|
||||
if abs(peak_freq - self.preamble_freq) < 50: # 50 Hz tolerance
|
||||
return i
|
||||
else:
|
||||
# Simple zero-crossing based detection without FFT
|
||||
for i in range(0, len(audio) - window_size, window_size // 2):
|
||||
window = list(audio[i:i + window_size])
|
||||
|
||||
|
||||
# Count zero crossings
|
||||
zero_crossings = 0
|
||||
for j in range(1, len(window)):
|
||||
if (window[j-1] >= 0) != (window[j] >= 0):
|
||||
zero_crossings += 1
|
||||
|
||||
|
||||
# Estimate frequency from zero crossings
|
||||
estimated_freq = (zero_crossings * self.sample_rate) / (2 * len(window))
|
||||
|
||||
|
||||
if abs(estimated_freq - self.preamble_freq) < 100: # 100 Hz tolerance
|
||||
return i
|
||||
|
||||
|
||||
return -1
|
||||
|
||||
|
||||
def _demodulate_symbol(self, audio) -> Tuple[int, float]:
|
||||
"""Demodulate a single FSK symbol."""
|
||||
if HAS_NUMPY:
|
||||
@ -400,58 +402,58 @@ class FSKModem:
|
||||
fft = np.fft.fft(audio)
|
||||
freqs = np.fft.fftfreq(len(audio), 1/self.sample_rate)
|
||||
magnitude = np.abs(fft[:len(fft)//2])
|
||||
|
||||
|
||||
# Find energy at each FSK frequency
|
||||
energies = []
|
||||
for freq in self.frequencies:
|
||||
idx = np.argmin(np.abs(freqs[:len(freqs)//2] - freq))
|
||||
energy = magnitude[idx]
|
||||
energies.append(energy)
|
||||
|
||||
|
||||
# Select symbol with highest energy
|
||||
symbol = np.argmax(energies)
|
||||
else:
|
||||
# Goertzel algorithm for specific frequency detection
|
||||
audio_list = list(audio) if hasattr(audio, '__iter__') else audio
|
||||
energies = []
|
||||
|
||||
|
||||
for freq in self.frequencies:
|
||||
# Goertzel algorithm
|
||||
omega = 2 * math.pi * freq / self.sample_rate
|
||||
coeff = 2 * math.cos(omega)
|
||||
|
||||
|
||||
s_prev = 0
|
||||
s_prev2 = 0
|
||||
|
||||
|
||||
for sample in audio_list:
|
||||
s = sample + coeff * s_prev - s_prev2
|
||||
s_prev2 = s_prev
|
||||
s_prev = s
|
||||
|
||||
|
||||
# Calculate magnitude
|
||||
power = s_prev2 * s_prev2 + s_prev * s_prev - coeff * s_prev * s_prev2
|
||||
energies.append(math.sqrt(abs(power)))
|
||||
|
||||
|
||||
# Select symbol with highest energy
|
||||
symbol = energies.index(max(energies))
|
||||
|
||||
|
||||
# Confidence is ratio of strongest to second strongest
|
||||
sorted_energies = sorted(energies, reverse=True)
|
||||
confidence = sorted_energies[0] / (sorted_energies[1] + 1e-6)
|
||||
|
||||
|
||||
return symbol, min(confidence, 10.0) / 10.0
|
||||
|
||||
|
||||
def _apply_envelope(self, audio):
|
||||
"""Apply smoothing envelope to reduce clicks."""
|
||||
# Simple raised cosine envelope
|
||||
ramp_samples = int(0.002 * self.sample_rate) # 2ms ramps
|
||||
|
||||
|
||||
if len(audio) > 2 * ramp_samples:
|
||||
if HAS_NUMPY:
|
||||
# Fade in
|
||||
t = np.linspace(0, np.pi/2, ramp_samples)
|
||||
audio[:ramp_samples] *= np.sin(t) ** 2
|
||||
|
||||
|
||||
# Fade out
|
||||
audio[-ramp_samples:] *= np.sin(t[::-1]) ** 2
|
||||
else:
|
||||
@ -460,13 +462,13 @@ class FSKModem:
|
||||
t = (i / ramp_samples) * (math.pi / 2)
|
||||
factor = math.sin(t) ** 2
|
||||
audio[i] *= factor
|
||||
|
||||
|
||||
# Manual fade out
|
||||
for i in range(ramp_samples):
|
||||
t = ((ramp_samples - 1 - i) / ramp_samples) * (math.pi / 2)
|
||||
factor = math.sin(t) ** 2
|
||||
audio[-(i+1)] *= factor
|
||||
|
||||
|
||||
return audio
|
||||
|
||||
|
||||
@ -475,38 +477,38 @@ class VoiceProtocol:
|
||||
Integrates voice codec and modem with the Icing protocol
|
||||
for encrypted voice transmission over GSM.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, protocol_instance):
|
||||
"""
|
||||
Initialize voice protocol handler.
|
||||
|
||||
|
||||
Args:
|
||||
protocol_instance: IcingProtocol instance
|
||||
"""
|
||||
self.protocol = protocol_instance
|
||||
self.codec = Codec2Wrapper(Codec2Mode.MODE_1200)
|
||||
self.modem = FSKModem(sample_rate=8000, baud_rate=600)
|
||||
|
||||
|
||||
# Voice crypto state
|
||||
self.voice_iv_counter = 0
|
||||
self.voice_sequence = 0
|
||||
|
||||
|
||||
# Buffers
|
||||
if HAS_NUMPY:
|
||||
self.audio_buffer = np.array([], dtype=np.int16)
|
||||
else:
|
||||
self.audio_buffer = array.array('h') # 16-bit signed integers
|
||||
self.frame_buffer = []
|
||||
|
||||
|
||||
print(f"{GREEN}[VOICE]{RESET} Voice protocol initialized")
|
||||
|
||||
|
||||
def process_voice_input(self, audio_samples):
|
||||
"""
|
||||
Process voice input: compress, encrypt, and modulate.
|
||||
|
||||
|
||||
Args:
|
||||
audio_samples: PCM audio samples (8kHz, 16-bit)
|
||||
|
||||
|
||||
Returns:
|
||||
Modulated audio signal ready for transmission (numpy array or array.array)
|
||||
"""
|
||||
@ -515,10 +517,10 @@ class VoiceProtocol:
|
||||
self.audio_buffer = np.concatenate([self.audio_buffer, audio_samples])
|
||||
else:
|
||||
self.audio_buffer.extend(audio_samples)
|
||||
|
||||
|
||||
# Process complete frames
|
||||
modulated_audio = []
|
||||
|
||||
|
||||
while len(self.audio_buffer) >= self.codec.frame_samples:
|
||||
# Extract frame
|
||||
if HAS_NUMPY:
|
||||
@ -527,22 +529,22 @@ class VoiceProtocol:
|
||||
else:
|
||||
frame_audio = array.array('h', self.audio_buffer[:self.codec.frame_samples])
|
||||
del self.audio_buffer[:self.codec.frame_samples]
|
||||
|
||||
|
||||
# Compress with Codec2
|
||||
compressed_frame = self.codec.encode(frame_audio)
|
||||
if not compressed_frame:
|
||||
continue
|
||||
|
||||
|
||||
# Encrypt frame
|
||||
encrypted = self._encrypt_voice_frame(compressed_frame)
|
||||
|
||||
|
||||
# Add FEC
|
||||
protected = self._add_fec(encrypted)
|
||||
|
||||
|
||||
# Modulate to audio
|
||||
audio_signal = self.modem.modulate(protected, add_preamble=True)
|
||||
modulated_audio.append(audio_signal)
|
||||
|
||||
|
||||
if modulated_audio:
|
||||
if HAS_NUMPY:
|
||||
return np.concatenate(modulated_audio)
|
||||
@ -553,86 +555,86 @@ class VoiceProtocol:
|
||||
result.extend(audio)
|
||||
return result
|
||||
return None
|
||||
|
||||
|
||||
def process_voice_output(self, modulated_audio):
|
||||
"""
|
||||
Process received audio: demodulate, decrypt, and decompress.
|
||||
|
||||
|
||||
Args:
|
||||
modulated_audio: Received FSK-modulated audio
|
||||
|
||||
|
||||
Returns:
|
||||
Decoded PCM audio samples (numpy array or array.array)
|
||||
"""
|
||||
# Demodulate
|
||||
data, confidence = self.modem.demodulate(modulated_audio)
|
||||
|
||||
|
||||
if confidence < 0.5:
|
||||
print(f"{YELLOW}[VOICE]{RESET} Low demodulation confidence: {confidence:.2f}")
|
||||
return None
|
||||
|
||||
|
||||
# Remove FEC
|
||||
frame_data = self._remove_fec(data)
|
||||
if not frame_data:
|
||||
return None
|
||||
|
||||
|
||||
# Decrypt
|
||||
compressed_frame = self._decrypt_voice_frame(frame_data)
|
||||
if not compressed_frame:
|
||||
return None
|
||||
|
||||
|
||||
# Decompress
|
||||
audio_samples = self.codec.decode(compressed_frame)
|
||||
|
||||
|
||||
return audio_samples
|
||||
|
||||
|
||||
def _encrypt_voice_frame(self, frame: Codec2Frame) -> bytes:
|
||||
"""Encrypt a voice frame using ChaCha20-CTR."""
|
||||
if not self.protocol.hkdf_key:
|
||||
raise ValueError("No encryption key available")
|
||||
|
||||
|
||||
# Prepare frame data
|
||||
frame_data = struct.pack('<BIH',
|
||||
frame_data = struct.pack('<BIH',
|
||||
frame.mode,
|
||||
frame.frame_number,
|
||||
len(frame.bits)
|
||||
) + frame.bits
|
||||
|
||||
|
||||
# Generate IV for this frame (ChaCha20 needs 16 bytes)
|
||||
iv = struct.pack('<Q', self.voice_iv_counter) + b'\x00' * 8 # 8 + 8 = 16 bytes
|
||||
self.voice_iv_counter += 1
|
||||
|
||||
|
||||
# Encrypt using ChaCha20
|
||||
from encryption import chacha20_encrypt
|
||||
key = bytes.fromhex(self.protocol.hkdf_key)
|
||||
encrypted = chacha20_encrypt(frame_data, key, iv)
|
||||
|
||||
|
||||
# Add sequence number and IV hint
|
||||
return struct.pack('<HQ', self.voice_sequence, self.voice_iv_counter) + encrypted
|
||||
|
||||
|
||||
def _decrypt_voice_frame(self, data: bytes) -> Optional[Codec2Frame]:
|
||||
"""Decrypt a voice frame."""
|
||||
if len(data) < 10:
|
||||
return None
|
||||
|
||||
|
||||
# Extract sequence and IV hint
|
||||
sequence, iv_hint = struct.unpack('<HQ', data[:10])
|
||||
encrypted = data[10:]
|
||||
|
||||
|
||||
# Generate IV (16 bytes for ChaCha20)
|
||||
iv = struct.pack('<Q', iv_hint) + b'\x00' * 8
|
||||
|
||||
|
||||
# Decrypt
|
||||
from encryption import chacha20_decrypt
|
||||
key = bytes.fromhex(self.protocol.hkdf_key)
|
||||
|
||||
|
||||
try:
|
||||
decrypted = chacha20_decrypt(encrypted, key, iv)
|
||||
|
||||
|
||||
# Parse frame
|
||||
mode, frame_num, bits_len = struct.unpack('<BIH', decrypted[:7])
|
||||
bits = decrypted[7:7+bits_len]
|
||||
|
||||
|
||||
return Codec2Frame(
|
||||
mode=Codec2Mode(mode),
|
||||
bits=bits,
|
||||
@ -642,32 +644,32 @@ class VoiceProtocol:
|
||||
except Exception as e:
|
||||
print(f"{RED}[VOICE]{RESET} Decryption failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _add_fec(self, data: bytes) -> bytes:
|
||||
"""Add forward error correction."""
|
||||
# Simple repetition code (3x) for testing
|
||||
# In production: use convolutional code or LDPC
|
||||
fec_data = bytearray()
|
||||
|
||||
|
||||
for byte in data:
|
||||
# Repeat each byte 3 times
|
||||
fec_data.extend([byte, byte, byte])
|
||||
|
||||
|
||||
return bytes(fec_data)
|
||||
|
||||
|
||||
def _remove_fec(self, data: bytes) -> Optional[bytes]:
|
||||
"""Remove FEC and correct errors."""
|
||||
if len(data) % 3 != 0:
|
||||
return None
|
||||
|
||||
|
||||
corrected = bytearray()
|
||||
|
||||
|
||||
for i in range(0, len(data), 3):
|
||||
# Majority voting
|
||||
votes = [data[i], data[i+1], data[i+2]]
|
||||
byte_value = max(set(votes), key=votes.count)
|
||||
corrected.append(byte_value)
|
||||
|
||||
|
||||
return bytes(corrected)
|
||||
|
||||
|
||||
@ -676,7 +678,7 @@ if __name__ == "__main__":
|
||||
# Test Codec2 wrapper
|
||||
print(f"\n{BLUE}=== Testing Codec2 Wrapper ==={RESET}")
|
||||
codec = Codec2Wrapper(Codec2Mode.MODE_1200)
|
||||
|
||||
|
||||
# Generate test audio
|
||||
if HAS_NUMPY:
|
||||
t = np.linspace(0, 0.04, 320) # 40ms at 8kHz
|
||||
@ -687,26 +689,26 @@ if __name__ == "__main__":
|
||||
t = i * 0.04 / 320
|
||||
value = int(math.sin(2 * math.pi * 440 * t) * 16384)
|
||||
test_audio.append(value)
|
||||
|
||||
|
||||
# Encode
|
||||
frame = codec.encode(test_audio)
|
||||
print(f"Encoded frame: {len(frame.bits)} bytes")
|
||||
|
||||
|
||||
# Decode
|
||||
decoded = codec.decode(frame)
|
||||
print(f"Decoded audio: {len(decoded)} samples")
|
||||
|
||||
|
||||
# Test FSK modem
|
||||
print(f"\n{BLUE}=== Testing FSK Modem ==={RESET}")
|
||||
modem = FSKModem()
|
||||
|
||||
|
||||
# Test data
|
||||
test_data = b"Hello, secure voice!"
|
||||
|
||||
|
||||
# Modulate
|
||||
modulated = modem.modulate(test_data)
|
||||
print(f"Modulated: {len(modulated)} samples ({len(modulated)/8000:.2f}s)")
|
||||
|
||||
|
||||
# Demodulate
|
||||
demodulated, confidence = modem.demodulate(modulated)
|
||||
print(f"Demodulated: {demodulated}")
|
@ -2,6 +2,10 @@
|
||||
<a href="https://github.com/AlexisDanlos" target="_blank">Alexis DANLOS</a>
|
||||
{{end}}
|
||||
|
||||
{{define "ange"}}
|
||||
<a href="https://yw5n.com" target="_blank">Ange DUHAYON</a>
|
||||
{{end}}
|
||||
|
||||
{{define "bartosz"}}
|
||||
<a href="https://github.com/Bartoszkk" target="_blank">Bartosz MICHALAK</a>
|
||||
{{end}}
|
||||
|
Loading…
Reference in New Issue
Block a user