Compare commits

..

16 Commits

Author SHA1 Message Date
10b44cdf72 add of changes
All checks were successful
/ mirror (push) Successful in 5s
2025-07-07 22:07:02 +01:00
4cc9e8b2d2 add of gsm settings
All checks were successful
/ mirror (push) Successful in 4s
2025-07-07 21:53:01 +01:00
1d5eae7d80 add of settings 2025-07-07 20:42:17 +01:00
d3d14919a8 add fix for samples
All checks were successful
/ mirror (push) Successful in 4s
2025-07-07 14:05:07 +01:00
8b6ba00d8c add of changes
All checks were successful
/ mirror (push) Successful in 4s
2025-07-07 12:00:29 +01:00
c4610fbcb9 add
All checks were successful
/ mirror (push) Successful in 4s
2025-07-07 11:03:22 +01:00
STCB
96553b27bd Merge remote-tracking branch 'origin/ProtcoleImplement' into ProtcoleImplement
All checks were successful
/ mirror (push) Successful in 4s
2025-07-07 00:02:39 +02:00
STCB
4832ba751f Cleaning a bit more 2025-07-07 00:00:05 +02:00
a6cd9632ee Cleaning a bit
All checks were successful
/ mirror (push) Successful in 5s
2025-07-06 23:36:41 +02:00
6b517f6a46 add playback
All checks were successful
/ mirror (push) Successful in 5s
2025-07-06 17:13:53 +01:00
a14084ce68 add
All checks were successful
/ mirror (push) Successful in 5s
2025-07-04 23:03:14 +01:00
5c274817df add
Some checks failed
/ mirror (push) Failing after 5s
/ build-stealth (push) Failing after 5m0s
/ build (push) Failing after 5m2s
2025-07-04 23:01:46 +01:00
8f81049822 add of drybox
Some checks failed
/ mirror (push) Failing after 3s
2025-07-04 22:57:36 +01:00
75f54dc90a add protocole into drybox
Some checks failed
/ mirror (push) Failing after 4s
2025-06-15 11:59:27 +01:00
0badc8862c feat: noise java lib | WIP NoiseHandler IK handshake
Some checks failed
/ mirror (push) Failing after 4s
/ build-stealth (push) Successful in 10m8s
/ build (push) Successful in 10m12s
2025-06-14 11:29:22 +03:00
9ef3ad5b56 feat: ED25519 keypair instead of P256
Some checks failed
/ build (push) Successful in 10m0s
/ build-stealth (push) Successful in 10m3s
/ mirror (push) Failing after 5s
2025-06-10 16:25:31 +03:00
59 changed files with 6613 additions and 2116 deletions

View File

@ -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 [![Give Feedback](https://img.shields.io/badge/Feedback-Form-blue)](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

View File

@ -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'
}

Binary file not shown.

View File

@ -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.

View File

@ -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"

View File

@ -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();
}
}
}
);
}
}

View File

@ -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
}
}

View File

@ -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)) {

View File

@ -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()
}
}

View File

@ -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
}

View File

@ -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
}
}
}

View File

@ -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
}
}

View File

@ -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}")
}
}
}

View File

@ -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()
}
}

View File

@ -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
}
}

View File

@ -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")

View File

@ -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,

View File

@ -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.

View File

@ -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

View File

@ -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();
}
}
}
}
}

View File

@ -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('');
}
}

View File

@ -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,
),

View File

@ -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(

View File

@ -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(

View File

@ -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(

View File

@ -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('');
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 phones 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 contacts 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 dialers 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

View File

@ -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.

View 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

View File

@ -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

View 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)

View 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;")

View File

@ -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__":

View File

@ -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")

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"]

View File

@ -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."

View File

@ -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é&lt;div&gt;-&lt;/div&gt;&lt;div&gt;Passage en clair&lt;/div&gt;" 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&lt;span style=&quot;background-color: transparent; color: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));&quot;&gt;&amp;nbsp;handshake a Bob&lt;/span&gt;" 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&#39;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&#39;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&#39;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&lt;span style=&quot;background-color: transparent; color: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));&quot;&gt;&amp;nbsp;?&lt;/span&gt;" 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&#39;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&#39;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={&quot;curved&quot;:0,&quot;rounded&quot;: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={&quot;curved&quot;:0,&quot;rounded&quot;: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="&lt;div&gt;sha256 (&lt;/div&gt;&lt;div&gt;numéro alice +&lt;/div&gt;&lt;div&gt;numéro bob +&lt;/div&gt;&lt;div&gt;timestamp +&lt;/div&gt;&lt;div&gt;random&lt;br&gt;&lt;/div&gt;&lt;div&gt;) / ~2 (left part)&lt;br&gt;&lt;/div&gt;" 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="&lt;div&gt;YES&lt;/div&gt;&lt;div&gt;NO&lt;br&gt;&lt;/div&gt;" 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&lt;div&gt;(+Z)&lt;/div&gt;" 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="&lt;font style=&quot;color: light-dark(rgb(0, 0, 0), rgb(255, 255, 255));&quot;&gt;White = additional data&lt;/font&gt;" 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>

View File

@ -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

View 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}")

View 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)

View 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)

View 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)

View 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

File diff suppressed because it is too large Load Diff

View 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

View File

@ -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}")

View File

@ -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}}