Merge main into feature/norway-preset-update

This commit is contained in:
torlando-tech
2025-12-16 20:40:18 -05:00
15 changed files with 1028 additions and 15 deletions

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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