mirror of
https://github.com/torlando-tech/columba.git
synced 2025-12-22 05:37:07 +00:00
Merge main into feature/norway-preset-update
This commit is contained in:
@@ -279,6 +279,9 @@ dependencies {
|
||||
implementation(libs.cameraX.lifecycle)
|
||||
implementation(libs.cameraX.view)
|
||||
|
||||
// MessagePack - for LXMF stamp generation
|
||||
implementation("org.msgpack:msgpack-core:0.9.8")
|
||||
|
||||
// Testing
|
||||
testImplementation(libs.junit)
|
||||
testImplementation(libs.junit.jupiter)
|
||||
|
||||
290
app/src/main/java/com/lxmf/messenger/crypto/StampGenerator.kt
Normal file
290
app/src/main/java/com/lxmf/messenger/crypto/StampGenerator.kt
Normal file
@@ -0,0 +1,290 @@
|
||||
package com.lxmf.messenger.crypto
|
||||
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.yield
|
||||
import org.msgpack.core.MessagePack
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.math.BigInteger
|
||||
import java.security.MessageDigest
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Native Kotlin implementation of LXMF stamp generation.
|
||||
*
|
||||
* This replaces Python's multiprocessing-based stamp generation which fails on Android
|
||||
* due to lack of sem_open support and aggressive process killing by Android.
|
||||
*
|
||||
* The algorithm matches LXMF's LXStamper.py exactly:
|
||||
* 1. Generate workblock using HKDF expansion
|
||||
* 2. Find a stamp (random 32 bytes) where SHA256(workblock + stamp) has enough leading zeros
|
||||
*/
|
||||
@Singleton
|
||||
class StampGenerator @Inject constructor() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "StampGenerator"
|
||||
const val STAMP_SIZE = 32 // 256 bits / 8
|
||||
const val WORKBLOCK_EXPAND_ROUNDS = 3000
|
||||
const val WORKBLOCK_EXPAND_ROUNDS_PN = 1000
|
||||
const val HKDF_OUTPUT_LENGTH = 256
|
||||
private const val PROGRESS_LOG_INTERVAL = 8000
|
||||
}
|
||||
|
||||
data class StampResult(
|
||||
val stamp: ByteArray?,
|
||||
val value: Int,
|
||||
val rounds: Long
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
other as StampResult
|
||||
if (stamp != null) {
|
||||
if (other.stamp == null) return false
|
||||
if (!stamp.contentEquals(other.stamp)) return false
|
||||
} else if (other.stamp != null) return false
|
||||
if (value != other.value) return false
|
||||
if (rounds != other.rounds) return false
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = stamp?.contentHashCode() ?: 0
|
||||
result = 31 * result + value
|
||||
result = 31 * result + rounds.hashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a workblock for stamp validation.
|
||||
*
|
||||
* Matches Python's stamp_workblock():
|
||||
* ```python
|
||||
* for n in range(expand_rounds):
|
||||
* workblock += HKDF(length=256, derive_from=material,
|
||||
* salt=SHA256(material + msgpack(n)), context=None)
|
||||
* ```
|
||||
*/
|
||||
fun generateWorkblock(material: ByteArray, expandRounds: Int = WORKBLOCK_EXPAND_ROUNDS): ByteArray {
|
||||
val output = ByteArrayOutputStream(expandRounds * HKDF_OUTPUT_LENGTH)
|
||||
|
||||
for (n in 0 until expandRounds) {
|
||||
// salt = SHA256(material + msgpack(n))
|
||||
val msgpackN = packInt(n)
|
||||
val saltInput = material + msgpackN
|
||||
val salt = sha256(saltInput)
|
||||
|
||||
// HKDF expand
|
||||
val expanded = hkdfExpand(
|
||||
ikm = material,
|
||||
salt = salt,
|
||||
info = ByteArray(0), // context=None
|
||||
length = HKDF_OUTPUT_LENGTH
|
||||
)
|
||||
output.write(expanded)
|
||||
}
|
||||
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a valid stamp for the given workblock.
|
||||
*
|
||||
* Uses Kotlin coroutines for parallel stamp search across multiple CPU cores.
|
||||
*
|
||||
* @param workblock The pre-generated workblock
|
||||
* @param stampCost The required stamp cost (number of leading zero bits)
|
||||
* @return StampResult containing the stamp, its value, and total rounds tried
|
||||
*/
|
||||
suspend fun generateStamp(
|
||||
workblock: ByteArray,
|
||||
stampCost: Int
|
||||
): StampResult = coroutineScope {
|
||||
val startTime = System.currentTimeMillis()
|
||||
val numWorkers = Runtime.getRuntime().availableProcessors().coerceIn(1, 8)
|
||||
|
||||
Log.d(TAG, "Starting stamp generation with cost $stampCost using $numWorkers workers")
|
||||
|
||||
// Use atomic reference for thread-safe result sharing
|
||||
val found = AtomicReference<ByteArray?>(null)
|
||||
val roundCounters = LongArray(numWorkers)
|
||||
|
||||
val jobs = (0 until numWorkers).map { workerId ->
|
||||
async(Dispatchers.Default) {
|
||||
val localRandom = SecureRandom()
|
||||
var localRounds = 0L
|
||||
val stamp = ByteArray(STAMP_SIZE)
|
||||
|
||||
while (found.get() == null && isActive) {
|
||||
localRandom.nextBytes(stamp)
|
||||
localRounds++
|
||||
|
||||
if (isStampValid(stamp, stampCost, workblock)) {
|
||||
// Use compareAndSet to ensure only one worker sets the result
|
||||
found.compareAndSet(null, stamp.copyOf())
|
||||
break
|
||||
}
|
||||
|
||||
// Periodically yield to allow cancellation and log progress
|
||||
if (localRounds % 1000 == 0L) {
|
||||
yield()
|
||||
}
|
||||
|
||||
if (localRounds % PROGRESS_LOG_INTERVAL == 0L) {
|
||||
roundCounters[workerId] = localRounds
|
||||
val totalRounds = roundCounters.sum()
|
||||
val elapsed = (System.currentTimeMillis() - startTime) / 1000.0
|
||||
if (elapsed > 0) {
|
||||
val speed = (totalRounds / elapsed).toLong()
|
||||
Log.d(TAG, "Stamp generation running. $totalRounds rounds, $speed rounds/sec")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
roundCounters[workerId] = localRounds
|
||||
localRounds
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all workers to complete
|
||||
jobs.awaitAll()
|
||||
|
||||
val totalRounds = roundCounters.sum()
|
||||
val duration = (System.currentTimeMillis() - startTime) / 1000.0
|
||||
val speed = if (duration > 0) (totalRounds / duration).toLong() else 0
|
||||
|
||||
val resultStamp = found.get()
|
||||
val value = if (resultStamp != null) stampValue(workblock, resultStamp) else 0
|
||||
|
||||
Log.d(TAG, "Stamp generation complete: value=$value, rounds=$totalRounds, " +
|
||||
"duration=${String.format(java.util.Locale.US, "%.2f", duration)}s, speed=$speed rounds/sec")
|
||||
|
||||
StampResult(resultStamp, value, totalRounds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method that generates workblock and stamp in one call.
|
||||
*/
|
||||
suspend fun generateStampWithWorkblock(
|
||||
messageId: ByteArray,
|
||||
stampCost: Int,
|
||||
expandRounds: Int = WORKBLOCK_EXPAND_ROUNDS
|
||||
): StampResult {
|
||||
Log.d(TAG, "Generating workblock with $expandRounds rounds...")
|
||||
val workblockStart = System.currentTimeMillis()
|
||||
val workblock = generateWorkblock(messageId, expandRounds)
|
||||
val workblockTime = System.currentTimeMillis() - workblockStart
|
||||
Log.d(TAG, "Workblock generated in ${workblockTime}ms (${workblock.size} bytes)")
|
||||
|
||||
return generateStamp(workblock, stampCost)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a stamp is valid for the given cost and workblock.
|
||||
*
|
||||
* Matches Python's stamp_valid():
|
||||
* ```python
|
||||
* target = 0b1 << 256-target_cost
|
||||
* result = SHA256(workblock + stamp)
|
||||
* return int(result) <= target
|
||||
* ```
|
||||
*/
|
||||
fun isStampValid(stamp: ByteArray, targetCost: Int, workblock: ByteArray): Boolean {
|
||||
val target = BigInteger.ONE.shiftLeft(256 - targetCost)
|
||||
val hash = sha256(workblock + stamp)
|
||||
val result = BigInteger(1, hash) // Positive BigInteger from bytes
|
||||
return result <= target
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the value of a stamp (number of leading zero bits).
|
||||
*
|
||||
* Matches Python's stamp_value():
|
||||
* ```python
|
||||
* material = SHA256(workblock + stamp)
|
||||
* i = int(material)
|
||||
* value = 0
|
||||
* while ((i & (1 << (bits - 1))) == 0):
|
||||
* i = (i << 1)
|
||||
* value += 1
|
||||
* ```
|
||||
*/
|
||||
fun stampValue(workblock: ByteArray, stamp: ByteArray): Int {
|
||||
val hash = sha256(workblock + stamp)
|
||||
val bigInt = BigInteger(1, hash)
|
||||
|
||||
// Count leading zeros
|
||||
// 256 - bitLength gives the number of leading zeros
|
||||
return 256 - bigInt.bitLength()
|
||||
}
|
||||
|
||||
// ==================== Crypto Primitives ====================
|
||||
|
||||
/**
|
||||
* SHA-256 hash.
|
||||
*/
|
||||
fun sha256(data: ByteArray): ByteArray {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
return digest.digest(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* HKDF (HMAC-based Key Derivation Function) as per RFC 5869.
|
||||
*
|
||||
* This matches RNS.Cryptography.hkdf() which uses SHA-256.
|
||||
*/
|
||||
fun hkdfExpand(
|
||||
ikm: ByteArray,
|
||||
salt: ByteArray,
|
||||
info: ByteArray,
|
||||
length: Int
|
||||
): ByteArray {
|
||||
// Extract phase: PRK = HMAC-SHA256(salt, IKM)
|
||||
val prk = hmacSha256(salt, ikm)
|
||||
|
||||
// Expand phase
|
||||
val hashLen = 32 // SHA-256 output length
|
||||
val n = (length + hashLen - 1) / hashLen
|
||||
val output = ByteArrayOutputStream(length)
|
||||
|
||||
var t = ByteArray(0)
|
||||
for (i in 1..n) {
|
||||
val input = t + info + byteArrayOf(i.toByte())
|
||||
t = hmacSha256(prk, input)
|
||||
output.write(t, 0, minOf(t.size, length - output.size()))
|
||||
}
|
||||
|
||||
return output.toByteArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* HMAC-SHA256.
|
||||
*/
|
||||
private fun hmacSha256(key: ByteArray, data: ByteArray): ByteArray {
|
||||
val mac = Mac.getInstance("HmacSHA256")
|
||||
val secretKey = SecretKeySpec(key, "HmacSHA256")
|
||||
mac.init(secretKey)
|
||||
return mac.doFinal(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack an integer using MessagePack format.
|
||||
* Matches Python's msgpack.packb(n).
|
||||
*/
|
||||
fun packInt(n: Int): ByteArray {
|
||||
val packer = MessagePack.newDefaultBufferPacker()
|
||||
packer.packInt(n)
|
||||
return packer.toByteArray()
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import com.lxmf.messenger.IInitializationCallback
|
||||
import com.lxmf.messenger.IReadinessCallback
|
||||
import com.lxmf.messenger.IReticulumService
|
||||
import com.lxmf.messenger.IReticulumServiceCallback
|
||||
import com.lxmf.messenger.crypto.StampGenerator
|
||||
import com.lxmf.messenger.reticulum.rnode.KotlinRNodeBridge
|
||||
import com.lxmf.messenger.reticulum.rnode.RNodeErrorListener
|
||||
import com.lxmf.messenger.service.manager.BleCoordinator
|
||||
@@ -766,6 +767,15 @@ class ReticulumServiceBinder(
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to set alternative relay callback: ${e.message}", e)
|
||||
}
|
||||
|
||||
// Setup native stamp generator (bypasses Python multiprocessing issues)
|
||||
try {
|
||||
val stampGenerator = StampGenerator()
|
||||
wrapperManager.setStampGeneratorCallback(stampGenerator)
|
||||
Log.d(TAG, "Native Kotlin stamp generator registered")
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to set stamp generator callback: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun announceLxmfDestination() {
|
||||
|
||||
@@ -4,12 +4,14 @@ import android.content.Context
|
||||
import android.util.Log
|
||||
import com.chaquo.python.PyObject
|
||||
import com.chaquo.python.Python
|
||||
import com.lxmf.messenger.crypto.StampGenerator
|
||||
import com.lxmf.messenger.reticulum.ble.bridge.KotlinBLEBridge
|
||||
import com.lxmf.messenger.reticulum.bridge.KotlinReticulumBridge
|
||||
import com.lxmf.messenger.service.state.ServiceState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -28,6 +30,7 @@ import kotlinx.coroutines.withTimeout
|
||||
* IMPORTANT: Python's signal module requires the main thread for handler registration.
|
||||
* All initialization calls go through Dispatchers.Main.immediate.
|
||||
*/
|
||||
@Suppress("TooManyFunctions") // Manager class wrapping Python API methods
|
||||
class PythonWrapperManager(
|
||||
private val state: ServiceState,
|
||||
private val context: Context,
|
||||
@@ -304,6 +307,60 @@ class PythonWrapperManager(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set native Kotlin stamp generator callback.
|
||||
*
|
||||
* This bypasses Python multiprocessing-based stamp generation which fails on Android
|
||||
* due to lack of sem_open support and aggressive process killing by Android.
|
||||
*
|
||||
* The callback is invoked by Python's LXStamper when stamp generation is needed.
|
||||
*
|
||||
* @param stampGenerator The Kotlin StampGenerator instance to use
|
||||
*/
|
||||
// Holder for stamp generator instance - used by static callback method
|
||||
private var stampGeneratorInstance: StampGenerator? = null
|
||||
|
||||
fun setStampGeneratorCallback(stampGenerator: StampGenerator) {
|
||||
stampGeneratorInstance = stampGenerator
|
||||
withWrapper { wrapper ->
|
||||
try {
|
||||
// Pass static method reference to avoid lambda type erasure issues with R8
|
||||
wrapper.callAttr("set_stamp_generator_callback", ::generateStampForPython)
|
||||
Log.d(TAG, "Native stamp generator callback registered with Python")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to set stamp generator callback: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Static-like method for Python to call for stamp generation.
|
||||
* Returns PyObject (Python tuple) to avoid Chaquopy type conversion issues.
|
||||
*/
|
||||
fun generateStampForPython(workblock: ByteArray, stampCost: Int): PyObject {
|
||||
Log.d(TAG, "Stamp generator callback invoked: cost=$stampCost, workblock=${workblock.size} bytes")
|
||||
|
||||
val generator = checkNotNull(stampGeneratorInstance) { "StampGenerator not initialized" }
|
||||
|
||||
// Python expects synchronous return from this callback
|
||||
val result = runBlocking(Dispatchers.Default) { // THREADING: allowed
|
||||
generator.generateStamp(workblock, stampCost)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Stamp generated: value=${result.value}, rounds=${result.rounds}")
|
||||
|
||||
// Create Python list with proper bytes conversion
|
||||
val py = Python.getInstance()
|
||||
val builtins = py.getBuiltins()
|
||||
val stamp = result.stamp ?: ByteArray(0)
|
||||
// Convert Java ByteArray to Python bytes for buffer protocol compatibility
|
||||
val pyBytes = builtins.callAttr("bytes", stamp)
|
||||
val pyList = builtins.callAttr("list")
|
||||
pyList.callAttr("append", pyBytes)
|
||||
pyList.callAttr("append", result.rounds)
|
||||
return pyList
|
||||
}
|
||||
|
||||
/**
|
||||
* Provide alternative relay to Python for message retry.
|
||||
* Called after finding an alternative relay via PropagationNodeManager.
|
||||
|
||||
@@ -126,9 +126,9 @@ fun ReviewConfigStep(viewModel: RNodeWizardViewModel) {
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
// In custom mode, skip showing region/modem/slot summary cards
|
||||
// since user is configuring everything manually
|
||||
if (!state.isCustomMode) {
|
||||
// In custom mode or when using a popular preset, skip showing region/modem/slot summary cards
|
||||
// since user is either configuring manually or using preset values
|
||||
if (!state.isCustomMode && state.selectedPreset == null) {
|
||||
// Frequency region summary
|
||||
state.selectedFrequencyRegion?.let { region ->
|
||||
Card(
|
||||
|
||||
@@ -381,6 +381,12 @@ class RNodeWizardViewModel
|
||||
// Also expand advanced settings by default
|
||||
_state.update { it.copy(showAdvancedSettings = true) }
|
||||
WizardStep.REVIEW_CONFIGURE
|
||||
} else if (currentState.selectedPreset != null) {
|
||||
// Popular local preset: skip modem and slot, go straight to review
|
||||
// Preset already contains all modem/frequency settings
|
||||
// Also expand advanced settings so user can see the configured values
|
||||
_state.update { it.copy(showAdvancedSettings = true) }
|
||||
WizardStep.REVIEW_CONFIGURE
|
||||
} else {
|
||||
// Apply frequency region settings when moving to modem step
|
||||
applyFrequencyRegionSettings()
|
||||
@@ -413,8 +419,8 @@ class RNodeWizardViewModel
|
||||
WizardStep.MODEM_PRESET -> WizardStep.REGION_SELECTION
|
||||
WizardStep.FREQUENCY_SLOT -> WizardStep.MODEM_PRESET
|
||||
WizardStep.REVIEW_CONFIGURE ->
|
||||
if (currentState.isCustomMode) {
|
||||
// Custom mode: go back to region selection (skipping modem and slot)
|
||||
if (currentState.isCustomMode || currentState.selectedPreset != null) {
|
||||
// Custom mode or preset: go back to region selection (skipping modem and slot)
|
||||
WizardStep.REGION_SELECTION
|
||||
} else {
|
||||
WizardStep.FREQUENCY_SLOT
|
||||
@@ -439,7 +445,7 @@ class RNodeWizardViewModel
|
||||
)
|
||||
}
|
||||
WizardStep.REGION_SELECTION ->
|
||||
state.selectedFrequencyRegion != null || state.isCustomMode
|
||||
state.selectedFrequencyRegion != null || state.isCustomMode || state.selectedPreset != null
|
||||
WizardStep.MODEM_PRESET ->
|
||||
true // Default preset is pre-selected, user can always proceed
|
||||
WizardStep.FREQUENCY_SLOT ->
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
package com.lxmf.messenger.crypto
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for StampGenerator.
|
||||
*
|
||||
* Test vectors are generated from Python LXMF implementation to ensure
|
||||
* byte-for-byte compatibility.
|
||||
*/
|
||||
class StampGeneratorTest {
|
||||
|
||||
private lateinit var stampGenerator: StampGenerator
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
stampGenerator = StampGenerator()
|
||||
}
|
||||
|
||||
// ==================== MessagePack Tests ====================
|
||||
|
||||
@Test
|
||||
fun `packInt encodes 0 correctly`() {
|
||||
val result = stampGenerator.packInt(0)
|
||||
assertArrayEquals(hexToBytes("00"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `packInt encodes 1 correctly`() {
|
||||
val result = stampGenerator.packInt(1)
|
||||
assertArrayEquals(hexToBytes("01"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `packInt encodes 127 correctly`() {
|
||||
val result = stampGenerator.packInt(127)
|
||||
assertArrayEquals(hexToBytes("7f"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `packInt encodes 128 correctly`() {
|
||||
val result = stampGenerator.packInt(128)
|
||||
assertArrayEquals(hexToBytes("cc80"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `packInt encodes 255 correctly`() {
|
||||
val result = stampGenerator.packInt(255)
|
||||
assertArrayEquals(hexToBytes("ccff"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `packInt encodes 256 correctly`() {
|
||||
val result = stampGenerator.packInt(256)
|
||||
assertArrayEquals(hexToBytes("cd0100"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `packInt encodes 1000 correctly`() {
|
||||
val result = stampGenerator.packInt(1000)
|
||||
assertArrayEquals(hexToBytes("cd03e8"), result)
|
||||
}
|
||||
|
||||
// ==================== SHA256 Tests ====================
|
||||
|
||||
@Test
|
||||
fun `sha256 produces correct hash`() {
|
||||
val input = "test data".toByteArray()
|
||||
val expected = hexToBytes("916f0027a575074ce72a331777c3478d6513f786a591bd892da1a577bf2335f9")
|
||||
val result = stampGenerator.sha256(input)
|
||||
assertArrayEquals(expected, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sha256 produces 32 byte output`() {
|
||||
val result = stampGenerator.sha256("any input".toByteArray())
|
||||
assertEquals(32, result.size)
|
||||
}
|
||||
|
||||
// ==================== HKDF Tests ====================
|
||||
|
||||
@Test
|
||||
fun `hkdfExpand matches RFC 5869 style test`() {
|
||||
val ikm = hexToBytes("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b")
|
||||
val salt = hexToBytes("000102030405060708090a0b0c")
|
||||
val info = ByteArray(0)
|
||||
val expected = hexToBytes("b2a3d45126d31fb6828ef00d76c6d54e9c2bd4785e49c6ad86e327d89d0de9408eeda1cbef2b03f30e05")
|
||||
|
||||
val result = stampGenerator.hkdfExpand(ikm, salt, info, 42)
|
||||
|
||||
assertArrayEquals(expected, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hkdfExpand produces correct length output`() {
|
||||
val ikm = ByteArray(32) { it.toByte() }
|
||||
val salt = ByteArray(16) { it.toByte() }
|
||||
val info = ByteArray(0)
|
||||
|
||||
for (length in listOf(16, 32, 64, 128, 256)) {
|
||||
val result = stampGenerator.hkdfExpand(ikm, salt, info, length)
|
||||
assertEquals(length, result.size)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Workblock Tests ====================
|
||||
|
||||
@Test
|
||||
fun `generateWorkblock produces correct size`() {
|
||||
val material = hexToBytes("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
|
||||
val expandRounds = 2
|
||||
|
||||
val workblock = stampGenerator.generateWorkblock(material, expandRounds)
|
||||
|
||||
// Each round produces 256 bytes
|
||||
assertEquals(expandRounds * 256, workblock.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generateWorkblock matches Python output`() {
|
||||
val material = hexToBytes("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
|
||||
val expandRounds = 2
|
||||
|
||||
// Expected first 64 bytes from Python test
|
||||
val expectedFirst64 = hexToBytes(
|
||||
"6b4e93e1358f5b1865f30c2e4c4d3e3e5585bc73c4f3bac53c5418f882791463" +
|
||||
"8980973daa9d75be40e2e50adc12987364ee078e492fa424c3980cc51579b83b"
|
||||
)
|
||||
|
||||
val workblock = stampGenerator.generateWorkblock(material, expandRounds)
|
||||
|
||||
assertArrayEquals(expectedFirst64, workblock.copyOfRange(0, 64))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generateWorkblock is deterministic`() {
|
||||
val material = hexToBytes("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
||||
val expandRounds = 3
|
||||
|
||||
val workblock1 = stampGenerator.generateWorkblock(material, expandRounds)
|
||||
val workblock2 = stampGenerator.generateWorkblock(material, expandRounds)
|
||||
|
||||
assertArrayEquals(workblock1, workblock2)
|
||||
}
|
||||
|
||||
// ==================== Stamp Validation Tests ====================
|
||||
|
||||
@Test
|
||||
fun `isStampValid returns true for valid stamp`() {
|
||||
val material = hexToBytes("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
|
||||
val workblock = stampGenerator.generateWorkblock(material, 2)
|
||||
|
||||
// Valid stamp from Python test (cost 8, actually has 11 leading zeros)
|
||||
val validStamp = hexToBytes("52c8508b7f8dfdd984e110d489e3c5535c0583005f1ebb08f63ca7c36c6c5882")
|
||||
val stampCost = 8
|
||||
|
||||
assertTrue(stampGenerator.isStampValid(validStamp, stampCost, workblock))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isStampValid returns false for invalid stamp`() {
|
||||
val material = hexToBytes("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
|
||||
val workblock = stampGenerator.generateWorkblock(material, 2)
|
||||
|
||||
// Invalid stamp (all 0xff - very unlikely to be valid)
|
||||
val invalidStamp = hexToBytes("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff")
|
||||
val stampCost = 8
|
||||
|
||||
assertFalse(stampGenerator.isStampValid(invalidStamp, stampCost, workblock))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isStampValid respects stamp cost`() {
|
||||
val material = hexToBytes("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
|
||||
val workblock = stampGenerator.generateWorkblock(material, 2)
|
||||
|
||||
// This stamp has 11 leading zeros
|
||||
val stamp = hexToBytes("52c8508b7f8dfdd984e110d489e3c5535c0583005f1ebb08f63ca7c36c6c5882")
|
||||
|
||||
// Should be valid for cost <= 11
|
||||
assertTrue(stampGenerator.isStampValid(stamp, 8, workblock))
|
||||
assertTrue(stampGenerator.isStampValid(stamp, 10, workblock))
|
||||
assertTrue(stampGenerator.isStampValid(stamp, 11, workblock))
|
||||
|
||||
// Should be invalid for cost > 11
|
||||
assertFalse(stampGenerator.isStampValid(stamp, 12, workblock))
|
||||
assertFalse(stampGenerator.isStampValid(stamp, 16, workblock))
|
||||
}
|
||||
|
||||
// ==================== Stamp Value Tests ====================
|
||||
|
||||
@Test
|
||||
fun `stampValue returns correct leading zeros count`() {
|
||||
val material = hexToBytes("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
|
||||
val workblock = stampGenerator.generateWorkblock(material, 2)
|
||||
|
||||
// This stamp has hash starting with 001c... = 11 leading zeros
|
||||
val stamp = hexToBytes("52c8508b7f8dfdd984e110d489e3c5535c0583005f1ebb08f63ca7c36c6c5882")
|
||||
|
||||
val value = stampGenerator.stampValue(workblock, stamp)
|
||||
|
||||
assertEquals(11, value)
|
||||
}
|
||||
|
||||
// ==================== Stamp Generation Tests ====================
|
||||
|
||||
@Test
|
||||
fun `generateStamp produces valid stamp`() = runTest {
|
||||
// Use small workblock for fast test
|
||||
val material = hexToBytes("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
||||
val workblock = stampGenerator.generateWorkblock(material, 2)
|
||||
val stampCost = 8 // Relatively easy to find
|
||||
|
||||
val result = stampGenerator.generateStamp(workblock, stampCost)
|
||||
|
||||
assertNotNull(result.stamp)
|
||||
assertTrue(stampGenerator.isStampValid(result.stamp!!, stampCost, workblock))
|
||||
assertTrue(result.value >= stampCost)
|
||||
assertTrue(result.rounds > 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generateStampWithWorkblock produces valid stamp`() = runTest {
|
||||
val messageId = hexToBytes("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
||||
val stampCost = 8
|
||||
val expandRounds = 2 // Small for fast test
|
||||
|
||||
val result = stampGenerator.generateStampWithWorkblock(messageId, stampCost, expandRounds)
|
||||
|
||||
assertNotNull(result.stamp)
|
||||
|
||||
// Verify the stamp is valid against regenerated workblock
|
||||
val workblock = stampGenerator.generateWorkblock(messageId, expandRounds)
|
||||
assertTrue(stampGenerator.isStampValid(result.stamp!!, stampCost, workblock))
|
||||
}
|
||||
|
||||
// ==================== StampResult Tests ====================
|
||||
|
||||
@Test
|
||||
fun `StampResult equals returns true for identical stamps`() {
|
||||
val stamp = hexToBytes("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
||||
val result1 = StampGenerator.StampResult(stamp, 10, 1000L)
|
||||
val result2 = StampGenerator.StampResult(stamp.copyOf(), 10, 1000L)
|
||||
|
||||
assertEquals(result1, result2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `StampResult equals returns false for different stamps`() {
|
||||
val stamp1 = hexToBytes("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
||||
val stamp2 = hexToBytes("fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210")
|
||||
val result1 = StampGenerator.StampResult(stamp1, 10, 1000L)
|
||||
val result2 = StampGenerator.StampResult(stamp2, 10, 1000L)
|
||||
|
||||
assertFalse(result1 == result2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `StampResult equals returns false for different values`() {
|
||||
val stamp = hexToBytes("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
||||
val result1 = StampGenerator.StampResult(stamp, 10, 1000L)
|
||||
val result2 = StampGenerator.StampResult(stamp.copyOf(), 11, 1000L)
|
||||
|
||||
assertFalse(result1 == result2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `StampResult equals returns false for different rounds`() {
|
||||
val stamp = hexToBytes("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
||||
val result1 = StampGenerator.StampResult(stamp, 10, 1000L)
|
||||
val result2 = StampGenerator.StampResult(stamp.copyOf(), 10, 2000L)
|
||||
|
||||
assertFalse(result1 == result2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `StampResult equals handles null stamps`() {
|
||||
val result1 = StampGenerator.StampResult(null, 0, 0L)
|
||||
val result2 = StampGenerator.StampResult(null, 0, 0L)
|
||||
val stamp = hexToBytes("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
||||
val result3 = StampGenerator.StampResult(stamp, 0, 0L)
|
||||
|
||||
assertEquals(result1, result2)
|
||||
assertFalse(result1 == result3)
|
||||
assertFalse(result3 == result1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `StampResult equals returns false for non-StampResult`() {
|
||||
val stamp = hexToBytes("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
||||
val result = StampGenerator.StampResult(stamp, 10, 1000L)
|
||||
|
||||
assertFalse(result.equals("not a StampResult"))
|
||||
assertFalse(result == null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `StampResult equals reflexive`() {
|
||||
val stamp = hexToBytes("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
||||
val result = StampGenerator.StampResult(stamp, 10, 1000L)
|
||||
|
||||
assertTrue(result == result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `StampResult hashCode consistent for equal objects`() {
|
||||
val stamp = hexToBytes("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
||||
val result1 = StampGenerator.StampResult(stamp, 10, 1000L)
|
||||
val result2 = StampGenerator.StampResult(stamp.copyOf(), 10, 1000L)
|
||||
|
||||
assertEquals(result1.hashCode(), result2.hashCode())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `StampResult hashCode handles null stamp`() {
|
||||
val result = StampGenerator.StampResult(null, 0, 0L)
|
||||
|
||||
// Should not throw and should return consistent value
|
||||
val hash1 = result.hashCode()
|
||||
val hash2 = result.hashCode()
|
||||
assertEquals(hash1, hash2)
|
||||
}
|
||||
|
||||
// ==================== Edge Case Tests ====================
|
||||
|
||||
@Test
|
||||
fun `stampValue returns 0 for worst case stamp`() {
|
||||
val material = hexToBytes("deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
|
||||
val workblock = stampGenerator.generateWorkblock(material, 2)
|
||||
|
||||
// A stamp that produces a hash with high first byte
|
||||
// This tests the edge case where stampValue returns low value
|
||||
val badStamp = hexToBytes("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff")
|
||||
val value = stampGenerator.stampValue(workblock, badStamp)
|
||||
|
||||
// Should return a small value (likely 0-4)
|
||||
assertTrue(value < 10)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generateStamp with very low cost finds stamp quickly`() = runTest {
|
||||
val material = hexToBytes("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
||||
val workblock = stampGenerator.generateWorkblock(material, 1) // Minimal workblock
|
||||
val stampCost = 1 // Very easy
|
||||
|
||||
val result = stampGenerator.generateStamp(workblock, stampCost)
|
||||
|
||||
assertNotNull(result.stamp)
|
||||
assertTrue(result.value >= stampCost)
|
||||
// Should find in very few rounds with cost 1
|
||||
assertTrue(result.rounds < 100)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sha256 empty input`() {
|
||||
val expected = hexToBytes("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855")
|
||||
val result = stampGenerator.sha256(ByteArray(0))
|
||||
assertArrayEquals(expected, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hkdfExpand with empty IKM`() {
|
||||
val result = stampGenerator.hkdfExpand(
|
||||
ikm = ByteArray(0),
|
||||
salt = ByteArray(16),
|
||||
info = ByteArray(0),
|
||||
length = 32
|
||||
)
|
||||
assertEquals(32, result.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `packInt encodes max positive fixint correctly`() {
|
||||
// 0x7f is the max value for positive fixint encoding
|
||||
val result = stampGenerator.packInt(0x7f)
|
||||
assertArrayEquals(hexToBytes("7f"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `packInt encodes 2999 correctly for workblock rounds`() {
|
||||
// WORKBLOCK_EXPAND_ROUNDS - 1 = 2999
|
||||
val result = stampGenerator.packInt(2999)
|
||||
assertArrayEquals(hexToBytes("cd0bb7"), result)
|
||||
}
|
||||
|
||||
// ==================== Helper Functions ====================
|
||||
|
||||
private fun hexToBytes(hex: String): ByteArray {
|
||||
val len = hex.length
|
||||
val data = ByteArray(len / 2)
|
||||
for (i in 0 until len step 2) {
|
||||
data[i / 2] = ((Character.digit(hex[i], 16) shl 4) +
|
||||
Character.digit(hex[i + 1], 16)).toByte()
|
||||
}
|
||||
return data
|
||||
}
|
||||
}
|
||||
@@ -184,7 +184,7 @@ class RNodeRegionalPresetsTest {
|
||||
|
||||
@Test
|
||||
fun `all presets have valid bandwidth`() {
|
||||
val validBandwidths = setOf(125_000, 250_000, 500_000, 812_500)
|
||||
val validBandwidths = setOf(31_250, 41_670, 62_500, 125_000, 250_000, 500_000, 812_500)
|
||||
RNodeRegionalPresets.presets.forEach { preset ->
|
||||
assertTrue(
|
||||
"Preset ${preset.id} should have valid bandwidth, got ${preset.bandwidth}",
|
||||
|
||||
@@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -176,7 +177,7 @@ class MessagingViewModelTest {
|
||||
|
||||
@Test
|
||||
fun `sendMessage success saves message to database with sent status`() =
|
||||
runTest {
|
||||
runTest(UnconfinedTestDispatcher()) {
|
||||
val viewModel = createTestViewModel()
|
||||
advanceUntilIdle()
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import com.lxmf.messenger.data.model.BluetoothType
|
||||
import com.lxmf.messenger.data.model.DiscoveredRNode
|
||||
import com.lxmf.messenger.data.model.FrequencyRegions
|
||||
import com.lxmf.messenger.data.model.ModemPreset
|
||||
import com.lxmf.messenger.data.model.RNodeRegionalPreset
|
||||
import com.lxmf.messenger.repository.InterfaceRepository
|
||||
import com.lxmf.messenger.reticulum.model.InterfaceConfig
|
||||
import io.mockk.coEvery
|
||||
@@ -256,6 +257,89 @@ class RNodeWizardViewModelTest {
|
||||
assertTrue(viewModel.canProceed())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `canProceed true when popular preset selected on REGION_SELECTION`() =
|
||||
runTest {
|
||||
viewModel.goToStep(WizardStep.REGION_SELECTION)
|
||||
advanceUntilIdle()
|
||||
|
||||
val testPreset = RNodeRegionalPreset(
|
||||
id = "test_preset",
|
||||
countryCode = "US",
|
||||
countryName = "United States",
|
||||
cityOrRegion = "Test City",
|
||||
frequency = 915_000_000,
|
||||
bandwidth = 125_000,
|
||||
spreadingFactor = 9,
|
||||
codingRate = 5,
|
||||
txPower = 17,
|
||||
description = "Test preset",
|
||||
)
|
||||
viewModel.selectPreset(testPreset)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(viewModel.canProceed())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `goToNextStep skips to REVIEW_CONFIGURE when preset selected`() =
|
||||
runTest {
|
||||
viewModel.goToStep(WizardStep.REGION_SELECTION)
|
||||
advanceUntilIdle()
|
||||
|
||||
val testPreset = RNodeRegionalPreset(
|
||||
id = "test_preset",
|
||||
countryCode = "US",
|
||||
countryName = "United States",
|
||||
cityOrRegion = "Test City",
|
||||
frequency = 915_000_000,
|
||||
bandwidth = 125_000,
|
||||
spreadingFactor = 9,
|
||||
codingRate = 5,
|
||||
txPower = 17,
|
||||
description = "Test preset",
|
||||
)
|
||||
viewModel.selectPreset(testPreset)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.goToNextStep()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(WizardStep.REVIEW_CONFIGURE, viewModel.state.value.currentStep)
|
||||
assertTrue(viewModel.state.value.showAdvancedSettings)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `goToPreviousStep returns to REGION_SELECTION from REVIEW when preset selected`() =
|
||||
runTest {
|
||||
viewModel.goToStep(WizardStep.REGION_SELECTION)
|
||||
advanceUntilIdle()
|
||||
|
||||
val testPreset = RNodeRegionalPreset(
|
||||
id = "test_preset",
|
||||
countryCode = "US",
|
||||
countryName = "United States",
|
||||
cityOrRegion = "Test City",
|
||||
frequency = 915_000_000,
|
||||
bandwidth = 125_000,
|
||||
spreadingFactor = 9,
|
||||
codingRate = 5,
|
||||
txPower = 17,
|
||||
description = "Test preset",
|
||||
)
|
||||
viewModel.selectPreset(testPreset)
|
||||
advanceUntilIdle()
|
||||
|
||||
viewModel.goToNextStep()
|
||||
advanceUntilIdle()
|
||||
assertEquals(WizardStep.REVIEW_CONFIGURE, viewModel.state.value.currentStep)
|
||||
|
||||
viewModel.goToPreviousStep()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(WizardStep.REGION_SELECTION, viewModel.state.value.currentStep)
|
||||
}
|
||||
|
||||
// ========== Region Selection Tests ==========
|
||||
|
||||
@Test
|
||||
|
||||
@@ -72,14 +72,17 @@ EXCLUDE_DIRS="*/build/* */test/* */androidTest/*"
|
||||
section "1. Checking for runBlocking in Production Code"
|
||||
|
||||
# Check for runBlocking (should be 0 in production code after Phase 1)
|
||||
# Exclude comments by filtering out lines with // before runBlocking
|
||||
# Allow exceptions marked with "// THREADING: allowed" inline comment
|
||||
# Ignore import statements and pure comment lines
|
||||
RUNBLOCKING_MATCHES=$(find $APP_SRC $RETICULUM_SRC $DATA_SRC -name "*.kt" 2>/dev/null | \
|
||||
grep -v -E "(test|Test|build)" | \
|
||||
xargs grep -n "runBlocking" 2>/dev/null | \
|
||||
grep -v "//" || true)
|
||||
grep -v "^[^:]*:.*import " | \
|
||||
grep -v "^[^:]*:[0-9]*:[[:space:]]*//" | \
|
||||
grep -v "THREADING: allowed" || true)
|
||||
|
||||
if [ -z "$RUNBLOCKING_MATCHES" ]; then
|
||||
success "No runBlocking found in production code"
|
||||
success "No runBlocking found in production code (or all instances are allowed)"
|
||||
else
|
||||
while IFS= read -r line; do
|
||||
violation "runBlocking found: $line"
|
||||
|
||||
@@ -69,14 +69,17 @@ DATA_SRC="data/src/main/java"
|
||||
section "1. Checking for runBlocking in Production Code"
|
||||
|
||||
# Check for runBlocking (should be 0 in production code after Phase 1)
|
||||
# Exclude comments by filtering out lines with // before runBlocking
|
||||
# Allow exceptions marked with "// THREADING: allowed" inline comment
|
||||
# Ignore import statements and pure comment lines
|
||||
RUNBLOCKING_MATCHES=$(find $APP_SRC $RETICULUM_SRC $DATA_SRC -name "*.kt" 2>/dev/null | \
|
||||
grep -v -E "(test|Test|build)" | \
|
||||
xargs grep -n "runBlocking" 2>/dev/null | \
|
||||
grep -v "//" || true)
|
||||
grep -v "^[^:]*:.*import " | \
|
||||
grep -v "^[^:]*:[0-9]*:[[:space:]]*//" | \
|
||||
grep -v "THREADING: allowed" || true)
|
||||
|
||||
if [ -z "$RUNBLOCKING_MATCHES" ]; then
|
||||
success "No runBlocking found in production code"
|
||||
success "No runBlocking found in production code (or all instances are allowed)"
|
||||
else
|
||||
while IFS= read -r line; do
|
||||
violation "runBlocking found: $line"
|
||||
|
||||
@@ -5,5 +5,8 @@
|
||||
# Patch: catch exceptions in __update_phy_stats() to prevent crashes when
|
||||
# connecting to another app's shared instance with mismatched RPC keys
|
||||
rns @ git+https://github.com/torlando-tech/Reticulum@fix-phy-stats-rpc
|
||||
lxmf>=0.8.0
|
||||
|
||||
# LXMF from fork with external stamp generator support
|
||||
# Patch: adds set_external_generator() to bypass Python multiprocessing on Android
|
||||
lxmf @ git+https://github.com/torlando-tech/LXMF@feature/external-stamp-generator
|
||||
cryptography>=42.0.0
|
||||
|
||||
@@ -137,6 +137,10 @@ class ReticulumWrapper:
|
||||
self._pending_relay_fallback_messages = {} # {msg_hash_hex: lxmf_message} - waiting for alternative
|
||||
self._max_relay_retries = 3 # Maximum number of alternative relays to try
|
||||
|
||||
# Native stamp generator callback (Kotlin)
|
||||
# Used to bypass Python multiprocessing issues on Android
|
||||
self.kotlin_stamp_generator_callback = None
|
||||
|
||||
# Set global instance so AndroidBLEDriver can access it
|
||||
_global_wrapper_instance = self
|
||||
|
||||
@@ -284,6 +288,30 @@ class ReticulumWrapper:
|
||||
log_info("ReticulumWrapper", "set_kotlin_request_alternative_relay_callback",
|
||||
"✅ Alternative relay callback registered (relay fallback enabled)")
|
||||
|
||||
def set_stamp_generator_callback(self, callback):
|
||||
"""
|
||||
Set native Kotlin callback for stamp generation.
|
||||
|
||||
This bypasses Python multiprocessing-based stamp generation which fails on Android
|
||||
due to lack of sem_open support and aggressive process killing by Android.
|
||||
|
||||
Callback signature: callback(workblock: bytes, stamp_cost: int) -> (stamp: bytes, rounds: int)
|
||||
|
||||
Args:
|
||||
callback: PyObject callable from Kotlin (passed via Chaquopy)
|
||||
"""
|
||||
self.kotlin_stamp_generator_callback = callback
|
||||
|
||||
# Register with LXMF's LXStamper module
|
||||
try:
|
||||
from LXMF import LXStamper
|
||||
LXStamper.set_external_generator(callback)
|
||||
log_info("ReticulumWrapper", "set_stamp_generator_callback",
|
||||
"✅ Native stamp generator registered with LXMF")
|
||||
except Exception as e:
|
||||
log_error("ReticulumWrapper", "set_stamp_generator_callback",
|
||||
f"Failed to register stamp generator: {e}")
|
||||
|
||||
def _clear_stale_ble_paths(self):
|
||||
"""
|
||||
Clear stale BLE paths from Transport.path_table on startup.
|
||||
|
||||
121
python/test_wrapper_stamp_generator.py
Normal file
121
python/test_wrapper_stamp_generator.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Test suite for ReticulumWrapper stamp generator callback functionality.
|
||||
|
||||
Tests the set_stamp_generator_callback method which allows native Kotlin
|
||||
stamp generation to bypass Python multiprocessing issues on Android.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import Mock, MagicMock, patch
|
||||
|
||||
# Add parent directory to path to import reticulum_wrapper
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Mock RNS and LXMF before importing reticulum_wrapper
|
||||
sys.modules['RNS'] = MagicMock()
|
||||
sys.modules['RNS.vendor'] = MagicMock()
|
||||
sys.modules['RNS.vendor.platformutils'] = MagicMock()
|
||||
sys.modules['LXMF'] = MagicMock()
|
||||
|
||||
# Now import after mocking
|
||||
import reticulum_wrapper
|
||||
|
||||
|
||||
class TestSetStampGeneratorCallback(unittest.TestCase):
|
||||
"""Test set_stamp_generator_callback method"""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures"""
|
||||
import tempfile
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
|
||||
|
||||
# Enable Reticulum
|
||||
reticulum_wrapper.RETICULUM_AVAILABLE = True
|
||||
self.wrapper.initialized = True
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test fixtures"""
|
||||
import shutil
|
||||
if os.path.exists(self.temp_dir):
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
def test_set_stamp_generator_callback_stores_callback(self):
|
||||
"""Test that callback is stored in instance variable"""
|
||||
mock_callback = Mock()
|
||||
|
||||
self.wrapper.set_stamp_generator_callback(mock_callback)
|
||||
|
||||
self.assertEqual(self.wrapper.kotlin_stamp_generator_callback, mock_callback)
|
||||
|
||||
def test_set_stamp_generator_callback_registers_with_lxstamper(self):
|
||||
"""Test that callback is registered with LXMF LXStamper"""
|
||||
mock_callback = Mock()
|
||||
|
||||
# Get the mocked LXMF module
|
||||
mock_lxmf = sys.modules['LXMF']
|
||||
mock_lxstamper = MagicMock()
|
||||
mock_lxmf.LXStamper = mock_lxstamper
|
||||
|
||||
self.wrapper.set_stamp_generator_callback(mock_callback)
|
||||
|
||||
# Verify LXStamper.set_external_generator was called with our callback
|
||||
mock_lxstamper.set_external_generator.assert_called_once_with(mock_callback)
|
||||
|
||||
def test_set_stamp_generator_callback_handles_import_error(self):
|
||||
"""Test graceful handling when LXMF import fails"""
|
||||
mock_callback = Mock()
|
||||
|
||||
# Make LXMF import raise an exception
|
||||
with patch.dict(sys.modules, {'LXMF': None}):
|
||||
# This should not raise, just log the error
|
||||
try:
|
||||
self.wrapper.set_stamp_generator_callback(mock_callback)
|
||||
except Exception:
|
||||
self.fail("set_stamp_generator_callback raised exception on import error")
|
||||
|
||||
# Callback should still be stored locally
|
||||
self.assertEqual(self.wrapper.kotlin_stamp_generator_callback, mock_callback)
|
||||
|
||||
def test_set_stamp_generator_callback_handles_registration_error(self):
|
||||
"""Test graceful handling when set_external_generator fails"""
|
||||
mock_callback = Mock()
|
||||
|
||||
# Get the mocked LXMF module and make set_external_generator raise
|
||||
mock_lxmf = sys.modules['LXMF']
|
||||
mock_lxstamper = MagicMock()
|
||||
mock_lxstamper.set_external_generator.side_effect = Exception("Registration failed")
|
||||
mock_lxmf.LXStamper = mock_lxstamper
|
||||
|
||||
# This should not raise, just log the error
|
||||
try:
|
||||
self.wrapper.set_stamp_generator_callback(mock_callback)
|
||||
except Exception:
|
||||
self.fail("set_stamp_generator_callback raised exception on registration error")
|
||||
|
||||
# Callback should still be stored locally
|
||||
self.assertEqual(self.wrapper.kotlin_stamp_generator_callback, mock_callback)
|
||||
|
||||
def test_set_stamp_generator_callback_with_none(self):
|
||||
"""Test setting callback to None (clearing)"""
|
||||
# First set a callback
|
||||
self.wrapper.kotlin_stamp_generator_callback = Mock()
|
||||
|
||||
# Get the mocked LXMF module
|
||||
mock_lxmf = sys.modules['LXMF']
|
||||
mock_lxstamper = MagicMock()
|
||||
mock_lxmf.LXStamper = mock_lxstamper
|
||||
|
||||
self.wrapper.set_stamp_generator_callback(None)
|
||||
|
||||
# Verify callback is cleared
|
||||
self.assertIsNone(self.wrapper.kotlin_stamp_generator_callback)
|
||||
|
||||
# Verify LXStamper was called with None
|
||||
mock_lxstamper.set_external_generator.assert_called_once_with(None)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user