Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ android {

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

buildConfigField("String", "DEFAULT_RELAY_URL", "\"https://relay.ashprotocol.app\"")
buildConfigField("String", "DEFAULT_RELAY_URL", "\"https://eu.relay.ashprotocol.app\"")
}

signingConfigs {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,23 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import javax.inject.Inject
import javax.inject.Singleton

/**
* Pad storage data matching iOS PadStorageData structure.
* Stores pad bytes together with consumption state for atomic persistence.
*/
@Serializable
data class PadStorageData(
val bytes: String, // Base64-encoded pad bytes
val consumedFront: Long,
val consumedBack: Long
)

@Singleton
class ConversationStorageService @Inject constructor(
@ApplicationContext private val context: Context
Expand Down Expand Up @@ -76,20 +88,83 @@ class ConversationStorageService @Inject constructor(
}
}

// Store pad bytes separately for security
// === Pad Storage (matching iOS PadStorageData) ===
// Pad bytes and consumption state are stored together atomically

/**
* Save pad with initial state (after ceremony).
* Matching iOS: PadManager.storePad
*/
suspend fun savePadBytes(conversationId: String, padBytes: ByteArray) = withContext(Dispatchers.IO) {
val encoded = android.util.Base64.encodeToString(padBytes, android.util.Base64.NO_WRAP)
val storageData = PadStorageData(
bytes = android.util.Base64.encodeToString(padBytes, android.util.Base64.NO_WRAP),
consumedFront = 0,
consumedBack = 0
)
val serialized = json.encodeToString(storageData)
encryptedPrefs.edit()
.putString("pad_$conversationId", serialized)
.apply()
}

/**
* Save pad with current consumption state.
* Matching iOS: PadManager.savePadState
*/
suspend fun savePadState(
conversationId: String,
padBytes: ByteArray,
consumedFront: Long,
consumedBack: Long
) = withContext(Dispatchers.IO) {
val storageData = PadStorageData(
bytes = android.util.Base64.encodeToString(padBytes, android.util.Base64.NO_WRAP),
consumedFront = consumedFront,
consumedBack = consumedBack
)
val serialized = json.encodeToString(storageData)
encryptedPrefs.edit()
.putString("pad_$conversationId", encoded)
.putString("pad_$conversationId", serialized)
.apply()
}

/**
* Get pad bytes only (for decryption/token derivation).
*/
suspend fun getPadBytes(conversationId: String): ByteArray? = withContext(Dispatchers.IO) {
val encoded = encryptedPrefs.getString("pad_$conversationId", null)
encoded?.let {
val serialized = encryptedPrefs.getString("pad_$conversationId", null) ?: return@withContext null
try {
val storageData = json.decodeFromString<PadStorageData>(serialized)
android.util.Base64.decode(storageData.bytes, android.util.Base64.NO_WRAP)
} catch (e: Exception) {
// Fallback: try legacy format (raw Base64)
try {
android.util.Base64.decode(it, android.util.Base64.NO_WRAP)
} catch (e: Exception) {
android.util.Base64.decode(serialized, android.util.Base64.NO_WRAP)
} catch (e2: Exception) {
null
}
}
}

/**
* Get full pad storage data (bytes + consumption state).
* Matching iOS: PadManager.loadPad reading from Keychain
*/
suspend fun getPadStorageData(conversationId: String): PadStorageData? = withContext(Dispatchers.IO) {
val serialized = encryptedPrefs.getString("pad_$conversationId", null) ?: return@withContext null
try {
json.decodeFromString<PadStorageData>(serialized)
} catch (e: Exception) {
// Fallback: try legacy format (raw Base64) - use conversation for state
try {
val bytes = android.util.Base64.decode(serialized, android.util.Base64.NO_WRAP)
val conversation = getConversation(conversationId)
PadStorageData(
bytes = android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP),
consumedFront = conversation?.padConsumedFront ?: 0,
consumedBack = conversation?.padConsumedBack ?: 0
)
} catch (e2: Exception) {
null
}
}
Expand All @@ -101,16 +176,26 @@ class ConversationStorageService @Inject constructor(
.apply()
}

/**
* Update consumption state in pad storage.
* Matching iOS: PadManager.savePadState
*/
suspend fun updatePadConsumption(
conversationId: String,
padConsumedFront: Long,
padConsumedBack: Long
consumedFront: Long,
consumedBack: Long
) = withContext(Dispatchers.IO) {
val conversation = getConversation(conversationId) ?: return@withContext
val updated = conversation.copy(
padConsumedFront = padConsumedFront,
padConsumedBack = padConsumedBack
// Load existing pad data
val existing = getPadStorageData(conversationId) ?: return@withContext

// Save with updated consumption state
val updated = existing.copy(
consumedFront = consumedFront,
consumedBack = consumedBack
)
saveConversation(updated)
val serialized = json.encodeToString(updated)
encryptedPrefs.edit()
.putString("pad_$conversationId", serialized)
.apply()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,26 +44,28 @@ class PadManager @Inject constructor(
// MARK: - Load/Store

/**
* Load pad for a conversation, using cache if available
* Load pad for a conversation, using cache if available.
* Matching iOS: PadManager.loadPad
*/
suspend fun loadPad(conversationId: String): Pad = mutex.withLock {
// Check cache first
padCache[conversationId]?.let { return@withLock it }

// Load from storage
val padBytes = conversationStorage.getPadBytes(conversationId)
// Load from storage (matching iOS: PadStorageData from Keychain)
val storageData = conversationStorage.getPadStorageData(conversationId)
?: throw IllegalStateException("Pad not found for conversation $conversationId")

val conversation = conversationStorage.getConversation(conversationId)
?: throw IllegalStateException("Conversation not found: $conversationId")
val padBytes = android.util.Base64.decode(storageData.bytes, android.util.Base64.NO_WRAP)

// Create Rust Pad with state
// Create Rust Pad with state (matching iOS: Pad.fromBytesWithState)
val pad = Pad.fromBytesWithState(
padBytes.map { it.toUByte() },
conversation.padConsumedFront.toULong(),
conversation.padConsumedBack.toULong()
storageData.consumedFront.toULong(),
storageData.consumedBack.toULong()
)

Log.d(TAG, "Loaded pad for ${conversationId.take(8)}: front=${storageData.consumedFront}, back=${storageData.consumedBack}")

padCache[conversationId] = pad
pad
}
Expand All @@ -80,13 +82,22 @@ class PadManager @Inject constructor(
}

/**
* Save current pad state to storage
* Save current pad state to storage.
* Matching iOS: PadManager.savePadState - saves bytes + consumption together
* Note: Called within mutex.withLock, no additional locking needed
*/
private suspend fun savePadState(pad: Pad, conversationId: String) {
val consumedFront = pad.consumedFront().toLong()
val consumedBack = pad.consumedBack().toLong()
conversationStorage.updatePadConsumption(conversationId, consumedFront, consumedBack)
val padBytes = pad.asBytes().map { it.toByte() }.toByteArray()

// Save bytes + consumption state together (matching iOS PadStorageData)
conversationStorage.savePadState(
conversationId = conversationId,
padBytes = padBytes,
consumedFront = consumedFront,
consumedBack = consumedBack
)
}

// MARK: - Send Operations
Expand Down Expand Up @@ -122,16 +133,15 @@ class PadManager @Inject constructor(
* IMPORTANT: This updates consumption state - call only once per message!
*/
suspend fun consumeForSending(length: Int, role: Role, conversationId: String): ByteArray = mutex.withLock {
// Get cached pad or load it
// Get cached pad or load it (matching iOS pattern)
val pad = padCache[conversationId] ?: run {
val padBytes = conversationStorage.getPadBytes(conversationId)
val storageData = conversationStorage.getPadStorageData(conversationId)
?: throw IllegalStateException("Pad not found for conversation $conversationId")
val conversation = conversationStorage.getConversation(conversationId)
?: throw IllegalStateException("Conversation not found: $conversationId")
val padBytes = android.util.Base64.decode(storageData.bytes, android.util.Base64.NO_WRAP)
Pad.fromBytesWithState(
padBytes.map { it.toUByte() },
conversation.padConsumedFront.toULong(),
conversation.padConsumedBack.toULong()
storageData.consumedFront.toULong(),
storageData.consumedBack.toULong()
).also { padCache[conversationId] = it }
}

Expand All @@ -154,16 +164,15 @@ class PadManager @Inject constructor(
* Update peer's consumption based on received message
*/
suspend fun updatePeerConsumption(peerRole: Role, consumed: Long, conversationId: String) = mutex.withLock {
// Get cached pad or load it
// Get cached pad or load it (matching iOS pattern)
val pad = padCache[conversationId] ?: run {
val padBytes = conversationStorage.getPadBytes(conversationId)
val storageData = conversationStorage.getPadStorageData(conversationId)
?: throw IllegalStateException("Pad not found for conversation $conversationId")
val conversation = conversationStorage.getConversation(conversationId)
?: throw IllegalStateException("Conversation not found: $conversationId")
val padBytes = android.util.Base64.decode(storageData.bytes, android.util.Base64.NO_WRAP)
Pad.fromBytesWithState(
padBytes.map { it.toUByte() },
conversation.padConsumedFront.toULong(),
conversation.padConsumedBack.toULong()
storageData.consumedFront.toULong(),
storageData.consumedBack.toULong()
).also { padCache[conversationId] = it }
}

Expand Down Expand Up @@ -215,26 +224,23 @@ class PadManager @Inject constructor(
* When a message expires, the key material is zeroed to prevent future decryption.
*/
suspend fun zeroPadBytes(offset: Long, length: Int, conversationId: String) = mutex.withLock {
// Get cached pad or load it
// Get cached pad or load it (matching iOS pattern)
val pad = padCache[conversationId] ?: run {
val padBytes = conversationStorage.getPadBytes(conversationId)
val storageData = conversationStorage.getPadStorageData(conversationId)
?: throw IllegalStateException("Pad not found for conversation $conversationId")
val conversation = conversationStorage.getConversation(conversationId)
?: throw IllegalStateException("Conversation not found: $conversationId")
val padBytes = android.util.Base64.decode(storageData.bytes, android.util.Base64.NO_WRAP)
Pad.fromBytesWithState(
padBytes.map { it.toUByte() },
conversation.padConsumedFront.toULong(),
conversation.padConsumedBack.toULong()
storageData.consumedFront.toULong(),
storageData.consumedBack.toULong()
).also { padCache[conversationId] = it }
}

val success = pad.zeroBytesAt(offset.toULong(), length.toULong())

if (success) {
// Persist updated state (with zeroed bytes)
// Note: We need to save the full pad bytes, not just consumption state
val bytes = pad.asBytes().map { it.toByte() }.toByteArray()
conversationStorage.savePadBytes(conversationId, bytes)
// Persist updated state (with zeroed bytes) - matching iOS savePadState
savePadState(pad, conversationId)
Log.d(TAG, "Zeroed $length pad bytes at offset $offset for forward secrecy")
} else {
Log.w(TAG, "Failed to zero pad bytes: offset $offset, length $length out of bounds")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,26 +23,33 @@ class QRCodeService @Inject constructor() {
* Uses L error correction level (7%) for maximum capacity.
* Fountain codes provide their own redundancy.
*
* Block size 1500 bytes + 16 byte header = 1516 bytes
* Base64 encoded: ~2021 characters
* This fits in QR Version 23-24 with L error correction
*
* @param data Raw bytes to encode
* @param size Target size in pixels
* @param size Target size in pixels (default 600 for larger QR codes)
* @return QR code bitmap or null on failure
*/
fun generate(data: ByteArray, size: Int = 400): Bitmap? {
fun generate(data: ByteArray, size: Int = 600): Bitmap? {
return try {
// Base64 encode for compatibility with QR string parsing
val base64 = Base64.encodeToString(data, Base64.NO_WRAP)

// Check if data is within QR code limits (roughly 2953 chars for L level)
// Check if data is within QR code limits
// Version 40 L can hold ~2953 chars, Version 24 L can hold ~2181 chars
if (base64.length > 2900) {
Log.e(TAG, "Data too large for QR code: ${base64.length} chars")
return null
}

Log.d(TAG, "Generating QR code: ${data.size} bytes -> ${base64.length} chars base64, target size: $size px")

val writer = QRCodeWriter()
val hints = mapOf(
EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.L,
EncodeHintType.MARGIN to 1,
EncodeHintType.CHARACTER_SET to "UTF-8"
EncodeHintType.CHARACTER_SET to "ISO-8859-1" // Binary-safe encoding
)

val bitMatrix = writer.encode(base64, BarcodeFormat.QR_CODE, size, size, hints)
Expand All @@ -59,8 +66,11 @@ class QRCodeService @Inject constructor() {
}
}

val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565)
// Use ARGB_8888 for better quality
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
bitmap.setPixels(pixels, 0, width, 0, 0, width, height)

Log.d(TAG, "QR code generated: ${width}x${height} pixels")
bitmap
} catch (e: Exception) {
Log.e(TAG, "Failed to generate QR code: ${e.message}", e)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ data class PollMessagesResponse(
data class ConnectionTestResult(
val success: Boolean,
val version: String? = null,
val latencyMs: Long? = null,
val error: String? = null
)

Expand Down Expand Up @@ -167,10 +168,12 @@ class RelayService @Inject constructor(

suspend fun testConnection(relayUrl: String): ConnectionTestResult {
return try {
val startTime = System.currentTimeMillis()
val response: HttpResponse = httpClient.get("$relayUrl/health")
val latencyMs = System.currentTimeMillis() - startTime
if (response.status.isSuccess()) {
val health: HealthResponse = response.body()
ConnectionTestResult(success = true, version = health.version)
ConnectionTestResult(success = true, version = health.version, latencyMs = latencyMs)
} else {
ConnectionTestResult(success = false, error = "Status: ${response.status}")
}
Expand Down
Loading
Loading