diff --git a/.codecov.yml b/.codecov.yml
index 5b1f0e197..7691d9c3e 100644
--- a/.codecov.yml
+++ b/.codecov.yml
@@ -29,6 +29,8 @@ flags:
ignore:
- "external/**"
- "python/test_*.py"
+ # Network-dependent Python code requires a live RNS instance, not unit tests
+ - "python/rns_api.py"
# Service layer code requires instrumented tests, not unit tests
- "app/src/main/java/com/lxmf/messenger/service/**"
- "app/src/main/java/com/lxmf/messenger/reticulum/protocol/**"
diff --git a/.github/workflows/build-prerelease-apk.yml b/.github/workflows/build-prerelease-apk.yml
index a3353b80c..f6492062f 100644
--- a/.github/workflows/build-prerelease-apk.yml
+++ b/.github/workflows/build-prerelease-apk.yml
@@ -26,7 +26,7 @@ jobs:
submodules: recursive
- name: Setup Gradle
- uses: gradle/actions/setup-gradle@v5
+ uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: false
@@ -36,7 +36,7 @@ jobs:
python-version: '3.11'
- name: Validate Gradle wrapper
- uses: gradle/actions/wrapper-validation@v5
+ uses: gradle/actions/wrapper-validation@v6
continue-on-error: true
- name: Grant execute permission for gradlew
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7cf40f962..63f4fa6ee 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -24,7 +24,7 @@ jobs:
fetch-depth: 1
- name: Validate Gradle wrapper
- uses: gradle/actions/wrapper-validation@v5
+ uses: gradle/actions/wrapper-validation@v6
lint:
name: Code Quality (ktlint + detekt + CPD)
@@ -40,7 +40,7 @@ jobs:
persist-credentials: false
- name: Setup Gradle
- uses: gradle/actions/setup-gradle@v5
+ uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: false
@@ -169,7 +169,7 @@ jobs:
persist-credentials: false
- name: Setup Gradle
- uses: gradle/actions/setup-gradle@v5
+ uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: false
@@ -253,7 +253,7 @@ jobs:
--cov-report=xml:coverage-python.xml
- name: Upload Python coverage to Codecov
- uses: codecov/codecov-action@v5
+ uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./python/coverage-python.xml
@@ -289,7 +289,7 @@ jobs:
persist-credentials: false
- name: Setup Gradle
- uses: gradle/actions/setup-gradle@v5
+ uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: false
@@ -373,7 +373,7 @@ jobs:
**/build/reports/tests/
- name: Upload coverage to Codecov
- uses: codecov/codecov-action@v5
+ uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./**/build/reports/jacoco/**/*.xml
@@ -406,7 +406,7 @@ jobs:
persist-credentials: false
- name: Setup Gradle
- uses: gradle/actions/setup-gradle@v5
+ uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: false
@@ -467,7 +467,7 @@ jobs:
**/build/reports/tests/
- name: Upload coverage to Codecov
- uses: codecov/codecov-action@v5
+ uses: codecov/codecov-action@v6
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./**/build/reports/jacoco/**/*.xml
@@ -492,7 +492,7 @@ jobs:
persist-credentials: false
- name: Setup Gradle
- uses: gradle/actions/setup-gradle@v5
+ uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: false
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3ca695dd2..4ffe3d47a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -24,7 +24,7 @@ jobs:
python-version: '3.11'
- name: Setup Gradle
- uses: gradle/actions/setup-gradle@v5
+ uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: false
diff --git a/LXST-kt b/LXST-kt
index b96936f5d..bbd6ce4f9 160000
--- a/LXST-kt
+++ b/LXST-kt
@@ -1 +1 @@
-Subproject commit b96936f5d9e88eb8e88818e3f833f4168bd33585
+Subproject commit bbd6ce4f90ec5d49ab90c4a80cce3c0463d7ebb1
diff --git a/README.md b/README.md
index aa9a9bc96..b3dd589b9 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,6 @@
-
+
+
+
# Columba
@@ -40,3 +42,10 @@ Want to learn more? Visit [Reticulum's documentation](https://reticulum.network/
## Why "Columba"
Columba, latin for "dove," is a [constellation](https://en.wikipedia.org/wiki/Columba_(constellation)) in the southern sky depicting a dove. Doves are commonly a symbol of peace and hope, and have been used as messengers throughout history.
+
+## Stats
+
+  
+
+
+[](https://star-history.com/#torlando-tech/columba&Date)
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index ba9d0e738..83f837c73 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -3,7 +3,6 @@ import java.util.Base64
plugins {
id("com.android.application")
- kotlin("android")
kotlin("plugin.compose")
kotlin("plugin.serialization")
id("com.google.devtools.ksp")
@@ -78,7 +77,7 @@ val (versionCodeValue, versionNameValue) = getVersionFromTag()
android {
namespace = "com.lxmf.messenger"
- compileSdk = 35
+ compileSdk = 36
defaultConfig {
applicationId = "com.lxmf.messenger"
@@ -412,7 +411,7 @@ dependencies {
// Crash Reporting - GlitchTip (Sentry-compatible)
// Phase 4 Task 4.2: Production Observability
- implementation("io.sentry:sentry-android:8.29.0")
+ implementation("io.sentry:sentry-android:8.31.0")
// Performance Monitoring - JankStats for frame monitoring
// Phase 1 Plan 01-03: Frame tracking integration with Sentry
@@ -453,7 +452,7 @@ dependencies {
testImplementation(libs.compose.test)
testImplementation("androidx.compose.ui:ui-test-manifest")
testImplementation(libs.paging.testing)
- testImplementation("androidx.test:core:1.5.0")
+ testImplementation(libs.test.core)
testImplementation("androidx.test.ext:junit:1.1.5")
testImplementation("org.json:json:20231013") // Real JSON implementation for unit tests
androidTestImplementation(libs.junit.android)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 73e9bc11d..daced17de 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -51,6 +51,7 @@
+
@@ -175,6 +176,15 @@
android:value="Emergency SOS gesture detection via accelerometer" />
+
+
+
+
+
diff --git a/app/src/main/aidl/com/lxmf/messenger/IReticulumService.aidl b/app/src/main/aidl/com/lxmf/messenger/IReticulumService.aidl
index 917685aa0..a34a18b37 100644
--- a/app/src/main/aidl/com/lxmf/messenger/IReticulumService.aidl
+++ b/app/src/main/aidl/com/lxmf/messenger/IReticulumService.aidl
@@ -678,6 +678,18 @@ interface IReticulumService {
*/
oneway void cancelNomadnetPageRequest();
+ /**
+ * Get current NomadNet file download progress.
+ * @return Float between 0.0 and 1.0 if download active, -1.0 if idle
+ */
+ float getNomadnetDownloadProgress();
+
+ /**
+ * Get current NomadNet request phase status (e.g., "Looking up path...", "Connecting (3 hops)...").
+ * @return Status string, empty if idle
+ */
+ String getNomadnetRequestStatus();
+
/**
* Identify ourselves on an existing NomadNet link (Strangler Fig -> rns_api.py).
* @param destHash Destination hash bytes (16 bytes)
diff --git a/app/src/main/java/com/lxmf/messenger/ColumbaApplication.kt b/app/src/main/java/com/lxmf/messenger/ColumbaApplication.kt
index 14d13d3f7..fb258fcc1 100644
--- a/app/src/main/java/com/lxmf/messenger/ColumbaApplication.kt
+++ b/app/src/main/java/com/lxmf/messenger/ColumbaApplication.kt
@@ -12,6 +12,7 @@ import com.lxmf.messenger.reticulum.model.ReticulumConfig
import com.lxmf.messenger.reticulum.protocol.ReticulumProtocol
import com.lxmf.messenger.reticulum.protocol.ServiceReticulumProtocol
import com.lxmf.messenger.service.IdentityResolutionManager
+import com.lxmf.messenger.service.LocationSharingManager
import com.lxmf.messenger.service.MessageCollector
import com.lxmf.messenger.service.PropagationNodeManager
import com.lxmf.messenger.service.SosActiveTracker
@@ -100,6 +101,9 @@ class ColumbaApplication : Application() {
@Inject
lateinit var receivedLocationDao: com.lxmf.messenger.data.db.dao.ReceivedLocationDao
+ @Inject
+ lateinit var locationSharingManager: LocationSharingManager
+
// Application-level coroutine scope for app-wide operations
// Uses Dispatchers.Default for background initialization (no main-thread work needed)
// SupervisorJob ensures failures don't crash the entire app
@@ -284,7 +288,8 @@ class ColumbaApplication : Application() {
// (collector address + send/request toggles) is available even if
// startup exits early while service is INITIALIZING/RESTARTING.
telemetryCollectorManager.start()
- android.util.Log.d("ColumbaApplication", "TelemetryCollectorManager started early after bind")
+ locationSharingManager.restoreIfActive()
+ android.util.Log.d("ColumbaApplication", "TelemetryCollectorManager + LocationSharingManager started early after bind")
// Check if service is already initialized (handle service process surviving app restart)
// Use timeout to prevent ANR if service is slow
@@ -325,6 +330,7 @@ class ColumbaApplication : Application() {
identityResolutionManager.start(applicationScope)
propagationNodeManager.start()
telemetryCollectorManager.start()
+ locationSharingManager.restoreIfActive()
android.util.Log.d(
"ColumbaApplication",
"MessageCollector, AutoAnnounceManager, IdentityResolutionManager, PropagationNodeManager, TelemetryCollectorManager started",
@@ -443,6 +449,7 @@ class ColumbaApplication : Application() {
identityResolutionManager.start(applicationScope)
propagationNodeManager.start()
telemetryCollectorManager.start()
+ locationSharingManager.restoreIfActive()
android.util.Log.d(
"ColumbaApplication",
"MessageCollector, AutoAnnounceManager, IdentityResolutionManager, PropagationNodeManager, TelemetryCollectorManager started",
@@ -638,6 +645,7 @@ class ColumbaApplication : Application() {
identityResolutionManager.start(applicationScope)
propagationNodeManager.start()
telemetryCollectorManager.start()
+ locationSharingManager.restoreIfActive()
android.util.Log.d(
"ColumbaApplication",
"initializeReticulumService: MessageCollector, AutoAnnounceManager, IdentityResolutionManager, PropagationNodeManager, TelemetryCollectorManager started",
diff --git a/app/src/main/java/com/lxmf/messenger/repository/SettingsRepository.kt b/app/src/main/java/com/lxmf/messenger/repository/SettingsRepository.kt
index 2121e9603..c1fa9f4c7 100644
--- a/app/src/main/java/com/lxmf/messenger/repository/SettingsRepository.kt
+++ b/app/src/main/java/com/lxmf/messenger/repository/SettingsRepository.kt
@@ -185,6 +185,9 @@ class SettingsRepository
// SOS audio recording
val SOS_AUDIO_ENABLED = booleanPreferencesKey("sos_audio_enabled")
val SOS_AUDIO_DURATION_SECONDS = intPreferencesKey("sos_audio_duration_seconds")
+
+ // Persisted location sharing sessions (JSON array, survives restart)
+ val LOCATION_SHARING_SESSIONS = stringPreferencesKey("location_sharing_sessions")
}
// Cross-process SharedPreferences for service communication
@@ -2206,4 +2209,21 @@ class SettingsRepository
preferences[PreferencesKeys.SOS_PILL_OFFSET_Y] = y
}
}
+
+ // ========== Location Sharing Session Persistence ==========
+
+ suspend fun saveLocationSharingSessions(sessionsJson: String) {
+ context.dataStore.edit { preferences ->
+ preferences[PreferencesKeys.LOCATION_SHARING_SESSIONS] = sessionsJson
+ }
+ }
+
+ suspend fun getLocationSharingSessions(): String? =
+ context.dataStore.data.first()[PreferencesKeys.LOCATION_SHARING_SESSIONS]
+
+ suspend fun clearLocationSharingSessions() {
+ context.dataStore.edit { preferences ->
+ preferences.remove(PreferencesKeys.LOCATION_SHARING_SESSIONS)
+ }
+ }
}
diff --git a/app/src/main/java/com/lxmf/messenger/reticulum/protocol/ServiceReticulumProtocol.kt b/app/src/main/java/com/lxmf/messenger/reticulum/protocol/ServiceReticulumProtocol.kt
index bf7ea318f..8514b2e18 100644
--- a/app/src/main/java/com/lxmf/messenger/reticulum/protocol/ServiceReticulumProtocol.kt
+++ b/app/src/main/java/com/lxmf/messenger/reticulum/protocol/ServiceReticulumProtocol.kt
@@ -2874,10 +2874,22 @@ class ServiceReticulumProtocol(
val result = org.json.JSONObject(resultJson)
if (result.optBoolean("success", false)) {
- NomadnetPageResult(
- content = result.getString("content"),
- path = result.getString("path"),
- )
+ val type = result.optString("type", "page")
+ if (type == "file") {
+ NomadnetPageResult(
+ content = "",
+ path = result.getString("path"),
+ type = "file",
+ filePath = result.getString("file_path"),
+ fileName = result.getString("file_name"),
+ fileSize = result.getLong("file_size"),
+ )
+ } else {
+ NomadnetPageResult(
+ content = result.getString("content"),
+ path = result.getString("path"),
+ )
+ }
} else {
throw RuntimeException(result.optString("error", "Unknown error"))
}
@@ -2894,6 +2906,28 @@ class ServiceReticulumProtocol(
}
}
+ suspend fun getNomadnetRequestStatus(): String =
+ kotlinx.coroutines.withContext(Dispatchers.IO) {
+ try {
+ this@ServiceReticulumProtocol.service?.nomadnetRequestStatus ?: ""
+ } catch (
+ @Suppress("SwallowedException") e: Exception,
+ ) {
+ ""
+ }
+ }
+
+ suspend fun getNomadnetDownloadProgress(): Float =
+ kotlinx.coroutines.withContext(Dispatchers.IO) {
+ try {
+ this@ServiceReticulumProtocol.service?.nomadnetDownloadProgress ?: -1f
+ } catch (
+ @Suppress("SwallowedException") e: Exception,
+ ) {
+ -1f
+ }
+ }
+
/**
* Identify ourselves on an existing NomadNet link.
* @return Result where Boolean = alreadyIdentified
@@ -2917,6 +2951,10 @@ class ServiceReticulumProtocol(
data class NomadnetPageResult(
val content: String,
val path: String,
+ val type: String = "page",
+ val filePath: String? = null,
+ val fileName: String? = null,
+ val fileSize: Long = 0L,
)
// Helper extension functions
diff --git a/app/src/main/java/com/lxmf/messenger/service/IdentityResolutionManager.kt b/app/src/main/java/com/lxmf/messenger/service/IdentityResolutionManager.kt
index ffeacc275..9dac7d998 100644
--- a/app/src/main/java/com/lxmf/messenger/service/IdentityResolutionManager.kt
+++ b/app/src/main/java/com/lxmf/messenger/service/IdentityResolutionManager.kt
@@ -3,6 +3,7 @@ package com.lxmf.messenger.service
import android.util.Log
import com.lxmf.messenger.data.db.entity.ContactStatus
import com.lxmf.messenger.data.repository.ContactRepository
+import com.lxmf.messenger.data.repository.ConversationRepository
import com.lxmf.messenger.reticulum.protocol.ReticulumProtocol
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -16,25 +17,26 @@ import javax.inject.Singleton
/**
* Manages background identity resolution for pending contacts.
*
- * This manager periodically:
- * 1. Checks pending contacts against Reticulum's identity cache
- * 2. Requests paths for contacts that need resolution
- * 3. Marks contacts as UNRESOLVED after 24 hours
- * 4. Persists transport data (paths) for crash resilience
- * 5. Requests paths for all contacts at startup as a safety net
+ * This manager:
+ * 1. Requests paths for the 3 most recent conversations at startup
+ * 2. Periodically checks pending contacts (every 3h, gives up after 24h)
+ * 3. Persists transport data for crash resilience
+ *
+ * All path requests go through [requestPathIfNeeded] which checks hasPath first.
*/
@Singleton
class IdentityResolutionManager
@Inject
constructor(
private val contactRepository: ContactRepository,
+ private val conversationRepository: ConversationRepository,
private val reticulumProtocol: ReticulumProtocol,
) {
companion object {
private const val TAG = "IdentityResolutionMgr"
- // Check interval: 15 minutes
- private const val CHECK_INTERVAL_MS = 15 * 60 * 1000L
+ // Check interval: 3 hours
+ private const val CHECK_INTERVAL_MS = 3 * 60 * 60 * 1000L
// Resolution timeout: 24 hours
private const val RESOLUTION_TIMEOUT_MS = 24 * 60 * 60 * 1000L
@@ -44,6 +46,9 @@ class IdentityResolutionManager
// Delay before startup sweep to let Reticulum initialize
private const val STARTUP_SWEEP_DELAY_MS = 5_000L
+
+ // Number of recent conversations to request paths for at startup
+ private const val STARTUP_SWEEP_LIMIT = 3
}
private var resolutionJob: Job? = null
@@ -73,11 +78,11 @@ class IdentityResolutionManager
}
}
- // One-shot startup sweep: request paths for all contacts as a safety net
+ // One-shot startup sweep: request paths for 3 most recent conversations
startupSweepJob =
scope.launch(Dispatchers.IO) {
delay(STARTUP_SWEEP_DELAY_MS)
- requestPathsForAllContacts()
+ requestPathsForRecentConversations()
}
}
@@ -136,9 +141,8 @@ class IdentityResolutionManager
publicKey = identity.publicKey,
)
} else {
- // Not in cache, request path to trigger network search
- Log.d(TAG, "Requesting path for ${contact.destinationHash.take(8)}...")
- reticulumProtocol.requestPath(destHashBytes)
+ // Not in cache, request path (guarded) to trigger network search
+ requestPathIfNeeded(destHashBytes, contact.destinationHash)
}
} catch (e: Exception) {
Log.e(TAG, "Error processing contact ${contact.destinationHash.take(8)}...", e)
@@ -166,53 +170,38 @@ class IdentityResolutionManager
.map { it.toInt(16).toByte() }
.toByteArray()
- if (reticulumProtocol.hasPath(destHashBytes)) {
- Log.d(TAG, "Path already exists for ${destinationHash.take(8)}..., skipping request")
- return
- }
-
- Log.d(TAG, "Requesting path for ${destinationHash.take(8)}...")
- reticulumProtocol.requestPath(destHashBytes)
+ requestPathIfNeeded(destHashBytes, destinationHash)
} catch (e: Exception) {
Log.e(TAG, "Error requesting path for ${destinationHash.take(8)}...", e)
}
}
/**
- * Request paths for all active and pending contacts.
- * Called once at startup as a safety net to repopulate the path table.
+ * Request paths for the N most recent conversations.
+ * Called once at startup to ensure the most relevant peers are reachable.
*/
- private suspend fun requestPathsForAllContacts() {
+ private suspend fun requestPathsForRecentConversations() {
try {
- val contacts =
- contactRepository.getContactsByStatus(
- listOf(ContactStatus.ACTIVE, ContactStatus.PENDING_IDENTITY),
- )
+ val recentPeerHashes = conversationRepository.getRecentPeerHashes(STARTUP_SWEEP_LIMIT)
- if (contacts.isEmpty()) {
- Log.d(TAG, "Startup sweep: no contacts to request paths for")
+ if (recentPeerHashes.isEmpty()) {
+ Log.d(TAG, "Startup sweep: no recent conversations")
return
}
- Log.d(TAG, "Startup sweep: requesting paths for ${contacts.size} contact(s)")
+ Log.d(TAG, "Startup sweep: requesting paths for ${recentPeerHashes.size} recent conversation(s)")
- for (contact in contacts) {
+ for (peerHash in recentPeerHashes) {
try {
val destHashBytes =
- contact.destinationHash
+ peerHash
.chunked(2)
.map { it.toInt(16).toByte() }
.toByteArray()
- if (reticulumProtocol.hasPath(destHashBytes)) {
- Log.d(TAG, "Startup sweep: path exists for ${contact.destinationHash.take(8)}..., skipping")
- continue
- }
-
- Log.d(TAG, "Startup sweep: requesting path for ${contact.destinationHash.take(8)}...")
- reticulumProtocol.requestPath(destHashBytes)
+ requestPathIfNeeded(destHashBytes, peerHash)
} catch (e: Exception) {
- Log.e(TAG, "Startup sweep: error for ${contact.destinationHash.take(8)}...", e)
+ Log.e(TAG, "Startup sweep: error for ${peerHash.take(8)}...", e)
}
delay(PATH_REQUEST_STAGGER_MS)
}
@@ -233,13 +222,33 @@ class IdentityResolutionManager
suspend fun retryResolution(destinationHash: String) {
Log.d(TAG, "Retry resolution for ${destinationHash.take(8)}...")
- // Request path on network
- val destHashBytes =
- destinationHash
- .chunked(2)
- .map { it.toInt(16).toByte() }
- .toByteArray()
+ try {
+ val destHashBytes =
+ destinationHash
+ .chunked(2)
+ .map { it.toInt(16).toByte() }
+ .toByteArray()
+
+ requestPathIfNeeded(destHashBytes, destinationHash)
+ } catch (e: Exception) {
+ Log.e(TAG, "Error in retryResolution for ${destinationHash.take(8)}...", e)
+ }
+ }
+
+ /**
+ * Central path request method — checks hasPath before requesting.
+ * All path requests in this class must go through here.
+ */
+ private suspend fun requestPathIfNeeded(
+ destHashBytes: ByteArray,
+ displayHash: String,
+ ) {
+ if (reticulumProtocol.hasPath(destHashBytes)) {
+ Log.d(TAG, "Path exists for ${displayHash.take(8)}..., skipping request")
+ return
+ }
+ Log.d(TAG, "Requesting path for ${displayHash.take(8)}...")
reticulumProtocol.requestPath(destHashBytes)
}
}
diff --git a/app/src/main/java/com/lxmf/messenger/service/LocationForegroundService.kt b/app/src/main/java/com/lxmf/messenger/service/LocationForegroundService.kt
new file mode 100644
index 000000000..3f888f755
--- /dev/null
+++ b/app/src/main/java/com/lxmf/messenger/service/LocationForegroundService.kt
@@ -0,0 +1,113 @@
+package com.lxmf.messenger.service
+
+import android.app.Notification
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.os.Build
+import android.os.IBinder
+import android.util.Log
+import androidx.core.app.NotificationCompat
+import com.lxmf.messenger.MainActivity
+import com.lxmf.messenger.R
+
+/**
+ * Lightweight foreground service that keeps the main process alive during Android Doze
+ * so that GPS callbacks from FusedLocationProviderClient continue to fire.
+ *
+ * Only runs when location sharing or telemetry collection is active.
+ * Managed via [LocationServiceCoordinator].
+ */
+class LocationForegroundService : Service() {
+ companion object {
+ private const val TAG = "LocationFgService"
+ private const val CHANNEL_ID = "location_sharing"
+ private const val NOTIFICATION_ID = 1004
+
+ private const val EXTRA_TEXT = "notification_text"
+
+ fun start(context: Context, notificationText: String = "Location active") {
+ val intent = Intent(context, LocationForegroundService::class.java)
+ .putExtra(EXTRA_TEXT, notificationText)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.startForegroundService(intent)
+ } else {
+ context.startService(intent)
+ }
+ }
+
+ fun stop(context: Context) {
+ context.stopService(Intent(context, LocationForegroundService::class.java))
+ }
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ createNotificationChannel()
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ val text = intent?.getStringExtra(EXTRA_TEXT) ?: "Location active"
+ val notification = buildNotification(text)
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION)
+ } else {
+ startForeground(NOTIFICATION_ID, notification)
+ }
+ } catch (e: SecurityException) {
+ Log.e(TAG, "Cannot start: location permission not granted", e)
+ LocationServiceCoordinator.clearAll()
+ stopSelf()
+ return START_NOT_STICKY
+ }
+ Log.d(TAG, "Location foreground service started")
+ // NOT_STICKY: don't restart after process death — coordinator will re-acquire
+ return START_NOT_STICKY
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onDestroy() {
+ super.onDestroy()
+ Log.d(TAG, "Location foreground service stopped")
+ }
+
+ private fun createNotificationChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ val channel =
+ NotificationChannel(
+ CHANNEL_ID,
+ "Location Sharing",
+ NotificationManager.IMPORTANCE_LOW,
+ ).apply {
+ description = "Shown while location sharing or telemetry collection is active"
+ setShowBadge(false)
+ }
+ val manager = getSystemService(NotificationManager::class.java)
+ manager.createNotificationChannel(channel)
+ }
+ }
+
+ private fun buildNotification(text: String): Notification {
+ val openIntent =
+ PendingIntent.getActivity(
+ this,
+ 0,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+
+ return NotificationCompat
+ .Builder(this, CHANNEL_ID)
+ .setSmallIcon(R.mipmap.ic_launcher)
+ .setContentText(text)
+ .setOngoing(true)
+ .setContentIntent(openIntent)
+ .build()
+ }
+}
diff --git a/app/src/main/java/com/lxmf/messenger/service/LocationServiceCoordinator.kt b/app/src/main/java/com/lxmf/messenger/service/LocationServiceCoordinator.kt
new file mode 100644
index 000000000..4d3c36758
--- /dev/null
+++ b/app/src/main/java/com/lxmf/messenger/service/LocationServiceCoordinator.kt
@@ -0,0 +1,76 @@
+package com.lxmf.messenger.service
+
+import android.content.Context
+import android.util.Log
+
+/**
+ * Coordinates [LocationForegroundService] lifecycle between multiple consumers
+ * (location sharing and telemetry collection).
+ *
+ * The service is started when the first consumer acquires and stopped when the
+ * last consumer releases. Thread-safe via synchronized.
+ */
+object LocationServiceCoordinator {
+ private const val TAG = "LocationServiceCoord"
+
+ const val REASON_SHARING = "location_sharing"
+ const val REASON_TELEMETRY = "telemetry_collection"
+
+ private val activeReasons = mutableSetOf()
+
+ fun isAcquired(reason: String): Boolean = synchronized(activeReasons) { reason in activeReasons }
+
+ fun acquire(context: Context, reason: String) {
+ synchronized(activeReasons) {
+ val wasEmpty = activeReasons.isEmpty()
+ activeReasons.add(reason)
+ val text = notificationText()
+ if (wasEmpty) {
+ Log.d(TAG, "Starting location foreground service (reason: $reason)")
+ try {
+ LocationForegroundService.start(context, text)
+ } catch (e: Exception) {
+ activeReasons.remove(reason)
+ Log.e(TAG, "Failed to start service, rolled back '$reason'", e)
+ }
+ } else {
+ // Update notification text to reflect new reason
+ LocationForegroundService.start(context, text)
+ Log.d(TAG, "Location service updated, added reason: $reason (active: $activeReasons)")
+ }
+ }
+ }
+
+ /** Called by the service when it fails to start foreground and self-destructs. */
+ fun clearAll() {
+ synchronized(activeReasons) {
+ Log.w(TAG, "Clearing all reasons due to service failure (was: $activeReasons)")
+ activeReasons.clear()
+ }
+ }
+
+ fun release(context: Context, reason: String) {
+ synchronized(activeReasons) {
+ if (!activeReasons.remove(reason)) {
+ Log.d(TAG, "release() for '$reason' — not acquired, ignoring")
+ return
+ }
+ if (activeReasons.isEmpty()) {
+ Log.d(TAG, "Stopping location foreground service (released: $reason)")
+ LocationForegroundService.stop(context)
+ } else {
+ // Update notification text to reflect remaining reasons
+ LocationForegroundService.start(context, notificationText())
+ Log.d(TAG, "Location service updated (released: $reason, remaining: $activeReasons)")
+ }
+ }
+ }
+
+ private fun notificationText(): String = when {
+ REASON_SHARING in activeReasons && REASON_TELEMETRY in activeReasons ->
+ "Location sharing & telemetry active"
+ REASON_SHARING in activeReasons -> "Location sharing active"
+ REASON_TELEMETRY in activeReasons -> "Telemetry collection active"
+ else -> "Location active"
+ }
+}
diff --git a/app/src/main/java/com/lxmf/messenger/service/LocationSharingManager.kt b/app/src/main/java/com/lxmf/messenger/service/LocationSharingManager.kt
index 04396f419..441a5aa2d 100644
--- a/app/src/main/java/com/lxmf/messenger/service/LocationSharingManager.kt
+++ b/app/src/main/java/com/lxmf/messenger/service/LocationSharingManager.kt
@@ -130,6 +130,85 @@ class LocationSharingManager
startMaintenanceLoop()
}
+ /**
+ * Restore persisted sharing sessions after app restart.
+ * Called from Application.onCreate to resume location sharing without opening the UI.
+ */
+ @SuppressLint("MissingPermission")
+ fun restoreIfActive() {
+ if (_isSharing.value) return // already active, don't clobber
+ scope.launch {
+ try {
+ if (_isSharing.value) return@launch // re-check after dispatch
+ val json = settingsRepository.getLocationSharingSessions() ?: return@launch
+ val sessions = deserializeSessions(json)
+ if (sessions.isEmpty()) return@launch
+
+ // Filter out sessions that have already expired
+ val now = System.currentTimeMillis()
+ val active = sessions.filter { it.endTime == null || it.endTime > now }
+ if (active.isEmpty()) {
+ settingsRepository.clearLocationSharingSessions()
+ return@launch
+ }
+
+ _activeSessions.value = active
+ _isSharing.value = true
+
+ LocationServiceCoordinator.acquire(context, LocationServiceCoordinator.REASON_SHARING)
+ if (locationUpdateJob == null || locationUpdateJob?.isActive != true) {
+ startLocationUpdates()
+ }
+ if (sessionCheckJob == null || sessionCheckJob?.isActive != true) {
+ startSessionCheck()
+ }
+
+ Log.d(TAG, "Restored ${active.size} sharing sessions from persistence")
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to restore sharing sessions, clearing", e)
+ settingsRepository.clearLocationSharingSessions()
+ }
+ }
+ }
+
+ private fun persistSessions() {
+ scope.launch {
+ val sessions = _activeSessions.value
+ if (sessions.isEmpty()) {
+ settingsRepository.clearLocationSharingSessions()
+ } else {
+ settingsRepository.saveLocationSharingSessions(serializeSessions(sessions))
+ }
+ }
+ }
+
+ private fun serializeSessions(sessions: List): String {
+ val array = org.json.JSONArray()
+ for (s in sessions) {
+ val obj = JSONObject().apply {
+ put("destinationHash", s.destinationHash)
+ put("displayName", s.displayName)
+ put("startTime", s.startTime)
+ if (s.endTime != null) put("endTime", s.endTime)
+ }
+ array.put(obj)
+ }
+ return array.toString()
+ }
+
+ private fun deserializeSessions(json: String): List {
+ val array = org.json.JSONArray(json)
+ return (0 until array.length()).map { i ->
+ val obj = array.getJSONObject(i)
+ SharingSession(
+ destinationHash = obj.getString("destinationHash"),
+ displayName = obj.getString("displayName"),
+ startTime = obj.getLong("startTime"),
+ endTime = if (obj.has("endTime")) obj.getLong("endTime") else null,
+ )
+ }
+ }
+
/**
* Start sharing location with specified contacts.
*
@@ -165,6 +244,7 @@ class LocationSharingManager
_isSharing.value = updated.isNotEmpty()
Log.d(TAG, "Started sharing with ${newSessions.size} contacts, duration=$duration")
+ persistSessions()
// Send last known location immediately (don't wait for first GPS update)
if (useGms) {
@@ -181,6 +261,9 @@ class LocationSharingManager
}
}
+ // Ensure foreground service is running for Doze-resistant GPS
+ LocationServiceCoordinator.acquire(context, LocationServiceCoordinator.REASON_SHARING)
+
// Start location updates if not already running
if (locationUpdateJob == null || locationUpdateJob?.isActive != true) {
startLocationUpdates()
@@ -268,9 +351,11 @@ class LocationSharingManager
stopLocationUpdates()
sessionCheckJob?.cancel()
sessionCheckJob = null
+ LocationServiceCoordinator.release(context, LocationServiceCoordinator.REASON_SHARING)
}
Log.d(TAG, "Stopped sharing, remaining sessions: ${updated.size}")
+ persistSessions()
scope.launch {
_sharingEvents.emit(SharingEvent.Stopped(destinationHash))
@@ -338,7 +423,7 @@ class LocationSharingManager
val locationRequest =
LocationRequest
.Builder(
- Priority.PRIORITY_BALANCED_POWER_ACCURACY,
+ Priority.PRIORITY_HIGH_ACCURACY,
LOCATION_UPDATE_INTERVAL_MS,
).apply {
setMinUpdateIntervalMillis(LOCATION_MIN_UPDATE_INTERVAL_MS)
@@ -364,6 +449,12 @@ class LocationSharingManager
} catch (e: SecurityException) {
Log.e(TAG, "Location permission not granted", e)
_sharingEvents.emit(SharingEvent.Error("Location permission required"))
+ _activeSessions.value = emptyList()
+ _isSharing.value = false
+ sessionCheckJob?.cancel()
+ sessionCheckJob = null
+ persistSessions()
+ LocationServiceCoordinator.release(context, LocationServiceCoordinator.REASON_SHARING)
}
}
}
@@ -421,9 +512,13 @@ class LocationSharingManager
if (active.isEmpty()) {
stopLocationUpdates()
+ sessionCheckJob?.cancel()
+ sessionCheckJob = null
+ LocationServiceCoordinator.release(context, LocationServiceCoordinator.REASON_SHARING)
}
_sharingEvents.emit(SharingEvent.SessionsExpired(expired.size))
+ persistSessions()
}
}
diff --git a/app/src/main/java/com/lxmf/messenger/service/TelemetryCollectorManager.kt b/app/src/main/java/com/lxmf/messenger/service/TelemetryCollectorManager.kt
index f5ae91370..dec4c4b3b 100644
--- a/app/src/main/java/com/lxmf/messenger/service/TelemetryCollectorManager.kt
+++ b/app/src/main/java/com/lxmf/messenger/service/TelemetryCollectorManager.kt
@@ -183,7 +183,7 @@ class TelemetryCollectorManager
// Start periodic sending and requesting
restartPeriodicSend()
restartPeriodicRequest()
- locationTracker.update(shouldTrackLocation())
+ updateLocationTracking()
}
private fun CoroutineScope.launchSendSettingsObservers() {
@@ -211,7 +211,7 @@ class TelemetryCollectorManager
Log.d(TAG, "Collector address updated: ${address ?: "none"}")
restartPeriodicSend()
restartPeriodicRequest()
- locationTracker.update(shouldTrackLocation())
+ updateLocationTracking()
}
}
launch {
@@ -221,7 +221,7 @@ class TelemetryCollectorManager
_isEnabled.value = enabled
Log.d(TAG, "Collector enabled: $enabled")
restartPeriodicSend()
- locationTracker.update(shouldTrackLocation())
+ updateLocationTracking()
}
}
launch {
@@ -250,6 +250,7 @@ class TelemetryCollectorManager
_isRequestEnabled.value = enabled
Log.d(TAG, "Request enabled: $enabled")
restartPeriodicRequest()
+ updateLocationTracking()
}
}
launch {
@@ -309,17 +310,43 @@ class TelemetryCollectorManager
*/
fun stop() {
Log.d(TAG, "Stopping TelemetryCollectorManager")
+ // Poison state first so straggling flow collectors won't re-acquire the service
+ _isEnabled.value = false
+ _isRequestEnabled.value = false
+ _collectorAddress.value = null
settingsObserverJob?.cancel()
periodicSendJob?.cancel()
periodicRequestJob?.cancel()
- locationTracker.stop()
settingsObserverJob = null
periodicSendJob = null
periodicRequestJob = null
+ locationTracker.stop()
+ if (LocationServiceCoordinator.isAcquired(LocationServiceCoordinator.REASON_TELEMETRY)) {
+ LocationServiceCoordinator.release(context, LocationServiceCoordinator.REASON_TELEMETRY)
+ }
}
private fun shouldTrackLocation(): Boolean = _isEnabled.value && _collectorAddress.value != null
+ /** Whether any telemetry activity (send or request) needs the process kept alive. */
+ private fun needsForegroundService(): Boolean =
+ _collectorAddress.value != null && (_isEnabled.value || _isRequestEnabled.value)
+
+ /** Update location tracking and coordinate the foreground service lifecycle. */
+ private fun updateLocationTracking() {
+ val shouldTrack = shouldTrackLocation()
+ locationTracker.update(shouldTrack)
+
+ // Foreground service is needed for both send (GPS) and request-only (periodic polling)
+ val needsService = needsForegroundService()
+ val hasService = LocationServiceCoordinator.isAcquired(LocationServiceCoordinator.REASON_TELEMETRY)
+ if (needsService && !hasService) {
+ LocationServiceCoordinator.acquire(context, LocationServiceCoordinator.REASON_TELEMETRY)
+ } else if (!needsService && hasService) {
+ LocationServiceCoordinator.release(context, LocationServiceCoordinator.REASON_TELEMETRY)
+ }
+ }
+
/**
* Update the collector address.
*
diff --git a/app/src/main/java/com/lxmf/messenger/service/TelemetryLocationTracker.kt b/app/src/main/java/com/lxmf/messenger/service/TelemetryLocationTracker.kt
index ce9c0f34e..321f7f741 100644
--- a/app/src/main/java/com/lxmf/messenger/service/TelemetryLocationTracker.kt
+++ b/app/src/main/java/com/lxmf/messenger/service/TelemetryLocationTracker.kt
@@ -141,7 +141,7 @@ internal class TelemetryLocationTracker(
val request =
LocationRequest
- .Builder(Priority.PRIORITY_BALANCED_POWER_ACCURACY, TRACKING_UPDATE_INTERVAL_MS)
+ .Builder(Priority.PRIORITY_HIGH_ACCURACY, TRACKING_UPDATE_INTERVAL_MS)
.setMinUpdateIntervalMillis(TRACKING_MIN_UPDATE_INTERVAL_MS)
.build()
diff --git a/app/src/main/java/com/lxmf/messenger/service/binder/ReticulumServiceBinder.kt b/app/src/main/java/com/lxmf/messenger/service/binder/ReticulumServiceBinder.kt
index e7a62fdc5..f5cacc0e2 100644
--- a/app/src/main/java/com/lxmf/messenger/service/binder/ReticulumServiceBinder.kt
+++ b/app/src/main/java/com/lxmf/messenger/service/binder/ReticulumServiceBinder.kt
@@ -1273,6 +1273,10 @@ class ReticulumServiceBinder(
wrapperManager.cancelNomadnetPageRequest()
}
+ override fun getNomadnetDownloadProgress(): Float = wrapperManager.getNomadnetDownloadProgress()
+
+ override fun getNomadnetRequestStatus(): String = wrapperManager.getNomadnetRequestStatus()
+
override fun identifyNomadnetLink(destHash: ByteArray): String = wrapperManager.identifyNomadnetLink(destHash)
// ===========================================
diff --git a/app/src/main/java/com/lxmf/messenger/service/manager/PythonWrapperManager.kt b/app/src/main/java/com/lxmf/messenger/service/manager/PythonWrapperManager.kt
index 724168752..3587dbe6f 100644
--- a/app/src/main/java/com/lxmf/messenger/service/manager/PythonWrapperManager.kt
+++ b/app/src/main/java/com/lxmf/messenger/service/manager/PythonWrapperManager.kt
@@ -853,6 +853,7 @@ class PythonWrapperManager(
}
val api = rnsApi ?: return """{"success": false, "error": "RnsApi not initialized"}"""
return try {
+ val downloadDir = java.io.File(context.cacheDir, "nomadnet_downloads").absolutePath
val result =
api.callAttr(
"request_nomadnet_page",
@@ -860,6 +861,7 @@ class PythonWrapperManager(
path,
formDataJson,
timeoutSeconds,
+ downloadDir,
)
result?.toString() ?: """{"success": false, "error": "No result from Python"}"""
} catch (e: Exception) {
@@ -868,6 +870,36 @@ class PythonWrapperManager(
}
}
+ /**
+ * Get current NomadNet file download progress.
+ * @return Float between 0.0 and 1.0 if download active, -1.0 if idle
+ */
+ fun getNomadnetDownloadProgress(): Float {
+ val api = rnsApi ?: return -1f
+ return try {
+ api.callAttr("get_download_progress")?.toFloat() ?: -1f
+ } catch (
+ @Suppress("SwallowedException") e: Exception,
+ ) {
+ -1f
+ }
+ }
+
+ /**
+ * Get current NomadNet request phase status for UI display.
+ * Polled by Kotlin ViewModel to show granular progress.
+ */
+ fun getNomadnetRequestStatus(): String {
+ val api = rnsApi ?: return ""
+ return try {
+ api.callAttr("get_request_status")?.toString() ?: ""
+ } catch (
+ @Suppress("SwallowedException") e: Exception,
+ ) {
+ ""
+ }
+ }
+
/**
* Cancel any in-progress NomadNet page request.
* Uses rns_api.py (Strangler Fig).
diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/AnnounceStreamScreen.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/AnnounceStreamScreen.kt
index f45ae1e09..2ca94a9c4 100644
--- a/app/src/main/java/com/lxmf/messenger/ui/screens/AnnounceStreamScreen.kt
+++ b/app/src/main/java/com/lxmf/messenger/ui/screens/AnnounceStreamScreen.kt
@@ -66,6 +66,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
+import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import com.lxmf.messenger.data.model.InterfaceType
import com.lxmf.messenger.data.repository.Announce
@@ -260,10 +261,7 @@ fun AnnounceStreamScreen(
) {
items(
count = pagingItems.itemCount,
- key = { index ->
- val announce = pagingItems.peek(index)
- if (announce != null) "${announce.destinationHash}_$index" else "placeholder_$index"
- },
+ key = pagingItems.stableKey(),
) { index ->
val announce = pagingItems[index]
if (announce != null) {
@@ -830,12 +828,7 @@ fun AnnounceStreamContent(
) {
items(
count = pagingItems.itemCount,
- key = { index ->
- // Include index to prevent duplicate key crash when Paging3
- // transiently returns overlapping items (issue #542)
- val hash = pagingItems.peek(index)?.destinationHash
- if (hash != null) "${hash}_$index" else "placeholder_$index"
- },
+ key = pagingItems.stableKey(),
) { index ->
val announce = pagingItems[index]
if (announce != null) {
@@ -1037,3 +1030,25 @@ fun ClearAllAnnouncesDialog(
},
)
}
+
+/**
+ * Stable key function for announce paging lists.
+ *
+ * Uses [com.lxmf.messenger.data.repository.Announce.destinationHash] as the primary key so Compose can track
+ * items across list re-sorts (e.g., when new announces insert at the top).
+ * Falls back to appending a disambiguator only for transient Paging3 duplicates
+ * (issue #542) to avoid a duplicate-key crash.
+ */
+private fun LazyPagingItems.stableKey(): (index: Int) -> Any {
+ val seen = mutableSetOf()
+ val keys =
+ Array(itemCount) { index ->
+ val hash = peek(index)?.destinationHash
+ if (hash != null) {
+ if (seen.add(hash)) hash else "${hash}_dup$index"
+ } else {
+ "placeholder_$index"
+ }
+ }
+ return { index -> keys[index] }
+}
diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/FocusInterfaceDetails.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/FocusInterfaceDetails.kt
index 15c967ea5..d28cbe084 100644
--- a/app/src/main/java/com/lxmf/messenger/ui/screens/FocusInterfaceDetails.kt
+++ b/app/src/main/java/com/lxmf/messenger/ui/screens/FocusInterfaceDetails.kt
@@ -87,4 +87,5 @@ data class FocusInterfaceDetails(
val status: String? = null,
val lastHeard: Long? = null,
val hops: Int? = null,
+ val firstSeen: Long? = null,
)
diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/MapScreen.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/MapScreen.kt
index 607d02b29..afa14d2fa 100644
--- a/app/src/main/java/com/lxmf/messenger/ui/screens/MapScreen.kt
+++ b/app/src/main/java/com/lxmf/messenger/ui/screens/MapScreen.kt
@@ -44,6 +44,7 @@ import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
+import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -99,8 +100,10 @@ import com.lxmf.messenger.ui.util.calculateDeclutteredPositions
import com.lxmf.messenger.util.LocationCompat
import com.lxmf.messenger.util.LocationPermissionManager
import com.lxmf.messenger.viewmodel.ContactMarker
+import com.lxmf.messenger.viewmodel.InterfaceMarker
import com.lxmf.messenger.viewmodel.MapViewModel
import com.lxmf.messenger.viewmodel.MarkerState
+import com.lxmf.messenger.viewmodel.toFocusInterfaceDetails
import org.maplibre.android.MapLibre
import org.maplibre.android.camera.CameraPosition
import org.maplibre.android.camera.CameraUpdateFactory
@@ -219,7 +222,9 @@ fun MapScreen(
!permissionSheetDismissed
var showShareLocationSheet by remember { mutableStateOf(false) }
var selectedMarker by remember { mutableStateOf(null) }
+ var selectedInterface by remember { mutableStateOf(null) }
var showFocusInterfaceSheet by remember { mutableStateOf(false) }
+ val filteredInterfaceMarkers by viewModel.filteredInterfaceMarkers.collectAsState()
val permissionSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val shareLocationSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val contactLocationSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@@ -837,6 +842,21 @@ fun MapScreen(
return@addOnMapClickListener true
}
+ // Check for interface marker click
+ val ifaceFeatures =
+ map.queryRenderedFeatures(
+ screenPoint,
+ "interface-markers-layer",
+ )
+ if (ifaceFeatures.isNotEmpty()) {
+ val id = ifaceFeatures.first().getStringProperty("id")
+ if (id != null) {
+ selectedInterface =
+ state.interfaceMarkers.find { it.id == id }
+ }
+ return@addOnMapClickListener true
+ }
+
// Check for contact marker click
val features =
map.queryRenderedFeatures(
@@ -1097,6 +1117,67 @@ fun MapScreen(
}
}
+ // Interface markers layer — discovered network interfaces with locations
+ LaunchedEffect(filteredInterfaceMarkers, mapStyleLoaded) {
+ if (!mapStyleLoaded) return@LaunchedEffect
+ val map = mapLibreMap ?: return@LaunchedEffect
+ val style = map.style ?: return@LaunchedEffect
+
+ val ifaceSourceId = "interface-markers-source"
+ val ifaceLayerId = "interface-markers-layer"
+ val screenDensity = context.resources.displayMetrics.density
+
+ // Register category bitmaps (one per type, not per marker)
+ for (category in com.lxmf.messenger.ui.util.InterfaceCategory.entries) {
+ val imageId = "iface-${category.name}"
+ if (style.getImage(imageId) == null) {
+ MarkerBitmapFactory
+ .createInterfaceMarker(
+ iconResId = category.markerIconResId,
+ backgroundColor = category.markerColor,
+ density = screenDensity,
+ context = context,
+ ).let { bitmap -> style.addImage(imageId, bitmap) }
+ }
+ }
+
+ // Build GeoJSON features
+ val features =
+ filteredInterfaceMarkers.map { marker ->
+ Feature
+ .fromGeometry(
+ Point.fromLngLat(marker.longitude, marker.latitude),
+ ).apply {
+ addStringProperty("id", marker.id)
+ addStringProperty("name", marker.name)
+ addStringProperty("imageId", "iface-${marker.category.name}")
+ }
+ }
+ val featureCollection = FeatureCollection.fromFeatures(features)
+
+ val existingSource = style.getSourceAs(ifaceSourceId)
+ if (existingSource != null) {
+ existingSource.setGeoJson(featureCollection)
+ } else {
+ style.addSource(GeoJsonSource(ifaceSourceId, featureCollection))
+ val layer =
+ SymbolLayer(ifaceLayerId, ifaceSourceId).withProperties(
+ PropertyFactory.iconImage(Expression.get("imageId")),
+ PropertyFactory.iconAnchor("center"),
+ PropertyFactory.iconAllowOverlap(true),
+ PropertyFactory.iconIgnorePlacement(true),
+ PropertyFactory.iconSize(1f),
+ )
+ // Add below contact markers so contacts always render on top
+ val contactLayer = style.getLayer("contact-markers-layer")
+ if (contactLayer != null) {
+ style.addLayerBelow(layer, "contact-markers-layer")
+ } else {
+ style.addLayer(layer)
+ }
+ }
+ }
+
// Add focus marker for discovered interface location (if provided)
LaunchedEffect(focusLatitude, focusLongitude, focusLabel, mapStyleLoaded) {
if (!mapStyleLoaded) return@LaunchedEffect
@@ -1289,6 +1370,43 @@ fun MapScreen(
)
}
+ // Interface type filter chips (shown when interface markers exist)
+ if (state.interfaceMarkers.isNotEmpty()) {
+ Row(
+ modifier =
+ Modifier
+ .align(Alignment.TopStart)
+ .statusBarsPadding()
+ .padding(top = 64.dp, start = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ com.lxmf.messenger.ui.util.InterfaceCategory.entries
+ .filter { cat -> state.interfaceMarkers.any { it.category == cat } }
+ .forEach { category ->
+ FilterChip(
+ selected = state.interfaceFilterEnabled[category] ?: true,
+ onClick = { viewModel.toggleInterfaceFilter(category) },
+ label = {
+ Text(
+ category.defaultText,
+ style = MaterialTheme.typography.labelSmall,
+ )
+ },
+ leadingIcon = {
+ Icon(
+ painter =
+ androidx.compose.ui.res
+ .painterResource(category.markerIconResId),
+ contentDescription = null,
+ modifier = Modifier.size(16.dp),
+ )
+ },
+ modifier = Modifier.height(32.dp),
+ )
+ }
+ }
+ }
+
// Scale bar (bottom right, next to My Location button)
ScaleBar(
metersPerPixel = metersPerPixel,
@@ -1546,6 +1664,33 @@ fun MapScreen(
)
}
+ // Bottom sheet for tapped interface marker on the map
+ selectedInterface?.let { marker ->
+ val details = marker.toFocusInterfaceDetails()
+ FocusInterfaceBottomSheet(
+ details = details,
+ onDismiss = { selectedInterface = null },
+ onCopyLoraParams = {
+ val params = formatLoraParamsForClipboard(details)
+ val clipboard =
+ context.getSystemService(Context.CLIPBOARD_SERVICE)
+ as android.content.ClipboardManager
+ val clip = android.content.ClipData.newPlainText("LoRa Parameters", params)
+ clipboard.setPrimaryClip(clip)
+ Toast.makeText(context, "LoRa parameters copied", Toast.LENGTH_SHORT).show()
+ },
+ onUseForNewRNode = {
+ selectedInterface = null
+ onNavigateToRNodeWizardWithParams(
+ details.frequency,
+ details.bandwidth,
+ details.spreadingFactor,
+ details.codingRate,
+ )
+ },
+ )
+ }
+
// Bottom sheet for focus interface details (discovered interface)
if (showFocusInterfaceSheet && focusInterfaceDetails != null) {
FocusInterfaceBottomSheet(
@@ -1733,13 +1878,20 @@ internal fun FocusInterfaceContent(
}
// Status details
- if (details.lastHeard != null || details.hops != null) {
+ if (details.firstSeen != null || details.lastHeard != null || details.hops != null) {
HorizontalDivider()
Text(
text = "Status",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
+ details.firstSeen?.let { timestamp ->
+ val timeAgo = formatTimeAgo(timestamp)
+ InterfaceDetailRow(
+ label = "First Seen",
+ value = timeAgo,
+ )
+ }
details.lastHeard?.let { timestamp ->
val timeAgo = formatTimeAgo(timestamp)
InterfaceDetailRow(
diff --git a/app/src/main/java/com/lxmf/messenger/ui/screens/NomadNetBrowserScreen.kt b/app/src/main/java/com/lxmf/messenger/ui/screens/NomadNetBrowserScreen.kt
index 622af6e42..54da914a1 100644
--- a/app/src/main/java/com/lxmf/messenger/ui/screens/NomadNetBrowserScreen.kt
+++ b/app/src/main/java/com/lxmf/messenger/ui/screens/NomadNetBrowserScreen.kt
@@ -1,6 +1,7 @@
package com.lxmf.messenger.ui.screens
import android.content.Intent
+import android.webkit.MimeTypeMap
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.awaitEachGesture
@@ -79,12 +80,15 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
+import androidx.core.content.FileProvider
import androidx.hilt.navigation.compose.hiltViewModel
import com.lxmf.messenger.ui.components.MicronPageContent
import com.lxmf.messenger.viewmodel.NomadNetBrowserViewModel
import com.lxmf.messenger.viewmodel.NomadNetBrowserViewModel.BrowserState
import com.lxmf.messenger.viewmodel.NomadNetBrowserViewModel.NavigationEvent
import com.lxmf.messenger.viewmodel.NomadNetBrowserViewModel.RenderingMode
+import java.io.File
+import java.util.Locale
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@@ -105,6 +109,7 @@ fun NomadNetBrowserScreen(
val partialStates by viewModel.partialStates.collectAsState()
val isPullRefreshing by viewModel.isPullRefreshing.collectAsState()
val canGoBack by viewModel.canGoBack.collectAsState()
+ val downloadState by viewModel.downloadState.collectAsState()
var showMenu by remember { mutableStateOf(false) }
var showIdentifyConfirm by remember { mutableStateOf(false) }
val currentPage =
@@ -156,6 +161,23 @@ fun NomadNetBrowserScreen(
}
}
+ // Show download dialog when download is active or completed
+ if (downloadState.isActive || downloadState.filePath != null || downloadState.error != null) {
+ NomadNetDownloadDialog(
+ downloadState = downloadState,
+ onDismiss = { viewModel.clearDownload() },
+ onCancel = { viewModel.cancelDownload() },
+ onOpen = { path ->
+ openDownloadedFile(context, path)
+ viewModel.clearDownload()
+ },
+ onShare = { path ->
+ shareDownloadedFile(context, path)
+ viewModel.clearDownload()
+ },
+ )
+ }
+
if (showIdentifyConfirm) {
androidx.compose.material3.AlertDialog(
onDismissRequest = { showIdentifyConfirm = false },
@@ -566,3 +588,126 @@ fun NomadNetBrowserScreen(
}
}
}
+
+@Composable
+private fun NomadNetDownloadDialog(
+ downloadState: NomadNetBrowserViewModel.DownloadState,
+ onDismiss: () -> Unit,
+ onCancel: () -> Unit,
+ onOpen: (String) -> Unit,
+ onShare: (String) -> Unit,
+) {
+ androidx.compose.material3.AlertDialog(
+ onDismissRequest = { if (!downloadState.isActive) onDismiss() },
+ title = {
+ Text(
+ if (downloadState.isActive) {
+ "Downloading..."
+ } else if (downloadState.error != null) {
+ "Download Failed"
+ } else {
+ "Download Complete"
+ },
+ )
+ },
+ text = {
+ Column {
+ if (downloadState.isActive) {
+ Text(downloadState.fileName, style = MaterialTheme.typography.bodyMedium)
+ Spacer(modifier = Modifier.height(12.dp))
+ LinearProgressIndicator(
+ progress = { downloadState.progress },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ "${(downloadState.progress * 100).toInt()}%",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ } else if (downloadState.error != null) {
+ Text(
+ downloadState.error,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error,
+ )
+ } else {
+ Text(downloadState.fileName, style = MaterialTheme.typography.bodyMedium)
+ Spacer(modifier = Modifier.height(4.dp))
+ Text(
+ formatFileSize(downloadState.fileSize),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ },
+ confirmButton = {
+ if (downloadState.isActive) {
+ TextButton(onClick = onCancel) { Text("Cancel") }
+ } else if (downloadState.filePath != null) {
+ TextButton(onClick = { onOpen(downloadState.filePath) }) { Text("Open") }
+ } else {
+ TextButton(onClick = onDismiss) { Text("OK") }
+ }
+ },
+ dismissButton = {
+ if (!downloadState.isActive && downloadState.filePath != null) {
+ TextButton(onClick = { onShare(downloadState.filePath) }) { Text("Share") }
+ } else if (!downloadState.isActive) {
+ TextButton(onClick = onDismiss) { Text("Close") }
+ }
+ },
+ )
+}
+
+private fun openDownloadedFile(
+ context: android.content.Context,
+ filePath: String,
+) {
+ try {
+ val file = File(filePath)
+ val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
+ val mimeType = getMimeTypeFromFileName(file.name)
+ val intent =
+ Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(uri, mimeType)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.startActivity(Intent.createChooser(intent, "Open with"))
+ } catch (_: Exception) {
+ // No app available to handle this file type
+ }
+}
+
+private fun shareDownloadedFile(
+ context: android.content.Context,
+ filePath: String,
+) {
+ try {
+ val file = File(filePath)
+ val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
+ val mimeType = getMimeTypeFromFileName(file.name)
+ val intent =
+ Intent(Intent.ACTION_SEND).apply {
+ type = mimeType
+ putExtra(Intent.EXTRA_STREAM, uri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ context.startActivity(Intent.createChooser(intent, "Share file"))
+ } catch (_: Exception) {
+ // No app available to share
+ }
+}
+
+private fun getMimeTypeFromFileName(fileName: String): String {
+ val extension = fileName.substringAfterLast('.', "").lowercase(Locale.ROOT)
+ return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: "application/octet-stream"
+}
+
+private fun formatFileSize(bytes: Long): String =
+ when {
+ bytes < 1024 -> "$bytes B"
+ bytes < 1024 * 1024 -> "${bytes / 1024} KB"
+ else -> "%.1f MB".format(bytes / (1024.0 * 1024.0))
+ }
diff --git a/app/src/main/java/com/lxmf/messenger/ui/util/InterfaceInfo.kt b/app/src/main/java/com/lxmf/messenger/ui/util/InterfaceInfo.kt
index ba8d01bbd..d791d8d59 100644
--- a/app/src/main/java/com/lxmf/messenger/ui/util/InterfaceInfo.kt
+++ b/app/src/main/java/com/lxmf/messenger/ui/util/InterfaceInfo.kt
@@ -4,6 +4,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.CellTower
import androidx.compose.material.icons.filled.Cloud
+import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.SettingsInputAntenna
import androidx.compose.material.icons.filled.Wifi
import androidx.compose.ui.graphics.vector.ImageVector
@@ -16,17 +17,22 @@ data class InterfaceInfo(
/**
* Enum representing known interface categories with their display properties.
+ * Used by both the announce detail screen and the map interface pins.
*/
-private enum class InterfaceCategory(
+enum class InterfaceCategory(
val icon: ImageVector,
+ val markerIconResId: Int,
val defaultText: String,
+ val markerColor: Int,
) {
- AUTO(Icons.Default.Wifi, "Local Network"),
- TCP(Icons.Default.Cloud, "TCP/IP"),
- BLUETOOTH(Icons.Default.Bluetooth, "Bluetooth"),
- LORA(Icons.Default.CellTower, "LoRa Radio"),
- SERIAL(Icons.Default.SettingsInputAntenna, "Serial"),
- UNKNOWN(Icons.Default.SettingsInputAntenna, ""),
+ AUTO(Icons.Default.Wifi, com.composables.icons.lucide.R.drawable.lucide_ic_wifi, "Local Network", 0xFF2E7D32.toInt()),
+ TCP(Icons.Default.Public, com.lxmf.messenger.R.drawable.ic_public_24, "TCP/IP", 0xFF1565C0.toInt()),
+ BLUETOOTH(Icons.Default.Bluetooth, com.composables.icons.lucide.R.drawable.lucide_ic_bluetooth, "Bluetooth", 0xFF283593.toInt()),
+ LORA(Icons.Default.CellTower, com.composables.icons.lucide.R.drawable.lucide_ic_antenna, "LoRa Radio", 0xFFE64A19.toInt()),
+ I2P(Icons.Default.Cloud, com.lxmf.messenger.R.drawable.ic_incognito_24, "I2P", 0xFF7B1FA2.toInt()),
+ YGGDRASIL(Icons.Default.Cloud, com.composables.icons.lucide.R.drawable.lucide_ic_tree_pine, "Yggdrasil", 0xFF00695C.toInt()),
+ SERIAL(Icons.Default.SettingsInputAntenna, com.composables.icons.lucide.R.drawable.lucide_ic_antenna, "Serial", 0xFF616161.toInt()),
+ UNKNOWN(Icons.Default.SettingsInputAntenna, com.composables.icons.lucide.R.drawable.lucide_ic_antenna, "", 0xFF9E9E9E.toInt()),
}
/**
@@ -66,27 +72,52 @@ private fun extractInterfaceType(interfaceName: String): String = interfaceName.
/**
* Determine the interface category based on the interface name.
*/
-private fun categorizeInterface(interfaceName: String): InterfaceCategory {
+internal fun categorizeInterface(interfaceName: String): InterfaceCategory = categorizeInterface(interfaceName, host = null)
+
+/**
+ * Determine the interface category based on the interface name and optional host.
+ * The host is used to distinguish Yggdrasil TCP interfaces from regular TCP.
+ */
+internal fun categorizeInterface(
+ interfaceName: String,
+ host: String?,
+): InterfaceCategory {
val lowerName = interfaceName.lowercase()
return when {
lowerName.contains("autointerface") ||
lowerName.contains("auto discovery") ||
lowerName.startsWith("auto") -> InterfaceCategory.AUTO
- lowerName.contains("tcp") || lowerName.contains("backbone") -> InterfaceCategory.TCP
+ lowerName.contains("i2p") -> InterfaceCategory.I2P
lowerName.contains("rnode") ||
- lowerName.contains("lora") -> InterfaceCategory.LORA
+ lowerName.contains("lora") ||
+ lowerName.contains("weave") ||
+ lowerName.contains("kiss") -> InterfaceCategory.LORA
lowerName.contains("ble") ||
lowerName.contains("bluetooth") ||
lowerName.contains("androidble") -> InterfaceCategory.BLUETOOTH
+ lowerName.contains("tcp") || lowerName.contains("backbone") ->
+ if (isYggdrasilHost(host)) InterfaceCategory.YGGDRASIL else InterfaceCategory.TCP
lowerName.contains("serial") -> InterfaceCategory.SERIAL
else -> InterfaceCategory.UNKNOWN
}
}
+/**
+ * Check if a host address belongs to the Yggdrasil network (IPv6 in 0200::/7 space).
+ */
+private fun isYggdrasilHost(host: String?): Boolean {
+ if (host == null) return false
+ val clean = host.trim().removePrefix("[").removeSuffix("]")
+ val firstSegment = clean.takeIf { it.contains(":") }?.split(":")?.firstOrNull()
+ val value = firstSegment?.toIntOrNull(16) ?: return false
+ return value in 0x0200..0x03FF
+}
+
fun getInterfaceInfo(interfaceName: String): InterfaceInfo {
val friendlyName = extractFriendlyName(interfaceName)
val interfaceType = extractInterfaceType(interfaceName)
val category = categorizeInterface(interfaceName)
+ val bracketContent = interfaceName.substringAfter("[", "").substringBefore("]", "")
val displayText =
when (category) {
@@ -94,9 +125,18 @@ fun getInterfaceInfo(interfaceName: String): InterfaceInfo {
else -> friendlyName ?: category.defaultText
}
+ // When there's no friendly name but bracket content exists (e.g., an IP address),
+ // show it as the subtitle instead of just the interface class name
+ val subtitle =
+ if (friendlyName == null && bracketContent.isNotEmpty()) {
+ "$interfaceType — $bracketContent"
+ } else {
+ interfaceType
+ }
+
return InterfaceInfo(
icon = category.icon,
text = displayText,
- subtitle = interfaceType,
+ subtitle = subtitle,
)
}
diff --git a/app/src/main/java/com/lxmf/messenger/ui/util/MarkerBitmapFactory.kt b/app/src/main/java/com/lxmf/messenger/ui/util/MarkerBitmapFactory.kt
index 0b28c213d..b9a5ab2f5 100644
--- a/app/src/main/java/com/lxmf/messenger/ui/util/MarkerBitmapFactory.kt
+++ b/app/src/main/java/com/lxmf/messenger/ui/util/MarkerBitmapFactory.kt
@@ -237,6 +237,63 @@ object MarkerBitmapFactory {
return bitmap
}
+ /**
+ * Creates a rounded-rectangle marker for a discovered network interface.
+ * Visually distinct from contact markers (circle) to avoid confusion.
+ *
+ * @param iconResId Drawable resource ID for the icon (e.g., Lucide antenna, Material globe)
+ * @param backgroundColor The category-specific color
+ * @param sizeDp The marker size in dp (default 32, smaller than contact markers)
+ * @param density Screen density for dp to px conversion
+ * @param context Context for loading the drawable
+ * @return A bitmap with the marker
+ */
+ fun createInterfaceMarker(
+ iconResId: Int,
+ backgroundColor: Int,
+ sizeDp: Float = 32f,
+ density: Float,
+ context: Context,
+ ): Bitmap {
+ val sizePx = (sizeDp * density).toInt()
+ val bitmap = Bitmap.createBitmap(sizePx, sizePx, Bitmap.Config.ARGB_8888)
+ val canvas = Canvas(bitmap)
+
+ val padding = 2f * density
+ val cornerRadius = 6f * density
+
+ // Draw rounded rectangle background
+ val bgPaint =
+ Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = backgroundColor
+ style = Paint.Style.FILL
+ }
+ val rect = android.graphics.RectF(padding, padding, sizePx - padding, sizePx - padding)
+ canvas.drawRoundRect(rect, cornerRadius, cornerRadius, bgPaint)
+
+ // Draw white border
+ val borderPaint =
+ Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = Color.WHITE
+ style = Paint.Style.STROKE
+ strokeWidth = 2f * density
+ }
+ canvas.drawRoundRect(rect, cornerRadius, cornerRadius, borderPaint)
+
+ // Draw vector icon centered
+ val iconPadding = (sizePx * 0.22f).toInt()
+ val drawable =
+ androidx.core.content.ContextCompat
+ .getDrawable(context, iconResId)
+ if (drawable != null) {
+ drawable.setBounds(iconPadding, iconPadding, sizePx - iconPadding, sizePx - iconPadding)
+ drawable.setTint(Color.WHITE)
+ drawable.draw(canvas)
+ }
+
+ return bitmap
+ }
+
/**
* Creates a dashed circle ring bitmap for stale location markers.
*
diff --git a/app/src/main/java/com/lxmf/messenger/viewmodel/MapViewModel.kt b/app/src/main/java/com/lxmf/messenger/viewmodel/MapViewModel.kt
index acfa411c3..92bad1f56 100644
--- a/app/src/main/java/com/lxmf/messenger/viewmodel/MapViewModel.kt
+++ b/app/src/main/java/com/lxmf/messenger/viewmodel/MapViewModel.kt
@@ -15,10 +15,13 @@ import com.lxmf.messenger.data.repository.OfflineMapRegionRepository
import com.lxmf.messenger.map.MapStyleResult
import com.lxmf.messenger.map.MapTileSourceManager
import com.lxmf.messenger.repository.SettingsRepository
+import com.lxmf.messenger.reticulum.protocol.ReticulumProtocol
import com.lxmf.messenger.service.LocationSharingManager
import com.lxmf.messenger.service.SharingSession
import com.lxmf.messenger.service.TelemetryCollectorManager
import com.lxmf.messenger.ui.model.SharingDuration
+import com.lxmf.messenger.ui.util.InterfaceCategory
+import com.lxmf.messenger.ui.util.categorizeInterface
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -27,6 +30,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
@@ -73,6 +77,51 @@ data class ContactMarker(
val publicKey: ByteArray? = null,
)
+/**
+ * Represents a discovered network interface with a known location, shown as a map pin.
+ */
+@Immutable
+data class InterfaceMarker(
+ val id: String,
+ val name: String,
+ val type: String,
+ val category: com.lxmf.messenger.ui.util.InterfaceCategory,
+ val latitude: Double,
+ val longitude: Double,
+ val height: Double? = null,
+ val frequency: Long? = null,
+ val bandwidth: Int? = null,
+ val spreadingFactor: Int? = null,
+ val codingRate: Int? = null,
+ val modulation: String? = null,
+ val reachableOn: String? = null,
+ val port: Int? = null,
+ val status: String,
+ val lastHeard: Long,
+ val hops: Int,
+ val firstSeen: Long? = null,
+)
+
+internal fun InterfaceMarker.toFocusInterfaceDetails() =
+ com.lxmf.messenger.ui.screens.FocusInterfaceDetails(
+ name = name,
+ type = type,
+ latitude = latitude,
+ longitude = longitude,
+ height = height,
+ reachableOn = reachableOn,
+ port = port,
+ frequency = frequency,
+ bandwidth = bandwidth,
+ spreadingFactor = spreadingFactor,
+ codingRate = codingRate,
+ modulation = modulation,
+ status = status,
+ lastHeard = lastHeard,
+ hops = hops,
+ firstSeen = firstSeen,
+ )
+
internal fun deduplicateContactMarkersByDestination(markers: List): List =
markers
.groupBy { it.destinationHash.lowercase() }
@@ -110,6 +159,8 @@ data class MapState(
val isSendingTelemetry: Boolean = false,
val isRequestingTelemetry: Boolean = false,
val mapMarkerDeclutterEnabled: Boolean = true,
+ val interfaceMarkers: List = emptyList(),
+ val interfaceFilterEnabled: Map = emptyMap(),
/** Center coordinates of the default offline map region (fallback when no GPS) */
val defaultRegionCenter: SavedCameraPosition? = null,
/** Whether the default region lookup has completed (even if no region was found) */
@@ -127,7 +178,7 @@ data class MapState(
* - Location sharing state
* - Location permission state
*/
-@Suppress("TooManyFunctions")
+@Suppress("TooManyFunctions", "LongParameterList") // TODO: extract MapDataSources wrapper to reduce constructor params
@HiltViewModel
class MapViewModel
@Inject
@@ -142,6 +193,8 @@ class MapViewModel
private val telemetryCollectorManager: TelemetryCollectorManager,
private val offlineMapRegionRepository: OfflineMapRegionRepository,
private val identityRepository: IdentityRepository,
+ private val reticulumProtocol: ReticulumProtocol,
+ private val interfaceFirstSeenDao: com.lxmf.messenger.data.db.dao.InterfaceFirstSeenDao,
) : ViewModel() {
companion object {
private const val TAG = "MapViewModel"
@@ -294,7 +347,7 @@ class MapViewModel
combine(
receivedLocationDao.getLatestLocationsPerSenderUnfiltered(),
contacts,
- announceDao.getEnrichedAnnounces(),
+ announceDao.getAnnouncesForLocationSenders(),
_refreshTrigger,
identityRepository.activeIdentity,
) { locations, contactList, announceList, _, activeIdentity ->
@@ -311,63 +364,64 @@ class MapViewModel
Log.d(TAG, "Processing ${locations.size} locations, ${contactList.size} contacts, ${announceList.size} announces")
- locations.mapNotNull { loc ->
- // Ignore self-echo telemetry entries from collector streams.
- val senderHash = loc.senderHash.lowercase()
- val isSelfEcho = localHashes.any { localHash -> senderHash == localHash }
- if (isSelfEcho) {
- return@mapNotNull null
- }
-
- // Calculate marker state - returns null if marker should be hidden
- // Use sender emission timestamp for freshness/staleness semantics:
- // a coordinate emitted long ago should be treated as stale,
- // even if it was received only recently.
- val markerState =
- calculateMarkerState(
+ locations
+ .mapNotNull { loc ->
+ // Ignore self-echo telemetry entries from collector streams.
+ val senderHash = loc.senderHash.lowercase()
+ val isSelfEcho = localHashes.any { localHash -> senderHash == localHash }
+ if (isSelfEcho) {
+ return@mapNotNull null
+ }
+
+ // Calculate marker state - returns null if marker should be hidden
+ // Use sender emission timestamp for freshness/staleness semantics:
+ // a coordinate emitted long ago should be treated as stale,
+ // even if it was received only recently.
+ val markerState =
+ calculateMarkerState(
+ timestamp = loc.timestamp,
+ expiresAt = loc.expiresAt,
+ currentTime = currentTime,
+ ) ?: return@mapNotNull null
+
+ // Look up announce for icon data and name fallback
+ val announce =
+ announceMap[loc.senderHash]
+ ?: announceMapLower[loc.senderHash.lowercase()]
+
+ // Try contacts first (exact, then case-insensitive)
+ // Then try announces (exact, then case-insensitive)
+ val displayName =
+ contactMap[loc.senderHash]?.displayName
+ ?: contactMapLower[loc.senderHash.lowercase()]?.displayName
+ ?: announce?.peerName
+ ?: loc.senderHash.take(8)
+
+ if (displayName == loc.senderHash.take(8)) {
+ Log.w(TAG, "No name found for senderHash: ${loc.senderHash}")
+ }
+
+ // Prefer appearance from telemetry message, fall back to announce
+ val telemetryAppearance = parseAppearanceJson(loc.appearanceJson)
+
+ ContactMarker(
+ destinationHash = loc.senderHash,
+ displayName = displayName,
+ latitude = loc.latitude,
+ longitude = loc.longitude,
+ accuracy = loc.accuracy,
+ // Display sender emission timestamp in UI (requested behavior).
+ // Freshness/staleness is based on sender emission time (timestamp) per calculateMarkerState above.
timestamp = loc.timestamp,
expiresAt = loc.expiresAt,
- currentTime = currentTime,
- ) ?: return@mapNotNull null
-
- // Look up announce for icon data and name fallback
- val announce =
- announceMap[loc.senderHash]
- ?: announceMapLower[loc.senderHash.lowercase()]
-
- // Try contacts first (exact, then case-insensitive)
- // Then try announces (exact, then case-insensitive)
- val displayName =
- contactMap[loc.senderHash]?.displayName
- ?: contactMapLower[loc.senderHash.lowercase()]?.displayName
- ?: announce?.peerName
- ?: loc.senderHash.take(8)
-
- if (displayName == loc.senderHash.take(8)) {
- Log.w(TAG, "No name found for senderHash: ${loc.senderHash}")
- }
-
- // Prefer appearance from telemetry message, fall back to announce
- val telemetryAppearance = parseAppearanceJson(loc.appearanceJson)
-
- ContactMarker(
- destinationHash = loc.senderHash,
- displayName = displayName,
- latitude = loc.latitude,
- longitude = loc.longitude,
- accuracy = loc.accuracy,
- // Display sender emission timestamp in UI (requested behavior).
- // Freshness/staleness is based on sender emission time (timestamp) per calculateMarkerState above.
- timestamp = loc.timestamp,
- expiresAt = loc.expiresAt,
- state = markerState,
- approximateRadius = loc.approximateRadius,
- iconName = telemetryAppearance?.first ?: announce?.iconName,
- iconForegroundColor = telemetryAppearance?.second ?: announce?.iconForegroundColor,
- iconBackgroundColor = telemetryAppearance?.third ?: announce?.iconBackgroundColor,
- publicKey = announce?.publicKey,
- )
- }.let(::deduplicateContactMarkersByDestination)
+ state = markerState,
+ approximateRadius = loc.approximateRadius,
+ iconName = telemetryAppearance?.first ?: announce?.iconName,
+ iconForegroundColor = telemetryAppearance?.second ?: announce?.iconForegroundColor,
+ iconBackgroundColor = telemetryAppearance?.third ?: announce?.iconBackgroundColor,
+ publicKey = announce?.publicKey,
+ )
+ }.let(::deduplicateContactMarkersByDestination)
}.collect { markers ->
_state.update { currentState ->
currentState.copy(
@@ -399,6 +453,15 @@ class MapViewModel
while (isActive) {
delay(REFRESH_INTERVAL_MS)
_refreshTrigger.value = System.currentTimeMillis()
+ loadInterfaceMarkers()
+ }
+ }
+ // Initial load of interface markers (retry after 5s if service wasn't ready)
+ viewModelScope.launch {
+ loadInterfaceMarkers()
+ if (_state.value.interfaceMarkers.isEmpty()) {
+ delay(5_000L)
+ loadInterfaceMarkers()
}
}
}
@@ -575,6 +638,82 @@ class MapViewModel
_state.update { it.copy(lastCameraPosition = null) }
}
+ // ==================== Interface Markers ====================
+
+ fun toggleInterfaceFilter(category: InterfaceCategory) {
+ _state.update { current ->
+ val newFilters = current.interfaceFilterEnabled.toMutableMap()
+ newFilters[category] = !(newFilters[category] ?: true)
+ current.copy(interfaceFilterEnabled = newFilters)
+ }
+ }
+
+ val filteredInterfaceMarkers: StateFlow> =
+ _state
+ .map { s ->
+ s.interfaceMarkers.filter { marker ->
+ s.interfaceFilterEnabled[marker.category] ?: true
+ }
+ }.stateIn(viewModelScope, SharingStarted.Eagerly, emptyList())
+
+ private suspend fun loadInterfaceMarkers() {
+ try {
+ val discovered = reticulumProtocol.getDiscoveredInterfaces()
+ val withLocation = discovered.filter { it.hasLocation }
+
+ // Compute IDs once and persist first-seen timestamps
+ // (INSERT OR IGNORE preserves originals)
+ val now = System.currentTimeMillis() / 1000
+ val withId =
+ withLocation.map { iface ->
+ val id = "${iface.name}\u0000${iface.type}\u0000${iface.reachableOn ?: ""}"
+ interfaceFirstSeenDao.insertIfNotExists(
+ com.lxmf.messenger.data.db.entity
+ .InterfaceFirstSeenEntity(id, now),
+ )
+ id to iface
+ }
+
+ // Batch-fetch first-seen timestamps
+ val ids = withId.map { it.first }
+ val firstSeenMap =
+ if (ids.isNotEmpty()) {
+ interfaceFirstSeenDao
+ .getFirstSeenBatch(ids)
+ .associate { it.interfaceId to it.firstSeenTimestamp }
+ } else {
+ emptyMap()
+ }
+
+ val markers =
+ withId.map { (id, iface) ->
+ InterfaceMarker(
+ id = id,
+ name = iface.name,
+ type = iface.type,
+ category = categorizeInterface(iface.type, iface.reachableOn),
+ latitude = iface.latitude!!,
+ longitude = iface.longitude!!,
+ height = iface.height,
+ frequency = iface.frequency,
+ bandwidth = iface.bandwidth,
+ spreadingFactor = iface.spreadingFactor,
+ codingRate = iface.codingRate,
+ modulation = iface.modulation,
+ reachableOn = iface.reachableOn,
+ port = iface.port,
+ status = iface.status,
+ lastHeard = iface.lastHeard,
+ hops = iface.hops,
+ firstSeen = firstSeenMap[id],
+ )
+ }
+ _state.update { it.copy(interfaceMarkers = markers) }
+ } catch (e: Exception) {
+ Log.w(TAG, "Failed to load interface markers", e)
+ }
+ }
+
/**
* Start sharing location with selected contacts.
*
diff --git a/app/src/main/java/com/lxmf/messenger/viewmodel/NomadNetBrowserViewModel.kt b/app/src/main/java/com/lxmf/messenger/viewmodel/NomadNetBrowserViewModel.kt
index fd1270852..7370b4448 100644
--- a/app/src/main/java/com/lxmf/messenger/viewmodel/NomadNetBrowserViewModel.kt
+++ b/app/src/main/java/com/lxmf/messenger/viewmodel/NomadNetBrowserViewModel.kt
@@ -96,6 +96,18 @@ class NomadNetBrowserViewModel
private val _isPullRefreshing = MutableStateFlow(false)
val isPullRefreshing: StateFlow = _isPullRefreshing.asStateFlow()
+ data class DownloadState(
+ val isActive: Boolean = false,
+ val progress: Float = 0f,
+ val fileName: String = "",
+ val filePath: String? = null,
+ val fileSize: Long = 0L,
+ val error: String? = null,
+ )
+
+ private val _downloadState = MutableStateFlow(DownloadState())
+ val downloadState: StateFlow = _downloadState.asStateFlow()
+
private val _navigationEvent = MutableSharedFlow()
val navigationEvent: SharedFlow = _navigationEvent
@@ -116,6 +128,9 @@ class NomadNetBrowserViewModel
@Volatile
private var fetchEpoch = 0
+ @Volatile
+ private var statusPollingJob: kotlinx.coroutines.Job? = null
+
private val partialManager: PartialManager? by lazy {
(reticulumProtocol as? ServiceReticulumProtocol)?.let { protocol ->
PartialManager(
@@ -195,6 +210,8 @@ class NomadNetBrowserViewModel
// Check cache before showing loading spinner
val cached = pageCache.get(destinationHash, path)
if (cached != null) {
+ fetchEpoch++ // Invalidate any in-flight request
+ stopStatusPolling()
val document = MicronParser.parse(cached)
emitPageLoaded(document, path, destinationHash)
return
@@ -226,14 +243,36 @@ class NomadNetBrowserViewModel
partialManager?.clear()
- // Collect form field values for submission
+ // Collect form field values for submission.
+ // NomadNet link fields can be:
+ // - "fieldname" → look up value from form fields
+ // - "key=value" → inline variable (sent as "var_key")
+ // - "*" → submit all form fields
val isFormSubmission = fieldNames.isNotEmpty()
val formDataJson =
if (isFormSubmission) {
val data = JSONObject()
- for (fieldName in fieldNames) {
- val value = _formFields.value[fieldName] ?: ""
- data.put(fieldName, value)
+ val submitAll = "*" in fieldNames
+ for (fieldEntry in fieldNames) {
+ if (fieldEntry == "*") continue
+ if ("=" in fieldEntry) {
+ // Inline variable: "key=value" → sent as "var_key"
+ val eqIdx = fieldEntry.indexOf('=')
+ val key = fieldEntry.substring(0, eqIdx)
+ val value = fieldEntry.substring(eqIdx + 1)
+ data.put("var_$key", value)
+ } else {
+ // Form field reference: look up value from form state
+ val value = _formFields.value[fieldEntry] ?: ""
+ data.put(fieldEntry, value)
+ }
+ }
+ if (submitAll) {
+ for ((key, value) in _formFields.value) {
+ if (!data.has(key)) {
+ data.put(key, value)
+ }
+ }
}
data.toString()
} else {
@@ -249,12 +288,16 @@ class NomadNetBrowserViewModel
_formFields.value = emptyMap()
// Form submissions always fetch fresh (response depends on submitted data)
- if (isFormSubmission) {
+ if (path.startsWith("/file/")) {
+ downloadFile(nodeHash, path)
+ } else if (isFormSubmission) {
submitFormAndNavigate(nodeHash, path, formDataJson!!)
} else {
// Non-form link: check cache first
val cached = pageCache.get(nodeHash, path)
if (cached != null) {
+ fetchEpoch++ // Invalidate any in-flight request
+ stopStatusPolling()
currentNodeHash = nodeHash
val document = MicronParser.parse(cached)
emitPageLoaded(document, path, nodeHash)
@@ -269,15 +312,18 @@ class NomadNetBrowserViewModel
path: String,
formDataJson: String,
) {
- fetchEpoch++
+ val epoch = ++fetchEpoch
lastFetchNodeHash = nodeHash
lastFetchPath = path
lastFetchFormDataJson = formDataJson
_browserState.value = BrowserState.Loading("Requesting page...")
+ startStatusPolling(epoch)
viewModelScope.launch(Dispatchers.IO) {
try {
val protocol = reticulumProtocol as? ServiceReticulumProtocol
if (protocol == null) {
+ stopStatusPolling(epoch)
+ if (fetchEpoch != epoch) return@launch
_browserState.value = BrowserState.Error("Service not available")
return@launch
}
@@ -290,6 +336,10 @@ class NomadNetBrowserViewModel
timeoutSeconds = PAGE_TIMEOUT_SECONDS,
)
+ stopStatusPolling(epoch)
+
+ if (fetchEpoch != epoch) return@launch
+
result.fold(
onSuccess = { pageResult ->
currentNodeHash = nodeHash
@@ -304,15 +354,114 @@ class NomadNetBrowserViewModel
},
)
} catch (e: Exception) {
+ stopStatusPolling(epoch)
+ if (fetchEpoch != epoch) return@launch
Log.e(TAG, "Error navigating", e)
_browserState.value = BrowserState.Error(e.message ?: "Unknown error")
}
}
}
+ private fun downloadFile(
+ nodeHash: String,
+ path: String,
+ ) {
+ val downloadEpoch = ++fetchEpoch
+ _downloadState.value = DownloadState(isActive = true, fileName = path.substringAfterLast("/"))
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ val protocol = reticulumProtocol as? ServiceReticulumProtocol
+ if (protocol == null) {
+ _downloadState.update { it.copy(isActive = false, error = "Service not available") }
+ return@launch
+ }
+
+ // Poll progress in a separate coroutine
+ val progressJob =
+ launch {
+ try {
+ while (fetchEpoch == downloadEpoch) {
+ kotlinx.coroutines.delay(300)
+ if (fetchEpoch != downloadEpoch) break
+ val progress = protocol.getNomadnetDownloadProgress()
+ if (progress >= 0f) {
+ _downloadState.update { it.copy(progress = progress) }
+ }
+ }
+ } catch (_: kotlinx.coroutines.CancellationException) {
+ // Normal shutdown
+ }
+ }
+
+ val result =
+ protocol.requestNomadnetPage(
+ destinationHash = nodeHash,
+ path = path,
+ timeoutSeconds = PAGE_TIMEOUT_SECONDS * 2,
+ )
+
+ progressJob.cancel()
+
+ // If user cancelled while download was in progress, don't update state
+ if (fetchEpoch != downloadEpoch) return@launch
+
+ result.fold(
+ onSuccess = { pageResult ->
+ if (pageResult.type == "file") {
+ _downloadState.value =
+ DownloadState(
+ isActive = false,
+ progress = 1f,
+ fileName = pageResult.fileName ?: path.substringAfterLast("/"),
+ filePath = pageResult.filePath,
+ fileSize = pageResult.fileSize,
+ )
+ } else {
+ // Unexpected page response for /file/ path — show the page
+ _downloadState.value = DownloadState()
+ currentNodeHash = nodeHash
+ val document = MicronParser.parse(pageResult.content)
+ emitPageLoaded(document, pageResult.path, nodeHash)
+ }
+ },
+ onFailure = { error ->
+ _downloadState.update {
+ it.copy(isActive = false, error = error.message ?: "Download failed")
+ }
+ },
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "Error downloading file", e)
+ _downloadState.update {
+ it.copy(isActive = false, error = e.message ?: "Download failed")
+ }
+ }
+ }
+ }
+
+ fun cancelDownload() {
+ fetchEpoch++
+ _downloadState.value = DownloadState()
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ (reticulumProtocol as? ServiceReticulumProtocol)?.cancelNomadnetPageRequest()
+ } catch (e: Exception) {
+ Log.e(TAG, "Error cancelling download", e)
+ }
+ }
+ }
+
+ fun clearDownload() {
+ _downloadState.value = DownloadState()
+ }
+
fun goBack(): Boolean {
if (history.isEmpty()) return false
+ // Invalidate any in-flight request so its result doesn't overwrite the page we're navigating back to
+ fetchEpoch++
+ stopStatusPolling()
+
partialManager?.clear()
val entry = history.removeAt(history.lastIndex)
_canGoBack.value = history.isNotEmpty()
@@ -347,6 +496,7 @@ class NomadNetBrowserViewModel
fun cancelLoading() {
val epoch = ++fetchEpoch
+ stopStatusPolling()
_browserState.value = BrowserState.Error("Cancelled")
_isPullRefreshing.value = false
viewModelScope.launch(Dispatchers.IO) {
@@ -400,6 +550,7 @@ class NomadNetBrowserViewModel
override fun onCleared() {
super.onCleared()
+ stopStatusPolling()
// Cancel any in-flight Python page request so the IO thread isn't blocked
// for up to PAGE_TIMEOUT_SECONDS after the user navigates away.
// Use NonCancellable because viewModelScope is already cancelled at this point.
@@ -451,6 +602,41 @@ class NomadNetBrowserViewModel
partialManager?.detectAndLoad(document)
}
+ /**
+ * Start polling Python for request phase status ("Looking up path...", etc.).
+ * Runs on Dispatchers.IO in viewModelScope. Automatically stops when fetchEpoch changes
+ * or the coroutine is cancelled.
+ */
+ private fun startStatusPolling(epoch: Int) {
+ statusPollingJob?.cancel()
+ val protocol = reticulumProtocol as? ServiceReticulumProtocol ?: return
+ statusPollingJob =
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ while (fetchEpoch == epoch) {
+ kotlinx.coroutines.delay(200)
+ if (fetchEpoch != epoch) break
+ val status = protocol.getNomadnetRequestStatus()
+ if (status.isNotEmpty()) {
+ _browserState.value = BrowserState.Loading(status)
+ }
+ }
+ } catch (_: kotlinx.coroutines.CancellationException) {
+ // Normal shutdown
+ } catch (e: Exception) {
+ Log.d(TAG, "Status polling stopped: ${e.message}")
+ }
+ }
+ }
+
+ private fun stopStatusPolling(epoch: Int? = null) {
+ // If epoch is provided, only stop if it still matches (prevents
+ // a stale IO coroutine from killing a newer navigation's poller)
+ if (epoch != null && fetchEpoch != epoch) return
+ statusPollingJob?.cancel()
+ statusPollingJob = null
+ }
+
/**
* Fetch a page from the network, optionally caching the response.
*/
@@ -459,23 +645,24 @@ class NomadNetBrowserViewModel
path: String,
cacheResponse: Boolean,
) {
- fetchEpoch++
+ val epoch = ++fetchEpoch
lastFetchNodeHash = nodeHash
lastFetchPath = path
lastFetchFormDataJson = null
_browserState.value = BrowserState.Loading("Requesting page...")
+ startStatusPolling(epoch)
viewModelScope.launch(Dispatchers.IO) {
try {
val protocol = reticulumProtocol as? ServiceReticulumProtocol
if (protocol == null) {
+ stopStatusPolling(epoch)
+ if (fetchEpoch != epoch) return@launch
_isPullRefreshing.value = false
_browserState.value = BrowserState.Error("Service not available")
return@launch
}
- _browserState.value = BrowserState.Loading("Connecting to node...")
-
val result =
protocol.requestNomadnetPage(
destinationHash = nodeHash,
@@ -483,14 +670,36 @@ class NomadNetBrowserViewModel
timeoutSeconds = PAGE_TIMEOUT_SECONDS,
)
+ stopStatusPolling(epoch)
+
+ // If user navigated away (back, new link) while we were loading,
+ // discard this stale result to avoid overwriting the current page
+ if (fetchEpoch != epoch) return@launch
+
result.fold(
onSuccess = { pageResult ->
- currentNodeHash = nodeHash
- val document = MicronParser.parse(pageResult.content)
- if (cacheResponse) {
- pageCache.put(nodeHash, pageResult.path, pageResult.content, document.cacheTime)
+ if (pageResult.type == "file") {
+ // Unexpected file response on a page path —
+ // clear loading state so screen doesn't get stuck
+ _isPullRefreshing.value = false
+ _browserState.value =
+ BrowserState.Error("Server returned a file instead of a page")
+ _downloadState.value =
+ DownloadState(
+ isActive = false,
+ progress = 1f,
+ fileName = pageResult.fileName ?: path.substringAfterLast("/"),
+ filePath = pageResult.filePath,
+ fileSize = pageResult.fileSize,
+ )
+ } else {
+ currentNodeHash = nodeHash
+ val document = MicronParser.parse(pageResult.content)
+ if (cacheResponse) {
+ pageCache.put(nodeHash, pageResult.path, pageResult.content, document.cacheTime)
+ }
+ emitPageLoaded(document, pageResult.path, nodeHash)
}
- emitPageLoaded(document, pageResult.path, nodeHash)
},
onFailure = { error ->
_isPullRefreshing.value = false
@@ -501,6 +710,8 @@ class NomadNetBrowserViewModel
},
)
} catch (e: Exception) {
+ stopStatusPolling(epoch)
+ if (fetchEpoch != epoch) return@launch
_isPullRefreshing.value = false
Log.e(TAG, "Error loading page", e)
_browserState.value = BrowserState.Error(e.message ?: "Unknown error")
diff --git a/app/src/main/res/drawable/ic_incognito_24.xml b/app/src/main/res/drawable/ic_incognito_24.xml
new file mode 100644
index 000000000..1921a3705
--- /dev/null
+++ b/app/src/main/res/drawable/ic_incognito_24.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_public_24.xml b/app/src/main/res/drawable/ic_public_24.xml
new file mode 100644
index 000000000..ded46b5cb
--- /dev/null
+++ b/app/src/main/res/drawable/ic_public_24.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/test/java/com/lxmf/messenger/ui/util/InterfaceInfoTest.kt b/app/src/test/java/com/lxmf/messenger/ui/util/InterfaceInfoTest.kt
index 0191063c5..47eac5646 100644
--- a/app/src/test/java/com/lxmf/messenger/ui/util/InterfaceInfoTest.kt
+++ b/app/src/test/java/com/lxmf/messenger/ui/util/InterfaceInfoTest.kt
@@ -3,7 +3,7 @@ package com.lxmf.messenger.ui.util
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.CellTower
-import androidx.compose.material.icons.filled.Cloud
+import androidx.compose.material.icons.filled.Public
import androidx.compose.material.icons.filled.SettingsInputAntenna
import androidx.compose.material.icons.filled.Wifi
import org.junit.Assert.assertEquals
@@ -21,7 +21,7 @@ class InterfaceInfoTest {
fun `TCP interface with user-configured name extracts friendly name`() {
val info = getInterfaceInfo("TCPClientInterface[Sideband Server]")
- assertEquals(Icons.Default.Cloud, info.icon)
+ assertEquals(Icons.Default.Public, info.icon)
assertEquals("Sideband Server", info.text)
assertEquals("TCPClientInterface", info.subtitle)
}
@@ -30,25 +30,25 @@ class InterfaceInfoTest {
fun `TCP interface with name and address extracts friendly name before slash`() {
val info = getInterfaceInfo("TCPClientInterface[Sideband Server/192.168.1.100:4965]")
- assertEquals(Icons.Default.Cloud, info.icon)
+ assertEquals(Icons.Default.Public, info.icon)
assertEquals("Sideband Server", info.text)
assertEquals("TCPClientInterface", info.subtitle)
}
@Test
- fun `TCP interface with only address falls back to TCP IP`() {
+ fun `TCP interface with only address shows address in subtitle`() {
val info = getInterfaceInfo("TCPClientInterface[192.168.1.100:4965]")
- assertEquals(Icons.Default.Cloud, info.icon)
+ assertEquals(Icons.Default.Public, info.icon)
assertEquals("TCP/IP", info.text)
- assertEquals("TCPClientInterface", info.subtitle)
+ assertEquals("TCPClientInterface — 192.168.1.100:4965", info.subtitle)
}
@Test
fun `TCP interface without brackets falls back to TCP IP`() {
val info = getInterfaceInfo("TCPClientInterface")
- assertEquals(Icons.Default.Cloud, info.icon)
+ assertEquals(Icons.Default.Public, info.icon)
assertEquals("TCP/IP", info.text)
assertEquals("TCPClientInterface", info.subtitle)
}
@@ -57,7 +57,7 @@ class InterfaceInfoTest {
fun `TCPInterface variant also recognized`() {
val info = getInterfaceInfo("TCPInterface[My Server]")
- assertEquals(Icons.Default.Cloud, info.icon)
+ assertEquals(Icons.Default.Public, info.icon)
assertEquals("My Server", info.text)
assertEquals("TCPInterface", info.subtitle)
}
@@ -70,7 +70,7 @@ class InterfaceInfoTest {
fun `BackboneInterface with user-configured name extracts friendly name`() {
val info = getInterfaceInfo("BackboneInterface[Beleth RNS Hub]")
- assertEquals(Icons.Default.Cloud, info.icon)
+ assertEquals(Icons.Default.Public, info.icon)
assertEquals("Beleth RNS Hub", info.text)
assertEquals("BackboneInterface", info.subtitle)
}
@@ -79,7 +79,7 @@ class InterfaceInfoTest {
fun `BackboneInterface with name and address extracts friendly name before slash`() {
val info = getInterfaceInfo("BackboneInterface[noDNS2/193.26.158.230:4965]")
- assertEquals(Icons.Default.Cloud, info.icon)
+ assertEquals(Icons.Default.Public, info.icon)
assertEquals("noDNS2", info.text)
assertEquals("BackboneInterface", info.subtitle)
}
@@ -88,7 +88,7 @@ class InterfaceInfoTest {
fun `BackboneClientInterface variant also recognized`() {
val info = getInterfaceInfo("BackboneClientInterface[My Backbone]")
- assertEquals(Icons.Default.Cloud, info.icon)
+ assertEquals(Icons.Default.Public, info.icon)
assertEquals("My Backbone", info.text)
assertEquals("BackboneClientInterface", info.subtitle)
}
@@ -97,7 +97,7 @@ class InterfaceInfoTest {
fun `BackboneInterface without brackets falls back to TCP IP`() {
val info = getInterfaceInfo("BackboneInterface")
- assertEquals(Icons.Default.Cloud, info.icon)
+ assertEquals(Icons.Default.Public, info.icon)
assertEquals("TCP/IP", info.text)
assertEquals("BackboneInterface", info.subtitle)
}
@@ -271,19 +271,19 @@ class InterfaceInfoTest {
}
@Test
- fun `Brackets with only address returns fallback`() {
+ fun `Brackets with only address shows address in subtitle`() {
val info = getInterfaceInfo("TCPClientInterface[10.0.0.1:4242]")
assertEquals("TCP/IP", info.text)
- assertEquals("TCPClientInterface", info.subtitle)
+ assertEquals("TCPClientInterface — 10.0.0.1:4242", info.subtitle)
}
@Test
- fun `Brackets with IPv6 address returns fallback`() {
+ fun `Brackets with IPv6 address shows address in subtitle`() {
val info = getInterfaceInfo("TCPClientInterface[fe80::1]")
assertEquals("TCP/IP", info.text)
- assertEquals("TCPClientInterface", info.subtitle)
+ assertEquals("TCPClientInterface — fe80::1", info.subtitle)
}
@Test
@@ -295,17 +295,17 @@ class InterfaceInfoTest {
}
@Test
- fun `Whitespace-only name in brackets returns fallback`() {
+ fun `Whitespace-only name in brackets shows whitespace in subtitle`() {
val info = getInterfaceInfo("TCPClientInterface[ ]")
assertEquals("TCP/IP", info.text)
- assertEquals("TCPClientInterface", info.subtitle)
+ assertEquals("TCPClientInterface — ", info.subtitle)
}
@Test
fun `Case insensitive matching for interface types`() {
val tcpInfo = getInterfaceInfo("tcpclientinterface[Test]")
- assertEquals(Icons.Default.Cloud, tcpInfo.icon)
+ assertEquals(Icons.Default.Public, tcpInfo.icon)
val bleInfo = getInterfaceInfo("ANDROIDBLEINTERFACE[Test]")
assertEquals(Icons.Default.Bluetooth, bleInfo.icon)
@@ -327,12 +327,12 @@ class InterfaceInfoTest {
}
@Test
- fun `Name with slash but blank before slash returns fallback`() {
+ fun `Name with slash but blank before slash shows full bracket in subtitle`() {
// Test when there's a slash but nothing before it
val info = getInterfaceInfo("TCPClientInterface[/192.168.1.100:4965]")
assertEquals("TCP/IP", info.text)
- assertEquals("TCPClientInterface", info.subtitle)
+ assertEquals("TCPClientInterface — /192.168.1.100:4965", info.subtitle)
}
@Test
@@ -346,20 +346,20 @@ class InterfaceInfoTest {
}
@Test
- fun `IPv6 link-local address starting with fe80 returns fallback`() {
+ fun `IPv6 link-local address starting with fe80 shows address in subtitle`() {
val info = getInterfaceInfo("TCPClientInterface[fe80::a00:27ff:fe4e:66a1%eth0]")
assertEquals("TCP/IP", info.text)
- assertEquals("TCPClientInterface", info.subtitle)
+ assertEquals("TCPClientInterface — fe80::a00:27ff:fe4e:66a1%eth0", info.subtitle)
}
@Test
- fun `Address with dot but no colon returns fallback`() {
+ fun `Address with dot but no colon shows address in subtitle`() {
// Test looksLikeAddress with dot only (like hostname.local)
val info = getInterfaceInfo("TCPClientInterface[server.local]")
assertEquals("TCP/IP", info.text)
- assertEquals("TCPClientInterface", info.subtitle)
+ assertEquals("TCPClientInterface — server.local", info.subtitle)
}
@Test
diff --git a/app/src/test/java/com/lxmf/messenger/viewmodel/MapViewModelTest.kt b/app/src/test/java/com/lxmf/messenger/viewmodel/MapViewModelTest.kt
index 0a794d1bc..40a2e20a3 100644
--- a/app/src/test/java/com/lxmf/messenger/viewmodel/MapViewModelTest.kt
+++ b/app/src/test/java/com/lxmf/messenger/viewmodel/MapViewModelTest.kt
@@ -7,7 +7,7 @@ import app.cash.turbine.test
import com.lxmf.messenger.data.db.dao.AnnounceDao
import com.lxmf.messenger.data.db.dao.ReceivedLocationDao
import com.lxmf.messenger.data.db.entity.ReceivedLocationEntity
-import com.lxmf.messenger.data.model.EnrichedAnnounce
+import com.lxmf.messenger.data.model.MapAnnounceLookup
import com.lxmf.messenger.data.repository.ContactRepository
import com.lxmf.messenger.data.repository.IdentityRepository
import com.lxmf.messenger.data.repository.OfflineMapRegionRepository
@@ -70,6 +70,8 @@ class MapViewModelTest {
private lateinit var telemetryCollectorManager: TelemetryCollectorManager
private lateinit var offlineMapRegionRepository: OfflineMapRegionRepository
private lateinit var identityRepository: IdentityRepository
+ private lateinit var reticulumProtocol: com.lxmf.messenger.reticulum.protocol.ReticulumProtocol
+ private lateinit var interfaceFirstSeenDao: com.lxmf.messenger.data.db.dao.InterfaceFirstSeenDao
private lateinit var viewModel: MapViewModel
@Before
@@ -87,18 +89,24 @@ class MapViewModelTest {
mapTileSourceManager = mockk()
telemetryCollectorManager = mockk()
offlineMapRegionRepository = mockk()
- identityRepository = mockk()
+ reticulumProtocol = mockk()
+ interfaceFirstSeenDao = mockk()
+ identityRepository = mockk()
every { identityRepository.activeIdentity } returns flowOf(null)
+ coEvery { reticulumProtocol.getDiscoveredInterfaces() } returns emptyList()
+ coEvery { interfaceFirstSeenDao.insertIfNotExists(any()) } returns Unit
+ coEvery { interfaceFirstSeenDao.getFirstSeenBatch(any()) } returns emptyList()
every { contactRepository.getEnrichedContacts() } returns flowOf(emptyList())
every { receivedLocationDao.getLatestLocationsPerSenderUnfiltered() } returns flowOf(emptyList())
- every { announceDao.getEnrichedAnnounces() } returns flowOf(emptyList())
+ every { announceDao.getAnnouncesForLocationSenders() } returns flowOf(emptyList())
every { locationSharingManager.isSharing } returns MutableStateFlow(false)
every { locationSharingManager.activeSessions } returns MutableStateFlow(emptyList())
every { locationSharingManager.startSharing(any(), any(), any()) } just Runs
every { locationSharingManager.stopSharing(any()) } just Runs
every { settingsRepository.hasDismissedLocationPermissionSheetFlow } returns flowOf(false)
every { settingsRepository.mapMarkerDeclutterEnabledFlow } returns flowOf(true)
+ every { settingsRepository.sortMessagesBySentTime } returns flowOf(false)
coEvery { settingsRepository.markLocationPermissionSheetDismissed() } just Runs
coEvery { settingsRepository.setHttpEnabledForDownload(any()) } just Runs
coEvery { mapTileSourceManager.getMapStyle(any(), any()) } returns MapStyleResult.Online(MapTileSourceManager.DEFAULT_STYLE_URL)
@@ -137,6 +145,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.state.test {
@@ -160,6 +170,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.state.test {
@@ -183,6 +195,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.state.test {
@@ -206,6 +220,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.state.test {
@@ -234,6 +250,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.state.test {
@@ -265,6 +283,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
val mockLocation = createMockLocation(37.7749, -122.4194)
@@ -296,6 +316,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
// Verify initial state has no error message
@@ -325,6 +347,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.state.test {
@@ -348,6 +372,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.state.test {
@@ -376,6 +402,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.state.test {
@@ -448,6 +476,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.state.test {
@@ -497,6 +527,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.state.test {
@@ -523,6 +555,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.state.test {
@@ -548,6 +582,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.state.test {
@@ -571,6 +607,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
val location1 = createMockLocation(37.7749, -122.4194)
val location2 = createMockLocation(40.7128, -74.0060)
@@ -627,6 +665,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
val newLocation = createMockLocation(40.7128, -74.0060) // New York
@@ -681,6 +721,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.state.test {
@@ -727,6 +769,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.state.test {
@@ -752,6 +796,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
val mockLocation = createMockLocation(37.7749, -122.4194)
@@ -787,6 +833,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
val originalState = viewModel.state.value
@@ -817,6 +865,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
val now = System.currentTimeMillis()
@@ -845,6 +895,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
val now = System.currentTimeMillis()
@@ -873,6 +925,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
val now = System.currentTimeMillis()
@@ -901,6 +955,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
val now = System.currentTimeMillis()
@@ -929,6 +985,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
val now = System.currentTimeMillis()
@@ -957,6 +1015,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
val now = System.currentTimeMillis()
@@ -986,6 +1046,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
val now = System.currentTimeMillis()
@@ -1016,6 +1078,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
val selectedContacts =
@@ -1058,6 +1122,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
val result = runCatching { viewModel.startSharing(emptyList(), com.lxmf.messenger.ui.model.SharingDuration.FIFTEEN_MINUTES) }
@@ -1083,6 +1149,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
val selectedContacts =
@@ -1122,6 +1190,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
val result = runCatching { viewModel.stopSharing() }
@@ -1147,6 +1217,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
val result = runCatching { viewModel.stopSharing("specific_hash") }
@@ -1172,6 +1244,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
val result1 = runCatching { viewModel.stopSharing("hash1") }
@@ -1233,6 +1307,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.state.test {
@@ -1270,6 +1346,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.state.test {
@@ -1301,6 +1379,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.state.test {
@@ -1331,26 +1411,10 @@ class MapViewModelTest {
runTest {
val announces =
listOf(
- EnrichedAnnounce(
+ MapAnnounceLookup(
destinationHash = "hash1",
peerName = "Announce Name",
publicKey = ByteArray(64),
- appData = null,
- hops = 1,
- lastSeenTimestamp = System.currentTimeMillis(),
- nodeType = "peer",
- receivingInterface = null,
- receivingInterfaceType = null,
- aspect = "lxmf.delivery",
- isFavorite = false,
- favoritedTimestamp = null,
- stampCost = null,
- stampCostFlexibility = null,
- peeringCost = null,
- propagationTransferLimitKb = null,
- iconName = null,
- iconForegroundColor = null,
- iconBackgroundColor = null,
),
)
val receivedLocations =
@@ -1369,7 +1433,7 @@ class MapViewModelTest {
// Empty contacts - no match
every { contactRepository.getEnrichedContacts() } returns flowOf(emptyList())
every { receivedLocationDao.getLatestLocationsPerSenderUnfiltered() } returns flowOf(receivedLocations)
- every { announceDao.getEnrichedAnnounces() } returns flowOf(announces)
+ every { announceDao.getAnnouncesForLocationSenders() } returns flowOf(announces)
viewModel =
MapViewModel(
@@ -1383,6 +1447,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.state.test {
@@ -1411,7 +1477,7 @@ class MapViewModelTest {
// No contacts or announces
every { contactRepository.getEnrichedContacts() } returns flowOf(emptyList())
every { receivedLocationDao.getLatestLocationsPerSenderUnfiltered() } returns flowOf(receivedLocations)
- every { announceDao.getEnrichedAnnounces() } returns flowOf(emptyList())
+ every { announceDao.getAnnouncesForLocationSenders() } returns flowOf(emptyList())
viewModel =
MapViewModel(
@@ -1425,6 +1491,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.state.test {
@@ -1447,26 +1515,10 @@ class MapViewModelTest {
)
val announces =
listOf(
- EnrichedAnnounce(
+ MapAnnounceLookup(
destinationHash = "hash1",
peerName = "Announce Name",
publicKey = ByteArray(64),
- appData = null,
- hops = 1,
- lastSeenTimestamp = System.currentTimeMillis(),
- nodeType = "peer",
- receivingInterface = null,
- receivingInterfaceType = null,
- aspect = "lxmf.delivery",
- isFavorite = false,
- favoritedTimestamp = null,
- stampCost = null,
- stampCostFlexibility = null,
- peeringCost = null,
- propagationTransferLimitKb = null,
- iconName = null,
- iconForegroundColor = null,
- iconBackgroundColor = null,
),
)
val receivedLocations =
@@ -1484,7 +1536,7 @@ class MapViewModelTest {
)
every { contactRepository.getEnrichedContacts() } returns flowOf(contacts)
every { receivedLocationDao.getLatestLocationsPerSenderUnfiltered() } returns flowOf(receivedLocations)
- every { announceDao.getEnrichedAnnounces() } returns flowOf(announces)
+ every { announceDao.getAnnouncesForLocationSenders() } returns flowOf(announces)
viewModel =
MapViewModel(
@@ -1498,6 +1550,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.state.test {
@@ -1515,23 +1569,10 @@ class MapViewModelTest {
runTest {
val announces =
listOf(
- EnrichedAnnounce(
+ MapAnnounceLookup(
destinationHash = "hash1",
peerName = "Test User",
publicKey = ByteArray(64) { it.toByte() },
- appData = null,
- hops = 1,
- lastSeenTimestamp = System.currentTimeMillis(),
- nodeType = "peer",
- receivingInterface = null,
- receivingInterfaceType = null,
- aspect = "lxmf.delivery",
- isFavorite = false,
- favoritedTimestamp = null,
- stampCost = null,
- stampCostFlexibility = null,
- peeringCost = null,
- propagationTransferLimitKb = null,
iconName = "account",
iconForegroundColor = "FFFFFF",
iconBackgroundColor = "1E88E5",
@@ -1552,7 +1593,7 @@ class MapViewModelTest {
)
every { contactRepository.getEnrichedContacts() } returns flowOf(emptyList())
every { receivedLocationDao.getLatestLocationsPerSenderUnfiltered() } returns flowOf(receivedLocations)
- every { announceDao.getEnrichedAnnounces() } returns flowOf(announces)
+ every { announceDao.getAnnouncesForLocationSenders() } returns flowOf(announces)
viewModel =
MapViewModel(
@@ -1566,6 +1607,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.state.test {
@@ -1584,27 +1627,11 @@ class MapViewModelTest {
runTest {
val announces =
listOf(
- EnrichedAnnounce(
+ MapAnnounceLookup(
destinationHash = "hash1",
peerName = "Test User",
publicKey = ByteArray(64),
- appData = null,
- hops = 1,
- lastSeenTimestamp = System.currentTimeMillis(),
- nodeType = "peer",
- receivingInterface = null,
- receivingInterfaceType = null,
- aspect = "lxmf.delivery",
- isFavorite = false,
- favoritedTimestamp = null,
- stampCost = null,
- stampCostFlexibility = null,
- peeringCost = null,
- propagationTransferLimitKb = null,
// No icon set - peer_icons table has no entry for this peer
- iconName = null,
- iconForegroundColor = null,
- iconBackgroundColor = null,
),
)
val receivedLocations =
@@ -1622,7 +1649,7 @@ class MapViewModelTest {
)
every { contactRepository.getEnrichedContacts() } returns flowOf(emptyList())
every { receivedLocationDao.getLatestLocationsPerSenderUnfiltered() } returns flowOf(receivedLocations)
- every { announceDao.getEnrichedAnnounces() } returns flowOf(announces)
+ every { announceDao.getAnnouncesForLocationSenders() } returns flowOf(announces)
viewModel =
MapViewModel(
@@ -1636,6 +1663,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.state.test {
@@ -1674,7 +1703,7 @@ class MapViewModelTest {
// Contact exists but no announce with icon (peer_icons table has no entry)
every { contactRepository.getEnrichedContacts() } returns flowOf(contacts)
every { receivedLocationDao.getLatestLocationsPerSenderUnfiltered() } returns flowOf(receivedLocations)
- every { announceDao.getEnrichedAnnounces() } returns flowOf(emptyList())
+ every { announceDao.getAnnouncesForLocationSenders() } returns flowOf(emptyList())
viewModel =
MapViewModel(
@@ -1688,6 +1717,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.state.test {
@@ -1719,6 +1750,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.state.test {
@@ -1745,6 +1778,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.state.test {
@@ -1768,6 +1803,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.dismissLocationPermissionSheet()
@@ -1796,6 +1833,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.dismissLocationPermissionSheet()
@@ -1815,6 +1854,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
// Then: Permission sheet should still be dismissed
@@ -1844,6 +1885,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
val result = runCatching { viewModel.enableHttp() }
@@ -1869,6 +1912,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
val result = runCatching { viewModel.enableHttp() }
@@ -1898,6 +1943,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
// Change HTTP enabled state
@@ -1926,6 +1973,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.dismissPermissionCard()
@@ -1945,6 +1994,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
// Then: Permission card should still be dismissed
@@ -1976,6 +2027,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
// Then: Permission card should NOT be dismissed
@@ -2002,6 +2055,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
vm1.dismissPermissionCard()
assertTrue(vm1.state.value.isPermissionCardDismissed)
@@ -2020,6 +2075,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
// Then: The second ViewModel should NOT have the card dismissed
@@ -2043,6 +2100,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
assertNull(viewModel.pendingFocusContact.value)
@@ -2063,6 +2122,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.focusOnContact("abc123")
@@ -2085,6 +2146,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.focusOnContact("abc123")
@@ -2109,6 +2172,8 @@ class MapViewModelTest {
telemetryCollectorManager,
offlineMapRegionRepository,
identityRepository,
+ reticulumProtocol,
+ interfaceFirstSeenDao,
)
viewModel.focusOnContact("first")
diff --git a/app/src/test/java/com/lxmf/messenger/viewmodel/NomadNetBrowserViewModelTest.kt b/app/src/test/java/com/lxmf/messenger/viewmodel/NomadNetBrowserViewModelTest.kt
index 94c75a01c..f36075046 100644
--- a/app/src/test/java/com/lxmf/messenger/viewmodel/NomadNetBrowserViewModelTest.kt
+++ b/app/src/test/java/com/lxmf/messenger/viewmodel/NomadNetBrowserViewModelTest.kt
@@ -56,6 +56,7 @@ class NomadNetBrowserViewModelTest {
pageCache = mockk()
every { pageCache.put(any(), any(), any(), any()) } just Runs
coEvery { protocol.cancelNomadnetPageRequest() } just Runs
+ coEvery { protocol.getNomadnetRequestStatus() } returns ""
viewModel = NomadNetBrowserViewModel(protocol, pageCache)
}
diff --git a/build.gradle.kts b/build.gradle.kts
index 862666ada..da5eefc74 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,14 +1,13 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
- id("com.android.application") version "8.13.0" apply false
- id("com.android.library") version "8.13.0" apply false
- id("org.jetbrains.kotlin.android") version "2.3.0" apply false
- id("org.jetbrains.kotlin.plugin.compose") version "2.3.10" apply false
- id("com.google.dagger.hilt.android") version "2.57.2" apply false
+ id("com.android.application") version "9.1.0" apply false
+ id("com.android.library") version "9.1.0" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.3.20" apply false
+ id("com.google.dagger.hilt.android") version "2.59.2" apply false
id("com.google.devtools.ksp") version "2.3.6" apply false
id("com.chaquo.python") version "17.0.0" apply false
- id("org.jetbrains.kotlin.plugin.serialization") version "2.3.10" apply false
- id("io.sentry.android.gradle") version "5.3.0" apply false
+ id("org.jetbrains.kotlin.plugin.serialization") version "2.3.20" apply false
+ id("io.sentry.android.gradle") version "6.1.0" apply false
id("app.cash.paparazzi") version "1.3.5" apply false
id("jacoco")
id("org.jlleitschuh.gradle.ktlint") version "12.1.1"
diff --git a/data/build.gradle.kts b/data/build.gradle.kts
index cfab9b261..4a12f2c14 100644
--- a/data/build.gradle.kts
+++ b/data/build.gradle.kts
@@ -1,13 +1,12 @@
plugins {
id("com.android.library")
- kotlin("android")
id("com.google.devtools.ksp")
id("com.google.dagger.hilt.android")
}
android {
namespace = "tech.torlando.columba.data"
- compileSdk = 35
+ compileSdk = 36
defaultConfig {
minSdk = 24
@@ -57,7 +56,7 @@ dependencies {
implementation(libs.paging.runtime)
// Compose runtime (for @Stable annotation on data classes used in Compose UI)
- implementation("androidx.compose.runtime:runtime:1.10.4")
+ implementation("androidx.compose.runtime:runtime:1.10.6")
// Testing
testImplementation(libs.junit)
@@ -65,11 +64,11 @@ dependencies {
testImplementation(libs.mockk)
testImplementation(libs.coroutines.test)
testImplementation(libs.robolectric)
- testImplementation("androidx.test:core:1.5.0")
+ testImplementation(libs.test.core)
testImplementation(libs.turbine)
testImplementation("org.json:json:20240303") // Real JSON implementation for unit tests
androidTestImplementation(libs.junit.android)
- androidTestImplementation("androidx.test:core:1.5.0")
+ androidTestImplementation(libs.test.core)
androidTestImplementation("androidx.test:runner:1.5.2")
androidTestImplementation("androidx.test.ext:junit:1.1.5")
androidTestImplementation(libs.coroutines.test)
diff --git a/data/src/main/java/com/lxmf/messenger/data/db/ColumbaDatabase.kt b/data/src/main/java/com/lxmf/messenger/data/db/ColumbaDatabase.kt
index f49fd3089..38e9c96bc 100644
--- a/data/src/main/java/com/lxmf/messenger/data/db/ColumbaDatabase.kt
+++ b/data/src/main/java/com/lxmf/messenger/data/db/ColumbaDatabase.kt
@@ -8,6 +8,7 @@ import com.lxmf.messenger.data.db.dao.ContactDao
import com.lxmf.messenger.data.db.dao.ConversationDao
import com.lxmf.messenger.data.db.dao.CustomThemeDao
import com.lxmf.messenger.data.db.dao.DraftDao
+import com.lxmf.messenger.data.db.dao.InterfaceFirstSeenDao
import com.lxmf.messenger.data.db.dao.LocalIdentityDao
import com.lxmf.messenger.data.db.dao.MessageDao
import com.lxmf.messenger.data.db.dao.OfflineMapRegionDao
@@ -21,6 +22,7 @@ import com.lxmf.messenger.data.db.entity.ContactEntity
import com.lxmf.messenger.data.db.entity.ConversationEntity
import com.lxmf.messenger.data.db.entity.CustomThemeEntity
import com.lxmf.messenger.data.db.entity.DraftEntity
+import com.lxmf.messenger.data.db.entity.InterfaceFirstSeenEntity
import com.lxmf.messenger.data.db.entity.LocalIdentityEntity
import com.lxmf.messenger.data.db.entity.MessageEntity
import com.lxmf.messenger.data.db.entity.OfflineMapRegionEntity
@@ -44,6 +46,7 @@ import com.lxmf.messenger.data.db.entity.RmspServerEntity
RmspServerEntity::class,
DraftEntity::class,
BlockedPeerEntity::class,
+ InterfaceFirstSeenEntity::class,
],
version = 44,
exportSchema = false,
@@ -74,4 +77,6 @@ abstract class ColumbaDatabase : RoomDatabase() {
abstract fun draftDao(): DraftDao
abstract fun blockedPeerDao(): BlockedPeerDao
+
+ abstract fun interfaceFirstSeenDao(): InterfaceFirstSeenDao
}
diff --git a/data/src/main/java/com/lxmf/messenger/data/db/dao/AnnounceDao.kt b/data/src/main/java/com/lxmf/messenger/data/db/dao/AnnounceDao.kt
index c5e751f43..7f250a4a6 100644
--- a/data/src/main/java/com/lxmf/messenger/data/db/dao/AnnounceDao.kt
+++ b/data/src/main/java/com/lxmf/messenger/data/db/dao/AnnounceDao.kt
@@ -7,6 +7,7 @@ import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.lxmf.messenger.data.db.entity.AnnounceEntity
import com.lxmf.messenger.data.model.EnrichedAnnounce
+import com.lxmf.messenger.data.model.MapAnnounceLookup
import kotlinx.coroutines.flow.Flow
@Dao
@@ -300,6 +301,29 @@ interface AnnounceDao {
)
fun getEnrichedAnnounces(): Flow>
+ /**
+ * Get lightweight announce data only for peers that have location entries.
+ *
+ * Used by MapViewModel to resolve display names and icons for map markers
+ * without loading the full announce table. Scoped via subquery on
+ * received_locations to avoid CursorWindow overflow on large databases.
+ */
+ @Query(
+ """
+ SELECT
+ a.destinationHash,
+ a.peerName,
+ a.publicKey,
+ pi.iconName as iconName,
+ pi.foregroundColor as iconForegroundColor,
+ pi.backgroundColor as iconBackgroundColor
+ FROM announces a
+ LEFT JOIN peer_icons pi ON a.destinationHash = pi.destinationHash
+ WHERE lower(a.destinationHash) IN (SELECT DISTINCT lower(senderHash) FROM received_locations)
+ """,
+ )
+ fun getAnnouncesForLocationSenders(): Flow>
+
/**
* Search announces with icon data by peer name or destination hash.
*/
diff --git a/data/src/main/java/com/lxmf/messenger/data/db/dao/ConversationDao.kt b/data/src/main/java/com/lxmf/messenger/data/db/dao/ConversationDao.kt
index 47d96f471..c0692da21 100644
--- a/data/src/main/java/com/lxmf/messenger/data/db/dao/ConversationDao.kt
+++ b/data/src/main/java/com/lxmf/messenger/data/db/dao/ConversationDao.kt
@@ -164,6 +164,19 @@ interface ConversationDao {
@Query("SELECT * FROM conversations WHERE identityHash = :identityHash")
suspend fun getAllConversationsList(identityHash: String): List
+ @Query(
+ """
+ SELECT peerHash FROM conversations
+ WHERE identityHash = :identityHash
+ ORDER BY lastMessageTimestamp DESC
+ LIMIT :limit
+ """,
+ )
+ suspend fun getRecentPeerHashes(
+ identityHash: String,
+ limit: Int,
+ ): List
+
/**
* Bulk insert conversations (for import).
*/
diff --git a/data/src/main/java/com/lxmf/messenger/data/db/dao/InterfaceFirstSeenDao.kt b/data/src/main/java/com/lxmf/messenger/data/db/dao/InterfaceFirstSeenDao.kt
new file mode 100644
index 000000000..f1d6e6a13
--- /dev/null
+++ b/data/src/main/java/com/lxmf/messenger/data/db/dao/InterfaceFirstSeenDao.kt
@@ -0,0 +1,16 @@
+package com.lxmf.messenger.data.db.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import com.lxmf.messenger.data.db.entity.InterfaceFirstSeenEntity
+
+@Dao
+interface InterfaceFirstSeenDao {
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ suspend fun insertIfNotExists(entity: InterfaceFirstSeenEntity)
+
+ @Query("SELECT * FROM interface_first_seen WHERE interfaceId IN (:ids)")
+ suspend fun getFirstSeenBatch(ids: List): List
+}
diff --git a/data/src/main/java/com/lxmf/messenger/data/db/entity/InterfaceFirstSeenEntity.kt b/data/src/main/java/com/lxmf/messenger/data/db/entity/InterfaceFirstSeenEntity.kt
new file mode 100644
index 000000000..c4ddc2f46
--- /dev/null
+++ b/data/src/main/java/com/lxmf/messenger/data/db/entity/InterfaceFirstSeenEntity.kt
@@ -0,0 +1,22 @@
+package com.lxmf.messenger.data.db.entity
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+/**
+ * Tracks the first time a discovered network interface was seen.
+ * Uses INSERT OR IGNORE to preserve the original timestamp across re-discoveries.
+ *
+ * This is the only interface discovery field stored in Room. All other fields
+ * (last heard, hops, coordinates, radio params, etc.) are persisted by RNS itself
+ * in ~/.reticulum/storage/discovery/interfaces/ as msgpack files and survive app
+ * restarts. First-seen is not tracked by RNS, so we persist it here.
+ *
+ * When migrating to reticulum-kt, this table should be retained unless the Kotlin
+ * implementation adds first-seen tracking natively.
+ */
+@Entity(tableName = "interface_first_seen")
+data class InterfaceFirstSeenEntity(
+ @PrimaryKey val interfaceId: String,
+ val firstSeenTimestamp: Long,
+)
diff --git a/data/src/main/java/com/lxmf/messenger/data/di/DatabaseModule.kt b/data/src/main/java/com/lxmf/messenger/data/di/DatabaseModule.kt
index 7b59eb686..927594ca1 100644
--- a/data/src/main/java/com/lxmf/messenger/data/di/DatabaseModule.kt
+++ b/data/src/main/java/com/lxmf/messenger/data/di/DatabaseModule.kt
@@ -11,6 +11,7 @@ import com.lxmf.messenger.data.db.dao.ContactDao
import com.lxmf.messenger.data.db.dao.ConversationDao
import com.lxmf.messenger.data.db.dao.CustomThemeDao
import com.lxmf.messenger.data.db.dao.DraftDao
+import com.lxmf.messenger.data.db.dao.InterfaceFirstSeenDao
import com.lxmf.messenger.data.db.dao.LocalIdentityDao
import com.lxmf.messenger.data.db.dao.MessageDao
import com.lxmf.messenger.data.db.dao.OfflineMapRegionDao
@@ -1688,13 +1689,22 @@ object DatabaseModule {
}
}
- // Migration 43→44: Add source column to received_locations table
+ // Migration 43→44: Add source column + interface_first_seen table
private val MIGRATION_43_44 =
object : Migration(43, 44) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"ALTER TABLE received_locations ADD COLUMN source TEXT NOT NULL DEFAULT 'location_sharing'",
)
+ database.execSQL(
+ "CREATE INDEX IF NOT EXISTS idx_received_locations_source " +
+ "ON received_locations(source, senderHash, timestamp)",
+ )
+ database.execSQL(
+ "CREATE TABLE IF NOT EXISTS interface_first_seen (" +
+ "interfaceId TEXT NOT NULL PRIMARY KEY, " +
+ "firstSeenTimestamp INTEGER NOT NULL)",
+ )
}
}
@@ -1752,6 +1762,9 @@ object DatabaseModule {
@Provides
fun provideBlockedPeerDao(database: ColumbaDatabase): BlockedPeerDao = database.blockedPeerDao()
+ @Provides
+ fun provideInterfaceFirstSeenDao(database: ColumbaDatabase): InterfaceFirstSeenDao = database.interfaceFirstSeenDao()
+
@Provides
@Singleton
@Suppress("InjectDispatcher") // This IS the DI provider for the IO dispatcher
diff --git a/data/src/main/java/com/lxmf/messenger/data/model/MapAnnounceLookup.kt b/data/src/main/java/com/lxmf/messenger/data/model/MapAnnounceLookup.kt
new file mode 100644
index 000000000..972f0c667
--- /dev/null
+++ b/data/src/main/java/com/lxmf/messenger/data/model/MapAnnounceLookup.kt
@@ -0,0 +1,45 @@
+package com.lxmf.messenger.data.model
+
+/**
+ * Lightweight announce lookup for map marker display.
+ *
+ * Contains only the fields needed by MapViewModel to resolve display names
+ * and icons for location markers. Avoids loading heavy fields like appData
+ * that can cause CursorWindow overflow on large announce tables.
+ *
+ * @see EnrichedAnnounce for the full announce projection used elsewhere.
+ */
+data class MapAnnounceLookup(
+ val destinationHash: String,
+ val peerName: String,
+ val publicKey: ByteArray,
+ val iconName: String? = null,
+ val iconForegroundColor: String? = null,
+ val iconBackgroundColor: String? = null,
+) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (javaClass != other?.javaClass) return false
+
+ other as MapAnnounceLookup
+
+ if (destinationHash != other.destinationHash) return false
+ if (peerName != other.peerName) return false
+ if (!publicKey.contentEquals(other.publicKey)) return false
+ if (iconName != other.iconName) return false
+ if (iconForegroundColor != other.iconForegroundColor) return false
+ if (iconBackgroundColor != other.iconBackgroundColor) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = destinationHash.hashCode()
+ result = 31 * result + peerName.hashCode()
+ result = 31 * result + publicKey.contentHashCode()
+ result = 31 * result + (iconName?.hashCode() ?: 0)
+ result = 31 * result + (iconForegroundColor?.hashCode() ?: 0)
+ result = 31 * result + (iconBackgroundColor?.hashCode() ?: 0)
+ return result
+ }
+}
diff --git a/data/src/main/java/com/lxmf/messenger/data/repository/ConversationRepository.kt b/data/src/main/java/com/lxmf/messenger/data/repository/ConversationRepository.kt
index 8454f8ebc..530588388 100644
--- a/data/src/main/java/com/lxmf/messenger/data/repository/ConversationRepository.kt
+++ b/data/src/main/java/com/lxmf/messenger/data/repository/ConversationRepository.kt
@@ -143,6 +143,14 @@ class ConversationRepository
return conversationDao.getConversation(peerHash, activeIdentity.identityHash)?.toConversation()
}
+ /**
+ * Get the peer hashes of the N most recent conversations (by last message time).
+ */
+ suspend fun getRecentPeerHashes(limit: Int): List {
+ val activeIdentity = localIdentityDao.getActiveIdentitySync() ?: return emptyList()
+ return conversationDao.getRecentPeerHashes(activeIdentity.identityHash, limit)
+ }
+
/**
* Get all messages for a specific conversation for the active identity.
* Automatically switches when identity changes.
diff --git a/detekt-rules/bin/main/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider b/detekt-rules/bin/main/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider
new file mode 100755
index 000000000..ef9f6e82a
--- /dev/null
+++ b/detekt-rules/bin/main/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider
@@ -0,0 +1 @@
+com.lxmf.messenger.detekt.rules.ColumbaRuleSetProvider
diff --git a/detekt-rules/bin/main/com/lxmf/messenger/detekt/rules/BleLoggingTagRule.kt b/detekt-rules/bin/main/com/lxmf/messenger/detekt/rules/BleLoggingTagRule.kt
new file mode 100755
index 000000000..38b2d3a77
--- /dev/null
+++ b/detekt-rules/bin/main/com/lxmf/messenger/detekt/rules/BleLoggingTagRule.kt
@@ -0,0 +1,152 @@
+package com.lxmf.messenger.detekt.rules
+
+import io.gitlab.arturbosch.detekt.api.CodeSmell
+import io.gitlab.arturbosch.detekt.api.Config
+import io.gitlab.arturbosch.detekt.api.Debt
+import io.gitlab.arturbosch.detekt.api.Entity
+import io.gitlab.arturbosch.detekt.api.Issue
+import io.gitlab.arturbosch.detekt.api.Rule
+import io.gitlab.arturbosch.detekt.api.Severity
+import org.jetbrains.kotlin.psi.KtClass
+import org.jetbrains.kotlin.psi.KtFile
+import org.jetbrains.kotlin.psi.KtObjectDeclaration
+import org.jetbrains.kotlin.psi.KtProperty
+
+/**
+ * Detekt rule to enforce hierarchical BLE logging tags.
+ *
+ * All Kotlin classes in the BLE package that perform logging must define a TAG constant
+ * following the pattern: Columba:BLE:K:
+ *
+ * Excluded from checking:
+ * - Data classes (typically don't log)
+ * - Enum classes (typically don't log)
+ * - Test classes (in test/ directories or ending in Test)
+ * - Exception classes (typically don't log)
+ * - Sealed class subtypes (inner classes)
+ *
+ * This enables consistent log filtering:
+ * - `adb logcat | grep "Columba:BLE"` - All BLE logs
+ * - `adb logcat | grep "Columba:BLE:K"` - All Kotlin BLE logs
+ * - `adb logcat | grep "Columba:BLE:K:Client"` - Specific component
+ */
+class BleLoggingTagRule(config: Config = Config.empty) : Rule(config) {
+
+ override val issue = Issue(
+ id = "BleLoggingTag",
+ severity = Severity.Maintainability,
+ description = "BLE components must use hierarchical logging tags (Columba:BLE:K:)",
+ debt = Debt.FIVE_MINS,
+ )
+
+ private val tagPattern = Regex("""^Columba:BLE:K:[A-Za-z]+$""")
+ private val blePackagePattern = Regex("""com\.lxmf\.messenger\.reticulum\.ble\.""")
+
+ override fun visitKtFile(file: KtFile) {
+ super.visitKtFile(file)
+
+ // Only check files in the BLE package
+ val packageName = file.packageFqName.asString()
+ if (!blePackagePattern.containsMatchIn(packageName)) {
+ return
+ }
+
+ // Skip test files
+ val filePath = file.virtualFilePath
+ if (filePath.contains("/test/") || filePath.contains("/androidTest/")) {
+ return
+ }
+
+ // Find all classes in this file
+ file.declarations.filterIsInstance().forEach { ktClass ->
+ checkClassForTag(ktClass)
+ }
+ }
+
+ private fun checkClassForTag(ktClass: KtClass) {
+ // Skip classes that typically don't need logging
+ if (shouldSkipClass(ktClass)) {
+ return
+ }
+
+ // Look for companion object
+ val companionObject = ktClass.companionObjects.firstOrNull()
+ if (companionObject == null) {
+ // BLE classes should have a companion object with TAG
+ reportMissingTag(ktClass)
+ return
+ }
+
+ // Look for TAG property in companion object
+ val tagProperty = findTagProperty(companionObject)
+ if (tagProperty == null) {
+ reportMissingTag(ktClass)
+ return
+ }
+
+ // Check TAG value matches pattern
+ val tagValue = extractStringValue(tagProperty)
+ if (tagValue == null || !tagPattern.matches(tagValue)) {
+ report(
+ CodeSmell(
+ issue = issue,
+ entity = Entity.from(tagProperty),
+ message = "TAG must follow pattern 'Columba:BLE:K:' but was: $tagValue",
+ ),
+ )
+ }
+ }
+
+ private fun shouldSkipClass(ktClass: KtClass): Boolean {
+ val className = ktClass.name ?: return true
+
+ // Skip data classes (models, DTOs)
+ if (ktClass.isData()) return true
+
+ // Skip enum classes
+ if (ktClass.isEnum()) return true
+
+ // Skip sealed classes (the sealed class itself may not log, subclasses are checked separately)
+ if (ktClass.isSealed()) return true
+
+ // Skip interfaces
+ if (ktClass.isInterface()) return true
+
+ // Skip exception classes
+ if (className.endsWith("Exception")) return true
+
+ // Skip test classes
+ if (className.endsWith("Test")) return true
+
+ // Skip inner/nested classes (they use parent's TAG)
+ if (ktClass.isInner()) return true
+
+ // Skip classes in model/dto packages (typically data containers)
+ val packageName = ktClass.containingKtFile.packageFqName.asString()
+ if (packageName.contains(".model") || packageName.contains(".dto")) return true
+
+ return false
+ }
+
+ private fun findTagProperty(companionObject: KtObjectDeclaration): KtProperty? {
+ return companionObject.declarations
+ .filterIsInstance()
+ .find { it.name == "TAG" }
+ }
+
+ private fun extractStringValue(property: KtProperty): String? {
+ val initializer = property.initializer?.text ?: return null
+ // Remove quotes from string literal
+ return initializer.trim('"')
+ }
+
+ private fun reportMissingTag(ktClass: KtClass) {
+ report(
+ CodeSmell(
+ issue = issue,
+ entity = Entity.from(ktClass),
+ message = "BLE class '${ktClass.name}' must have a TAG constant in companion object",
+ ),
+ )
+ }
+}
diff --git a/detekt-rules/bin/main/com/lxmf/messenger/detekt/rules/ColumbaRuleSetProvider.kt b/detekt-rules/bin/main/com/lxmf/messenger/detekt/rules/ColumbaRuleSetProvider.kt
new file mode 100755
index 000000000..0a14ba088
--- /dev/null
+++ b/detekt-rules/bin/main/com/lxmf/messenger/detekt/rules/ColumbaRuleSetProvider.kt
@@ -0,0 +1,23 @@
+package com.lxmf.messenger.detekt.rules
+
+import io.gitlab.arturbosch.detekt.api.Config
+import io.gitlab.arturbosch.detekt.api.RuleSet
+import io.gitlab.arturbosch.detekt.api.RuleSetProvider
+
+/**
+ * Provides Columba-specific detekt rules.
+ */
+class ColumbaRuleSetProvider : RuleSetProvider {
+ override val ruleSetId: String = "columba"
+
+ override fun instance(config: Config): RuleSet =
+ RuleSet(
+ ruleSetId,
+ listOf(
+ BleLoggingTagRule(config),
+ NoRelaxedMocksRule(config),
+ NoVerifyOnlyTestsRule(config),
+ StateFlowPollingLoopRule(config),
+ ),
+ )
+}
diff --git a/detekt-rules/bin/main/com/lxmf/messenger/detekt/rules/NoRelaxedMocksRule.kt b/detekt-rules/bin/main/com/lxmf/messenger/detekt/rules/NoRelaxedMocksRule.kt
new file mode 100755
index 000000000..6ed38ccf5
--- /dev/null
+++ b/detekt-rules/bin/main/com/lxmf/messenger/detekt/rules/NoRelaxedMocksRule.kt
@@ -0,0 +1,196 @@
+package com.lxmf.messenger.detekt.rules
+
+import io.gitlab.arturbosch.detekt.api.CodeSmell
+import io.gitlab.arturbosch.detekt.api.Config
+import io.gitlab.arturbosch.detekt.api.Debt
+import io.gitlab.arturbosch.detekt.api.Entity
+import io.gitlab.arturbosch.detekt.api.Issue
+import io.gitlab.arturbosch.detekt.api.Rule
+import io.gitlab.arturbosch.detekt.api.Severity
+import org.jetbrains.kotlin.psi.KtCallExpression
+import org.jetbrains.kotlin.psi.KtFile
+import org.jetbrains.kotlin.psi.KtValueArgument
+
+/**
+ * Detekt rule to prevent relaxed mocks in tests.
+ *
+ * Relaxed mocks (`mockk(relaxed = true)`) are dangerous because:
+ * 1. They return default values for any unmocked method, hiding missing test setup
+ * 2. Tests using them often verify mock interactions instead of actual behavior
+ * 3. They don't fail when production code changes, making tests useless for catching regressions
+ *
+ * Instead of relaxed mocks:
+ * - Use real implementations (in-memory databases, fake repositories)
+ * - Mock only external dependencies with explicit `every { }` stubs
+ * - Test actual behavior with assertions, not `verify { }` calls
+ *
+ * This rule is NON-SUPPRESSABLE for non-Android types. The only exception is Android
+ * system types (Context, BluetoothManager, etc.) which are automatically allowed.
+ *
+ * If you need a relaxed mock for an Android type not in the allowed list, add it
+ * to the allowedTypes set in this rule rather than suppressing.
+ */
+class NoRelaxedMocksRule(
+ config: Config = Config.empty,
+) : Rule(config) {
+ // Make this rule non-suppressable
+ override val defaultRuleIdAliases: Set = emptySet()
+ override val issue =
+ Issue(
+ id = "NoRelaxedMocks",
+ severity = Severity.Maintainability,
+ description =
+ "Relaxed mocks hide missing test setup and lead to tests that verify mock " +
+ "behavior instead of production code. Use real implementations or explicit stubs.",
+ debt = Debt.TWENTY_MINS,
+ )
+
+ override fun visitKtFile(file: KtFile) {
+ super.visitKtFile(file)
+
+ // Only check test files
+ val filePath = file.virtualFilePath
+ if (!filePath.contains("/test/") && !filePath.contains("/androidTest/")) {
+ return
+ }
+ }
+
+ override fun visitCallExpression(expression: KtCallExpression) {
+ super.visitCallExpression(expression)
+
+ // Only check in test files
+ val filePath = expression.containingKtFile.virtualFilePath
+ if (!filePath.contains("/test/") && !filePath.contains("/androidTest/")) {
+ return
+ }
+
+ // Check if this is a mockk() call
+ val calleeName = expression.calleeExpression?.text ?: return
+ if (calleeName != "mockk" && calleeName != "spyk" && calleeName != "mockkClass") {
+ return
+ }
+
+ // Check for relaxed = true argument
+ val relaxedArg =
+ expression.valueArguments.find { arg ->
+ isRelaxedTrueArgument(arg)
+ }
+
+ if (relaxedArg != null) {
+ // Check if it's for an allowed type (Context, system services)
+ // First check type arguments: mockk(relaxed = true)
+ val typeArg = expression.typeArguments.firstOrNull()?.text
+ if (isAllowedRelaxedType(typeArg)) {
+ return
+ }
+
+ // Also check variable name patterns that suggest Android types
+ // e.g., mockContext, mockWifiManager, context, etc.
+ val variableName = getAssignedVariableName(expression)
+ if (isAllowedVariableName(variableName)) {
+ return
+ }
+
+ report(
+ CodeSmell(
+ issue = issue,
+ entity = Entity.from(expression),
+ message = buildMessage(calleeName),
+ ),
+ )
+ }
+ }
+
+ private fun getAssignedVariableName(expression: KtCallExpression): String? {
+ // Try to get the variable name this mock is assigned to
+ // Handles: val mockContext = mockk(...) and mockContext = mockk(...)
+ val parent = expression.parent
+ return when {
+ parent is org.jetbrains.kotlin.psi.KtProperty -> parent.name
+ parent is org.jetbrains.kotlin.psi.KtBinaryExpression -> {
+ parent.left?.text
+ }
+ else -> null
+ }
+ }
+
+ private fun isAllowedVariableName(name: String?): Boolean {
+ if (name == null) return false
+ val lowerName = name.lowercase()
+
+ // Variable names that suggest Android system types
+ val allowedPatterns =
+ listOf(
+ "context",
+ "application",
+ "activity",
+ "service",
+ "contentresolver",
+ "sharedpreferences",
+ "resources",
+ "packagemanager",
+ "wifimanager",
+ "bluetoothmanager",
+ "bluetoothadapter",
+ "notificationmanager",
+ "alarmmanager",
+ "connectivitymanager",
+ "locationmanager",
+ "powermanager",
+ "multicastlock",
+ "wakelock",
+ )
+
+ return allowedPatterns.any { lowerName.contains(it) }
+ }
+
+ private fun isRelaxedTrueArgument(arg: KtValueArgument): Boolean {
+ val argText = arg.text
+ // Match: relaxed = true, relaxed=true, relaxed = true
+ return argText.contains("relaxed") && argText.contains("true")
+ }
+
+ private fun isAllowedRelaxedType(typeArg: String?): Boolean {
+ if (typeArg == null) return false
+
+ // Android system types that genuinely need mocking
+ val allowedTypes =
+ setOf(
+ "Context",
+ "Application",
+ "Activity",
+ "Service",
+ "ContentResolver",
+ "SharedPreferences",
+ "Resources",
+ "PackageManager",
+ "WifiManager",
+ "BluetoothManager",
+ "BluetoothAdapter",
+ "NotificationManager",
+ "AlarmManager",
+ "ConnectivityManager",
+ "LocationManager",
+ "PowerManager",
+ "WifiManager.MulticastLock",
+ "PowerManager.WakeLock",
+ )
+
+ return allowedTypes.any { typeArg.contains(it) }
+ }
+
+ private fun buildMessage(calleeName: String): String =
+ """
+ |Avoid $calleeName(relaxed = true). Relaxed mocks:
+ | • Hide missing test setup by returning defaults for unmocked methods
+ | • Lead to tests that verify mock calls instead of actual behavior
+ | • Don't catch regressions when production code changes
+ |
+ |Instead:
+ | • Use real implementations (in-memory Room database, fake repositories)
+ | • Use explicit every { } stubs for external dependencies
+ | • Assert on actual results, not verify { } calls
+ |
+ |For Android Context/system services, use @Suppress("NoRelaxedMocks").
+ """.trimMargin()
+}
diff --git a/detekt-rules/bin/main/com/lxmf/messenger/detekt/rules/NoVerifyOnlyTestsRule.kt b/detekt-rules/bin/main/com/lxmf/messenger/detekt/rules/NoVerifyOnlyTestsRule.kt
new file mode 100755
index 000000000..e1d663e0d
--- /dev/null
+++ b/detekt-rules/bin/main/com/lxmf/messenger/detekt/rules/NoVerifyOnlyTestsRule.kt
@@ -0,0 +1,219 @@
+package com.lxmf.messenger.detekt.rules
+
+import io.gitlab.arturbosch.detekt.api.CodeSmell
+import io.gitlab.arturbosch.detekt.api.Config
+import io.gitlab.arturbosch.detekt.api.Debt
+import io.gitlab.arturbosch.detekt.api.Entity
+import io.gitlab.arturbosch.detekt.api.Issue
+import io.gitlab.arturbosch.detekt.api.Rule
+import io.gitlab.arturbosch.detekt.api.Severity
+import org.jetbrains.kotlin.psi.KtAnnotationEntry
+import org.jetbrains.kotlin.psi.KtCallExpression
+import org.jetbrains.kotlin.psi.KtNamedFunction
+
+/**
+ * Detekt rule to flag test functions that only use verify/coVerify without assertions.
+ *
+ * Tests that only verify mock interactions without asserting on actual outcomes are often:
+ * 1. Testing mock wiring rather than production behavior
+ * 2. Brittle to refactoring (fail when implementation changes but behavior is preserved)
+ * 3. Unable to catch real regressions
+ *
+ * This rule is NON-SUPPRESSABLE. The whole point of this rule is to prevent AI agents
+ * and developers from writing useless tests that don't test production code.
+ *
+ * If you have a legitimate use case (UI event dispatch, side effect verification),
+ * add an assertion that verifies the outcome, not just that the method was called.
+ */
+class NoVerifyOnlyTestsRule(
+ config: Config = Config.empty,
+) : Rule(config) {
+ // Make this rule non-suppressable
+ override val defaultRuleIdAliases: Set = emptySet()
+
+ // Override to prevent suppression via annotations
+ override fun visitAnnotationEntry(annotationEntry: KtAnnotationEntry) {
+ super.visitAnnotationEntry(annotationEntry)
+ // Check if this is a @Suppress or @file:Suppress trying to suppress this rule
+ val annotationText = annotationEntry.text
+ if (annotationText.contains("Suppress") && annotationText.contains("NoVerifyOnlyTests")) {
+ report(
+ CodeSmell(
+ issue = suppressionAttemptIssue,
+ entity = Entity.from(annotationEntry),
+ message =
+ "Cannot suppress NoVerifyOnlyTests rule. This rule exists to prevent " +
+ "useless tests that only verify mock calls. Add real assertions instead.",
+ ),
+ )
+ }
+ }
+
+ private val suppressionAttemptIssue =
+ Issue(
+ id = "NoVerifyOnlyTestsSuppression",
+ severity = Severity.CodeSmell,
+ description = "Attempting to suppress the NoVerifyOnlyTests rule is not allowed.",
+ debt = Debt.TEN_MINS,
+ )
+ override val issue =
+ Issue(
+ id = "NoVerifyOnlyTests",
+ severity = Severity.Maintainability,
+ description =
+ "Test function uses verify/coVerify but has no assertions. " +
+ "Consider adding assertions on actual behavior, not just mock interactions.",
+ debt = Debt.TEN_MINS,
+ )
+
+ // Track state for current function being analyzed
+ private var currentFunctionHasVerify = false
+ private var currentFunctionHasAssertion = false
+ private var currentFunction: KtNamedFunction? = null
+
+ override fun visitNamedFunction(function: KtNamedFunction) {
+ // Only check test files
+ val filePath = function.containingKtFile.virtualFilePath
+ if (!filePath.contains("/test/") && !filePath.contains("/androidTest/")) {
+ return
+ }
+
+ // Only check @Test functions
+ if (!function.annotationEntries.any { it.shortName?.asString() == "Test" }) {
+ super.visitNamedFunction(function)
+ return
+ }
+
+ // Reset tracking for this function
+ currentFunction = function
+ currentFunctionHasVerify = false
+ currentFunctionHasAssertion = false
+
+ // Visit children to check for verify/assert calls
+ super.visitNamedFunction(function)
+
+ // Report if function has verify but no assertions
+ if (currentFunctionHasVerify && !currentFunctionHasAssertion) {
+ report(
+ CodeSmell(
+ issue = issue,
+ entity = Entity.atName(function),
+ message = buildMessage(function.name ?: "test"),
+ ),
+ )
+ }
+
+ currentFunction = null
+ }
+
+ override fun visitCallExpression(expression: KtCallExpression) {
+ super.visitCallExpression(expression)
+
+ // Only process if we're inside a test function
+ if (currentFunction == null) return
+
+ val calleeName = expression.calleeExpression?.text ?: return
+
+ // Check for verify calls
+ if (calleeName in VERIFY_FUNCTIONS) {
+ currentFunctionHasVerify = true
+ }
+
+ // Check for assertion calls
+ if (calleeName in ASSERTION_FUNCTIONS) {
+ currentFunctionHasAssertion = true
+ }
+
+ // Check for assert() call with parens
+ if (calleeName == "assert") {
+ currentFunctionHasAssertion = true
+ }
+ }
+
+ private fun buildMessage(functionName: String): String =
+ """
+ |Test '$functionName' only uses verify/coVerify without assertions.
+ |
+ |This often indicates a test that:
+ | • Tests mock wiring rather than production behavior
+ | • Will break when implementation changes but behavior is preserved
+ | • Won't catch real regressions
+ |
+ |Consider:
+ | • Adding assertions on actual return values or state changes
+ | • Testing the outcome, not just that methods were called
+ |
+ |If this is a legitimate UI test or integration test verifying side effects,
+ |use @Suppress("NoVerifyOnlyTests") with a comment explaining why.
+ """.trimMargin()
+
+ companion object {
+ private val VERIFY_FUNCTIONS =
+ setOf(
+ "verify",
+ "coVerify",
+ "verifyAll",
+ "coVerifyAll",
+ "verifyOrder",
+ "coVerifyOrder",
+ "verifySequence",
+ "coVerifySequence",
+ "confirmVerified",
+ )
+
+ private val ASSERTION_FUNCTIONS =
+ setOf(
+ // JUnit assertions
+ "assertEquals",
+ "assertNotEquals",
+ "assertTrue",
+ "assertFalse",
+ "assertNull",
+ "assertNotNull",
+ "assertSame",
+ "assertNotSame",
+ "assertArrayEquals",
+ "assertThrows",
+ "assertDoesNotThrow",
+ "assertTimeout",
+ "assertTimeoutPreemptively",
+ "fail",
+ // Kotlin test
+ "expect",
+ "expectThat",
+ // Kotest/should matchers
+ "shouldBe",
+ "shouldEqual",
+ "shouldNotBe",
+ "shouldNotEqual",
+ "shouldThrow",
+ "shouldNotThrow",
+ "shouldBeNull",
+ "shouldNotBeNull",
+ "shouldBeTrue",
+ "shouldBeFalse",
+ "shouldBeEmpty",
+ "shouldNotBeEmpty",
+ "shouldContain",
+ "shouldNotContain",
+ // AssertJ/Truth
+ "assertThat",
+ // Compose testing
+ "assertIsDisplayed",
+ "assertIsNotDisplayed",
+ "assertExists",
+ "assertDoesNotExist",
+ "assertTextEquals",
+ "assertTextContains",
+ "assertIsEnabled",
+ "assertIsNotEnabled",
+ "assertIsSelected",
+ "assertIsNotSelected",
+ "assertIsToggleable",
+ "assertIsOn",
+ "assertIsOff",
+ "assertContentDescriptionEquals",
+ "assertContentDescriptionContains",
+ )
+ }
+}
diff --git a/detekt-rules/bin/main/com/lxmf/messenger/detekt/rules/StateFlowPollingLoopRule.kt b/detekt-rules/bin/main/com/lxmf/messenger/detekt/rules/StateFlowPollingLoopRule.kt
new file mode 100755
index 000000000..d212d3cee
--- /dev/null
+++ b/detekt-rules/bin/main/com/lxmf/messenger/detekt/rules/StateFlowPollingLoopRule.kt
@@ -0,0 +1,117 @@
+package com.lxmf.messenger.detekt.rules
+
+import io.gitlab.arturbosch.detekt.api.CodeSmell
+import io.gitlab.arturbosch.detekt.api.Config
+import io.gitlab.arturbosch.detekt.api.Debt
+import io.gitlab.arturbosch.detekt.api.Entity
+import io.gitlab.arturbosch.detekt.api.Issue
+import io.gitlab.arturbosch.detekt.api.Rule
+import io.gitlab.arturbosch.detekt.api.Severity
+import org.jetbrains.kotlin.psi.KtCallExpression
+import org.jetbrains.kotlin.psi.KtDotQualifiedExpression
+import org.jetbrains.kotlin.psi.KtWhileExpression
+import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType
+
+/**
+ * Detekt rule to detect StateFlow polling loops that can cause infinite loops in tests.
+ *
+ * The pattern `while (stateFlow.value is X) { delay() }` is dangerous because:
+ * 1. With UnconfinedTestDispatcher, delay() executes immediately without advancing time
+ * 2. If test mocks don't properly update the StateFlow, the loop runs forever
+ * 3. This causes CI timeouts that are hard to debug
+ *
+ * Example problematic pattern:
+ * ```kotlin
+ * while (callState.value is CallState.Active) {
+ * delay(1000)
+ * _duration.value += 1
+ * }
+ * ```
+ *
+ * Safer alternatives:
+ * 1. Use `callState.collectLatest { if (it is Active) { ... } }` - automatically cancels
+ * 2. Use a cancellable Job that's explicitly cancelled when state changes
+ * 3. Ensure tests use `answers { }` to update StateFlow, not `just Runs`
+ *
+ * This rule warns but doesn't prevent the pattern - sometimes it's the right choice.
+ * The warning reminds developers to ensure proper test coverage.
+ */
+class StateFlowPollingLoopRule(
+ config: Config = Config.empty,
+) : Rule(config) {
+ override val issue =
+ Issue(
+ id = "StateFlowPollingLoop",
+ severity = Severity.Warning,
+ description =
+ "while loop checking StateFlow.value with delay() can cause infinite loops in tests. " +
+ "Ensure test mocks update the StateFlow, or consider using collectLatest instead.",
+ debt = Debt.TEN_MINS,
+ )
+
+ override fun visitWhileExpression(expression: KtWhileExpression) {
+ super.visitWhileExpression(expression)
+
+ // Skip test files - this pattern is only problematic when in production code
+ val filePath = expression.containingKtFile.virtualFilePath
+ if (filePath.contains("/test/") || filePath.contains("/androidTest/")) {
+ return
+ }
+
+ // Check if condition references .value (likely StateFlow/MutableStateFlow)
+ val condition = expression.condition ?: return
+ val conditionText = condition.text
+
+ // Look for patterns like: someFlow.value, _someFlow.value
+ if (!conditionText.contains(".value")) {
+ return
+ }
+
+ // Check if the loop body contains delay()
+ val body = expression.body ?: return
+ val hasDelay =
+ body.collectDescendantsOfType().any { call ->
+ val callee = call.calleeExpression?.text
+ callee == "delay"
+ }
+
+ if (!hasDelay) {
+ return
+ }
+
+ // Also check for kotlinx.coroutines.delay via qualified expression
+ val hasQualifiedDelay =
+ body.collectDescendantsOfType().any { expr ->
+ expr.text.contains("delay(")
+ }
+
+ if (!hasDelay && !hasQualifiedDelay) {
+ return
+ }
+
+ // Found the dangerous pattern
+ report(
+ CodeSmell(
+ issue = issue,
+ entity = Entity.from(expression),
+ message = buildMessage(conditionText),
+ ),
+ )
+ }
+
+ private fun buildMessage(condition: String): String =
+ """
+ |Polling loop with StateFlow detected: while ($condition) { delay() }
+ |
+ |This pattern can cause infinite loops in unit tests because:
+ | - UnconfinedTestDispatcher executes delay() immediately
+ | - If mocks use `just Runs` instead of updating the StateFlow, the loop never exits
+ |
+ |Recommended fixes:
+ | 1. In tests: Use `answers { stateFlow.value = NewState }` instead of `just Runs`
+ | 2. Refactor: Use `stateFlow.collectLatest { }` which auto-cancels on new emissions
+ | 3. Refactor: Use a Job that's explicitly cancelled when state changes
+ |
+ |If this pattern is intentional, add @Suppress("StateFlowPollingLoop")
+ """.trimMargin()
+}
diff --git a/detekt-rules/bin/test/com/lxmf/messenger/detekt/rules/BleLoggingTagRuleTest.kt b/detekt-rules/bin/test/com/lxmf/messenger/detekt/rules/BleLoggingTagRuleTest.kt
new file mode 100755
index 000000000..95762a815
--- /dev/null
+++ b/detekt-rules/bin/test/com/lxmf/messenger/detekt/rules/BleLoggingTagRuleTest.kt
@@ -0,0 +1,223 @@
+package com.lxmf.messenger.detekt.rules
+
+import io.gitlab.arturbosch.detekt.api.Config
+import io.gitlab.arturbosch.detekt.test.lint
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Test
+
+class BleLoggingTagRuleTest {
+
+ private val rule = BleLoggingTagRule(Config.empty)
+
+ @Test
+ fun `valid TAG pattern passes`() {
+ val code = """
+ package com.lxmf.messenger.reticulum.ble.client
+
+ class BleScanner {
+ companion object {
+ private const val TAG = "Columba:BLE:K:Scan"
+ }
+ }
+ """.trimIndent()
+
+ val findings = rule.lint(code)
+ assertEquals(0, findings.size, "Valid TAG pattern should not report any issues")
+ }
+
+ @Test
+ fun `invalid TAG pattern reports issue`() {
+ val code = """
+ package com.lxmf.messenger.reticulum.ble.client
+
+ class BleScanner {
+ companion object {
+ private const val TAG = "Columba:Kotlin:BleScanner"
+ }
+ }
+ """.trimIndent()
+
+ val findings = rule.lint(code)
+ assertEquals(1, findings.size, "Invalid TAG pattern should report an issue")
+ assert(findings[0].message.contains("must follow pattern"))
+ }
+
+ @Test
+ fun `missing TAG reports issue`() {
+ val code = """
+ package com.lxmf.messenger.reticulum.ble.client
+
+ class BleScanner {
+ companion object {
+ private const val SOME_OTHER_CONST = "value"
+ }
+ }
+ """.trimIndent()
+
+ val findings = rule.lint(code)
+ assertEquals(1, findings.size, "Missing TAG should report an issue")
+ assert(findings[0].message.contains("must have a TAG constant"))
+ }
+
+ @Test
+ fun `missing companion object reports issue`() {
+ val code = """
+ package com.lxmf.messenger.reticulum.ble.client
+
+ class BleScanner {
+ private val someField = "value"
+ }
+ """.trimIndent()
+
+ val findings = rule.lint(code)
+ assertEquals(1, findings.size, "Missing companion object should report an issue")
+ }
+
+ @Test
+ fun `non-BLE package is ignored`() {
+ val code = """
+ package com.lxmf.messenger.reticulum.bridge
+
+ class SomeBridge {
+ // No TAG needed - not in BLE package
+ }
+ """.trimIndent()
+
+ val findings = rule.lint(code)
+ assertEquals(0, findings.size, "Non-BLE package should be ignored")
+ }
+
+ @Test
+ fun `data class is ignored`() {
+ val code = """
+ package com.lxmf.messenger.reticulum.ble.model
+
+ data class BleDevice(val address: String, val name: String)
+ """.trimIndent()
+
+ val findings = rule.lint(code)
+ assertEquals(0, findings.size, "Data class should be ignored")
+ }
+
+ @Test
+ fun `enum class is ignored`() {
+ val code = """
+ package com.lxmf.messenger.reticulum.ble.model
+
+ enum class BleConnectionState { CONNECTED, DISCONNECTED }
+ """.trimIndent()
+
+ val findings = rule.lint(code)
+ assertEquals(0, findings.size, "Enum class should be ignored")
+ }
+
+ @Test
+ fun `exception class is ignored`() {
+ val code = """
+ package com.lxmf.messenger.reticulum.ble.util
+
+ class TimeoutException(message: String) : Exception(message)
+ """.trimIndent()
+
+ val findings = rule.lint(code)
+ assertEquals(0, findings.size, "Exception class should be ignored")
+ }
+
+ @Test
+ fun `interface is ignored`() {
+ val code = """
+ package com.lxmf.messenger.reticulum.ble.client
+
+ interface BleCallback {
+ fun onConnected()
+ }
+ """.trimIndent()
+
+ val findings = rule.lint(code)
+ assertEquals(0, findings.size, "Interface should be ignored")
+ }
+
+ @Test
+ fun `sealed class is ignored`() {
+ val code = """
+ package com.lxmf.messenger.reticulum.ble.util
+
+ sealed class BleOperation {
+ data class Connect(val address: String) : BleOperation()
+ }
+ """.trimIndent()
+
+ val findings = rule.lint(code)
+ assertEquals(0, findings.size, "Sealed class should be ignored")
+ }
+
+ @Test
+ fun `model package is ignored`() {
+ val code = """
+ package com.lxmf.messenger.reticulum.ble.model
+
+ class BleConfig {
+ val timeout = 5000
+ }
+ """.trimIndent()
+
+ val findings = rule.lint(code)
+ assertEquals(0, findings.size, "Classes in model package should be ignored")
+ }
+
+ @Test
+ fun `various valid component names pass`() {
+ val validTags = listOf(
+ "Columba:BLE:K:Bridge",
+ "Columba:BLE:K:Scan",
+ "Columba:BLE:K:Client",
+ "Columba:BLE:K:Server",
+ "Columba:BLE:K:Adv",
+ "Columba:BLE:K:Queue",
+ "Columba:BLE:K:ConnMgr",
+ "Columba:BLE:K:Pair",
+ )
+
+ for (tag in validTags) {
+ val code = """
+ package com.lxmf.messenger.reticulum.ble.service
+
+ class TestComponent {
+ companion object {
+ private const val TAG = "$tag"
+ }
+ }
+ """.trimIndent()
+
+ val findings = rule.lint(code)
+ assertEquals(0, findings.size, "TAG '$tag' should be valid")
+ }
+ }
+
+ @Test
+ fun `invalid patterns are rejected`() {
+ val invalidTags = listOf(
+ "BleScanner", // No prefix
+ "Columba:Kotlin:BleScanner", // Old pattern
+ "Columba:BLE:Py:Driver", // Python pattern (K expected)
+ "Columba:BLE:K:", // Missing component
+ "Columba:BLE:K:Scan:Extra", // Too many segments
+ "columba:ble:k:scan", // Wrong case
+ )
+
+ for (tag in invalidTags) {
+ val code = """
+ package com.lxmf.messenger.reticulum.ble.service
+
+ class TestComponent {
+ companion object {
+ private const val TAG = "$tag"
+ }
+ }
+ """.trimIndent()
+
+ val findings = rule.lint(code)
+ assertEquals(1, findings.size, "TAG '$tag' should be invalid")
+ }
+ }
+}
diff --git a/detekt-rules/bin/test/com/lxmf/messenger/detekt/rules/StateFlowPollingLoopRuleTest.kt b/detekt-rules/bin/test/com/lxmf/messenger/detekt/rules/StateFlowPollingLoopRuleTest.kt
new file mode 100755
index 000000000..5a34ac37c
--- /dev/null
+++ b/detekt-rules/bin/test/com/lxmf/messenger/detekt/rules/StateFlowPollingLoopRuleTest.kt
@@ -0,0 +1,229 @@
+package com.lxmf.messenger.detekt.rules
+
+import io.gitlab.arturbosch.detekt.api.Config
+import io.gitlab.arturbosch.detekt.test.lint
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+class StateFlowPollingLoopRuleTest {
+ private val rule = StateFlowPollingLoopRule(Config.empty)
+
+ @Test
+ fun `detects while loop with StateFlow value and delay`() {
+ val code =
+ """
+ package com.example
+
+ import kotlinx.coroutines.delay
+ import kotlinx.coroutines.flow.MutableStateFlow
+
+ class Timer {
+ private val callState = MutableStateFlow(State.Idle)
+
+ suspend fun startTimer() {
+ while (callState.value is State.Active) {
+ delay(1000)
+ }
+ }
+
+ sealed class State {
+ object Idle : State()
+ object Active : State()
+ }
+ }
+ """.trimIndent()
+
+ val findings = rule.lint(code)
+ assertEquals(1, findings.size, "Should detect StateFlow polling loop with delay")
+ assertTrue(findings[0].message.contains("Polling loop with StateFlow"))
+ }
+
+ @Test
+ fun `detects while loop with equality check`() {
+ val code =
+ """
+ package com.example
+
+ import kotlinx.coroutines.delay
+ import kotlinx.coroutines.flow.MutableStateFlow
+
+ class Poller {
+ private val _running = MutableStateFlow(true)
+
+ suspend fun poll() {
+ while (_running.value == true) {
+ delay(500)
+ doWork()
+ }
+ }
+
+ fun doWork() {}
+ }
+ """.trimIndent()
+
+ val findings = rule.lint(code)
+ assertEquals(1, findings.size, "Should detect StateFlow polling loop with equality check")
+ }
+
+ @Test
+ fun `ignores while loop without delay`() {
+ val code =
+ """
+ package com.example
+
+ import kotlinx.coroutines.flow.MutableStateFlow
+
+ class Processor {
+ private val queue = MutableStateFlow>(emptyList())
+
+ fun process() {
+ while (queue.value.isNotEmpty()) {
+ // Process without delay - this is fine
+ println(queue.value.first())
+ }
+ }
+ }
+ """.trimIndent()
+
+ val findings = rule.lint(code)
+ assertEquals(0, findings.size, "Should not flag while loop without delay")
+ }
+
+ @Test
+ fun `ignores while loop without StateFlow value`() {
+ val code =
+ """
+ package com.example
+
+ import kotlinx.coroutines.delay
+
+ class SimpleLoop {
+ var running = true
+
+ suspend fun run() {
+ while (running) {
+ delay(1000)
+ }
+ }
+ }
+ """.trimIndent()
+
+ val findings = rule.lint(code)
+ assertEquals(0, findings.size, "Should not flag simple boolean while loop")
+ }
+
+ @Test
+ fun `ignores regular for loops`() {
+ val code =
+ """
+ package com.example
+
+ import kotlinx.coroutines.delay
+
+ class Batch {
+ suspend fun process(items: List) {
+ for (item in items) {
+ delay(100)
+ println(item)
+ }
+ }
+ }
+ """.trimIndent()
+
+ val findings = rule.lint(code)
+ assertEquals(0, findings.size, "Should not flag for loops")
+ }
+
+ @Test
+ fun `message includes helpful suggestions`() {
+ val code =
+ """
+ package com.example
+
+ import kotlinx.coroutines.delay
+ import kotlinx.coroutines.flow.MutableStateFlow
+
+ class Example {
+ val state = MutableStateFlow(true)
+
+ suspend fun loop() {
+ while (state.value) {
+ delay(1000)
+ }
+ }
+ }
+ """.trimIndent()
+
+ val findings = rule.lint(code)
+ assertEquals(1, findings.size)
+
+ val message = findings[0].message
+ assertTrue(message.contains("UnconfinedTestDispatcher"), "Should mention test dispatcher")
+ assertTrue(message.contains("just Runs"), "Should mention just Runs pattern")
+ assertTrue(message.contains("collectLatest"), "Should suggest collectLatest alternative")
+ assertTrue(message.contains("answers"), "Should suggest answers pattern")
+ }
+
+ @Test
+ fun `detects nested delay in block`() {
+ val code =
+ """
+ package com.example
+
+ import kotlinx.coroutines.delay
+ import kotlinx.coroutines.flow.MutableStateFlow
+
+ class Nested {
+ val state = MutableStateFlow(true)
+
+ suspend fun run() {
+ while (state.value) {
+ if (someCondition()) {
+ delay(1000)
+ }
+ }
+ }
+
+ fun someCondition() = true
+ }
+ """.trimIndent()
+
+ val findings = rule.lint(code)
+ assertEquals(1, findings.size, "Should detect delay nested in if block")
+ }
+
+ @Test
+ fun `detects pattern from CallViewModel - exact match`() {
+ val code =
+ """
+ package com.lxmf.messenger.viewmodel
+
+ import kotlinx.coroutines.delay
+ import kotlinx.coroutines.launch
+
+ class CallViewModel {
+ val callState: Any = TODO()
+ val _callDuration: Any = TODO()
+ val viewModelScope: Any = TODO()
+
+ private fun startDurationTimer() {
+ viewModelScope.launch {
+ _callDuration.value = 0L
+ while (callState.value is CallState.Active) {
+ delay(1000)
+ _callDuration.value += 1
+ }
+ }
+ }
+
+ sealed class CallState {
+ object Active : CallState()
+ }
+ }
+ """.trimIndent()
+
+ val findings = rule.lint(code)
+ assertEquals(1, findings.size, "Should detect the exact CallViewModel pattern")
+ }
+}
diff --git a/docs/micron-parser-audit.md b/docs/micron-parser-audit.md
new file mode 100644
index 000000000..da63a4b36
--- /dev/null
+++ b/docs/micron-parser-audit.md
@@ -0,0 +1,294 @@
+# Micron Parser Audit: Columba vs Reference Implementations
+
+**Date:** 2026-03-14
+**Scope:** Columba's `MicronParser.kt` compared against:
+- NomadNet `MicronParser.py` (Python, reference/canonical)
+- `micron-parser-js` (JavaScript, 1:1 port of NomadNet)
+
+## Architecture Overview
+
+### Columba's Three Rendering Modes
+
+All three modes share the **same parser** (`MicronParser.parse()`) but differ in
+layout and styling at the Compose rendering layer (`MicronComposables.kt`).
+
+| Mode | Font | Font Size | Layout | Use Case |
+|------|------|-----------|--------|----------|
+| **MONOSPACE_SCROLL** | JetBrains Mono NL | 14sp | Horizontal + vertical scroll, pinch zoom (0.5x–3x), square line height for pixel art | ASCII/pixel art pages |
+| **MONOSPACE_ZOOM** | JetBrains Mono NL | 10sp | Vertical scroll only, pinch zoom | Compact monospace viewing |
+| **PROPORTIONAL_WRAP** | System default | 14sp | Text wraps within viewport, LazyColumn | Regular readable text |
+
+**Rendering-mode-specific behaviors:**
+- MONOSPACE_SCROLL uses `lineHeight = 2 × charWidth` (sp) for square half-block pixels, `letterSpacing = 0.sp`, and `includeFontPadding = false` for tight pixel art.
+- MONOSPACE_SCROLL/ZOOM use `softWrap = false`; PROPORTIONAL_WRAP uses `softWrap = true`.
+- Center/right-aligned text: MONOSPACE_SCROLL uses `widthIn(min=viewport)` (allowing horizontal scroll); other modes use `width(viewport)` (exact width for wrapping).
+- MONOSPACE_SCROLL renders all lines in a single `Column` with scroll modifiers. Other modes use `LazyColumn` with per-line items.
+
+---
+
+## Discrepancies Found
+
+### D1 — CRITICAL: Links and fields parsed without requiring backtick prefix
+
+**NomadNet/micron-parser-js:** `[` and `<` only trigger link/field parsing when the
+parser is in **formatting mode** (entered by encountering a backtick `` ` ``). In text
+mode, `[` and `<` are literal characters.
+
+**Columba:** `[` and `<` are parsed as link/field openers directly in `parseInline()`,
+regardless of whether a backtick preceded them.
+
+**Impact:** Any document containing literal `[` or `<` characters (e.g., `[some note]`,
+`x < y`) will be incorrectly parsed as links or fields in Columba. Conversely, pages
+that properly use `` `[label`dest] `` work fine in both.
+
+**Files:** `MicronParser.kt:229-262` (link/field parsing in text mode)
+
+**Fix:** Move `[` and `<` handling into the backtick processing branch. When a backtick
+is encountered and the next char is `[` or `<`, delegate to link/field parsing. In text
+mode, treat `[` and `<` as literal characters.
+
+---
+
+### D2 — CRITICAL: `<` section depth reset requires exact line match
+
+**NomadNet/micron-parser-js:** `<` at line start resets depth to 0, then **recursively
+re-parses the remainder** of the line:
+```python
+elif first_char == "<":
+ state["depth"] = 0
+ return parse_line(line[1:], state, url_delegate)
+```
+
+**Columba:** Requires `line == "<"` (exact match). A line like `Hello` → heading parsing triggers on the `\` not matching `>` ... actually,
+since `\` is the first char and it's not `>`, headings are skipped. But `\-` would not
+match `-` either. However, `\#comment` WOULD skip comment detection because `\` ≠ `#`.
+
+Wait — actually Columba checks `line.startsWith("#")`, `line.startsWith(">")`,
+`line.startsWith("-")`. Since the line starts with `\`, none of these match, so block
+parsing is incidentally skipped (similar to micron-parser-js). The `\` is then consumed
+in `parseInline()` as an escape.
+
+**Remaining issue:** The `\` itself is consumed in `parseInline` and escapes the next
+character, so `\>Hello` outputs `>Hello` in all three parsers. This is functionally
+equivalent.
+
+**Verdict:** Not actually a discrepancy for Columba in practice. The block-level checks
+naturally skip because `\` doesn't match any block-level starter. ✅ No fix needed.
+
+---
+
+### D5 — MODERATE: Divider custom character not restricted to 2-char lines
+
+**NomadNet:** Custom divider character only when line is **exactly** 2 characters:
+```python
+if len(line) == 2:
+ divider_char = line[1]
+else:
+ divider_char = "\u2500"
+```
+
+**micron-parser-js:** Any line starting with `-` that is longer than 1 char takes
+`line[1]` as divider char (no length restriction).
+
+**Columba:** Takes `line[1]` if `line.length >= 2`, matching micron-parser-js but not
+NomadNet:
+```kotlin
+val dividerChar = if (line.length >= 2) line[1] else '\u2500'
+```
+
+**Impact:** A line like `-Hello` would produce a divider with char `H` in Columba and
+micron-parser-js, but a default `─` divider in NomadNet.
+
+**Files:** `MicronParser.kt:137`
+
+**Fix:** Change to `if (line.length == 2) line[1] else '\u2500'` to match NomadNet.
+
+---
+
+### D6 — MODERATE: `trimEnd()` strips trailing whitespace from lines
+
+**NomadNet/micron-parser-js:** No line trimming at all. Lines are used exactly as split
+from `\n`.
+
+**Columba:** Applies `line.trimEnd()` to every input line:
+```kotlin
+val line = rawLine.trimEnd()
+```
+
+**Impact:** Trailing whitespace with background colors is significant in pixel art pages.
+A line ending in `\`B00f ` (three spaces with blue background) would lose those spaces
+in Columba, creating gaps in pixel art rendering.
+
+**Files:** `MicronParser.kt:47`
+
+**Fix:** Remove `trimEnd()`. Process lines exactly as split.
+
+---
+
+### D7 — MINOR: No heading + field conflict sanitization
+
+**NomadNet:** When a line starts with `>` AND contains `` `< `` (a field marker), the
+leading `>` characters are stripped to prevent heading formatting from interfering with
+field rendering:
+```python
+if first_char == ">" and "`<" in line:
+ line = line.lstrip(">")
+```
+
+**micron-parser-js:** Does NOT have this sanitization.
+
+**Columba:** Does NOT have this sanitization.
+
+**Impact:** Fields inside heading lines would be rendered with heading styling applied,
+which may cause visual issues. This is an edge case.
+
+**Files:** `MicronParser.kt:106-133`
+
+**Fix:** Add the same check before heading processing.
+
+---
+
+### D8 — MINOR: No control character check for divider characters
+
+**NomadNet:** Replaces divider characters with `ord < 32` with the default `\u2500`:
+```python
+if ord(divider_char) < 32:
+ divider_char = "\u2500"
+```
+
+**Columba/micron-parser-js:** No such check.
+
+**Impact:** A divider like `-\x01` would produce a control character divider in Columba.
+Unlikely in practice but could cause rendering issues.
+
+**Files:** `MicronParser.kt:137`
+
+**Fix:** Add `if (dividerChar.code < 32) '\u2500' else dividerChar`.
+
+---
+
+### D9 — MINOR: Double-backtick reset also resets alignment
+
+**NomadNet:** `` `` `` resets bold, underline, italic, fg, bg, AND alignment to defaults.
+
+**micron-parser-js:** Has a special case for double-backtick in text mode that handles
+the reset differently.
+
+**Columba:** `` `` `` resets style to `MicronStyle()` AND sets alignment to
+`MicronAlignment.LEFT`.
+
+**Verdict:** Columba matches NomadNet here. ✅ No fix needed.
+
+---
+
+### D10 — NOTE: micron-parser-js supports truecolor (`FT`/`BT`) commands
+
+**micron-parser-js** has extended color support with `FT` (truecolor foreground) and `BT`
+(truecolor background) commands that accept 6-char hex values. Neither NomadNet nor
+Columba supports these.
+
+**Impact:** Pages using truecolor commands would not render colors in Columba. This is a
+micron-parser-js extension, not a NomadNet feature.
+
+---
+
+### D11 — NOTE: 6-char hex color support in page directives
+
+**NomadNet:** The `make_style()` function has a code path for 6-char color strings, but
+the `F`/`B` inline commands only consume 3 chars. The 6-char path exists but is not
+reachable via standard markup.
+
+**Columba:** `MicronColor.parse()` only handles 3-char strings. Page directives
+(`#!bg=`, `#!fg=`) also use `MicronColor.parse()`, so 6-char hex values in directives
+would fail.
+
+**Impact:** Negligible — no known NomadNet pages use 6-char colors.
+
+---
+
+## Summary
+
+| ID | Severity | Discrepancy | NomadNet | Columba | Status |
+|----|----------|-------------|----------|---------|--------|
+| D1 | **CRITICAL** | `[`/`<` parsed without backtick | Requires backtick | Parsed directly | **FIXED** |
+| D2 | **CRITICAL** | `<` depth reset exact match | Re-parses remainder | Exact match only | **FIXED** |
+| D3 | MODERATE | Literal mode trim | Exact match `` `= `` | `trimStart()` match | **FIXED** |
+| D5 | MODERATE | Divider custom char length | Exactly 2 chars | Any length ≥ 2 | **FIXED** |
+| D6 | MODERATE | `trimEnd()` on lines | No trimming | Trims trailing whitespace | **FIXED** |
+| D7 | MINOR | Heading + field sanitization | Strips `>` when field present | No sanitization | **FIXED** |
+| D8 | MINOR | Divider control char check | Replaces ord < 32 | No check | **FIXED** |
+
+All discrepancies verified against NomadNet `MicronParser.py` and MeshChatX source (2026-03-28)
+and resolved in this PR with 19 regression tests.
+
+### Test with provided sample document
+
+The sample document provided uses:
+- `` `= `` literal mode — would work (on its own line, no indentation)
+- `` `! `` bold — works ✅
+- `` `* `` italic — works ✅
+- `-` and `-∿` dividers — `-∿` is 3+ bytes in Columba's length check → D5 applies (`∿` used as divider char, which happens to be correct since `line.length >= 2`)
+- `` `c ``, `` `r ``, `` `a `` alignment — works ✅
+- `` `B005 ``, `` `Fff ``, `` `Ff00 `` etc. colors — works ✅
+- `` `_ `` underline — works ✅
+- `` `` `` reset all — works ✅
+- `[label`dest]` links — would be parsed even without preceding backtick → D1 applies
+- `Ffd0` on first line — this is a formatting command but sits as its own line. In NomadNet, it would be parsed as inline text with `` `F `` consuming `fd0`. In Columba, same behavior ✅
+
+### Rendering Mode Observations
+
+The three rendering modes only affect layout/styling, not parsing. All discrepancies
+above affect all three modes equally. However:
+
+- **D6 (trimEnd)** is most impactful in MONOSPACE_SCROLL mode where pixel art with
+ trailing colored spaces would break.
+- **D1 (link/field without backtick)** could cause false positives in PROPORTIONAL_WRAP
+ mode where wrapped text containing `[` brackets would be misinterpreted as links.
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index a6f05c89b..424a3fda5 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,8 +1,8 @@
[versions]
-kotlin = "2.3.0"
+kotlin = "2.3.20"
compose = "1.7.5"
-composeBom = "2026.02.01"
-hilt = "2.57.2"
+composeBom = "2026.03.01"
+hilt = "2.59.2"
room = "2.8.4"
coroutines = "1.10.2"
lifecycle = "2.10.0"
@@ -12,11 +12,11 @@ msgpack = "0.9.10"
dataStore = "1.1.1"
paging = "3.3.6"
zxing = "3.5.3"
-cameraX = "1.5.2"
+cameraX = "1.5.3"
ktlint = "12.1.1"
detekt = "1.23.7"
serialization = "1.10.0"
-coil = "2.6.0"
+coil = "2.7.0"
[libraries]
# Compose
@@ -86,8 +86,8 @@ mockk-android = { module = "io.mockk:mockk-android", version = "1.14.9" }
turbine = { module = "app.cash.turbine:turbine", version = "1.2.1" }
arch-core-testing = { module = "androidx.arch.core:core-testing", version = "2.2.0" }
robolectric = { module = "org.robolectric:robolectric", version = "4.16" }
-test-core = { module = "androidx.test:core", version = "1.6.1" }
+test-core = { module = "androidx.test:core", version = "1.7.0" }
junit-android = { module = "androidx.test.ext:junit", version = "1.2.1" }
espresso = { module = "androidx.test.espresso:espresso-core", version = "3.6.1" }
-test-orchestrator = { module = "androidx.test:orchestrator", version = "1.4.2" }
+test-orchestrator = { module = "androidx.test:orchestrator", version = "1.6.1" }
test-services = { module = "androidx.test.services:test-services", version = "1.6.0" }
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
index 9bbc975c7..d997cfc60 100644
Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index d4081da47..c61a118f7 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/gradlew b/gradlew
index faf93008b..739907dfd 100755
--- a/gradlew
+++ b/gradlew
@@ -1,7 +1,7 @@
#!/bin/sh
#
-# Copyright © 2015-2021 the original authors.
+# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -57,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
-# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -114,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
-CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
@@ -172,7 +171,6 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
- CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -212,8 +210,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
- -classpath "$CLASSPATH" \
- org.gradle.wrapper.GradleWrapperMain \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
diff --git a/gradlew.bat b/gradlew.bat
index 9b42019c7..e509b2dd8 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -70,11 +70,10 @@ goto fail
:execute
@rem Setup the command line
-set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
diff --git a/micron/src/main/java/com/lxmf/messenger/micron/MicronParser.kt b/micron/src/main/java/com/lxmf/messenger/micron/MicronParser.kt
index 331a7e2c7..b8cfc93a2 100644
--- a/micron/src/main/java/com/lxmf/messenger/micron/MicronParser.kt
+++ b/micron/src/main/java/com/lxmf/messenger/micron/MicronParser.kt
@@ -44,7 +44,7 @@ object MicronParser {
val inputLines = markup.lines()
for (rawLine in inputLines) {
- val line = rawLine.trimEnd()
+ val line = rawLine
// Empty lines → line break
if (line.isEmpty()) {
@@ -58,8 +58,8 @@ object MicronParser {
continue
}
- // Literal mode toggle: `=
- if (line.trimStart() == "`=") {
+ // Literal mode toggle: `= (exact match, no leading whitespace)
+ if (line == "`=") {
literalMode = !literalMode
continue
}
@@ -96,13 +96,44 @@ object MicronParser {
continue
}
- // Section depth reset
- if (line == "<") {
+ // Section depth reset: < resets depth then re-parses remainder
+ if (line.startsWith("<")) {
sectionDepth = 0
+ val remainder = line.drop(1)
+ if (remainder.isEmpty()) continue
+ // Re-parse the remainder as a regular content line
+ val (elements, updatedStyle, updatedAlignment) =
+ parseInline(remainder, currentStyle, currentAlignment)
+ currentStyle = updatedStyle
+ currentAlignment = updatedAlignment
+ outputLines.add(
+ MicronLine(
+ elements = elements,
+ alignment = currentAlignment,
+ indentLevel = sectionDepth,
+ ),
+ )
continue
}
// Headings: >, >>, >>>
+ // D7: If line starts with > but contains `<, strip leading > chars
+ // to prevent heading formatting from interfering with field rendering
+ if (line.startsWith(">") && "`<" in line) {
+ val stripped = line.trimStart('>')
+ val (elements, updatedStyle, updatedAlignment) =
+ parseInline(stripped, currentStyle, currentAlignment)
+ currentStyle = updatedStyle
+ currentAlignment = updatedAlignment
+ outputLines.add(
+ MicronLine(
+ elements = elements,
+ alignment = currentAlignment,
+ indentLevel = sectionDepth,
+ ),
+ )
+ continue
+ }
if (line.startsWith(">")) {
val headingLevel = line.takeWhile { it == '>' }.length.coerceAtMost(MAX_HEADING_LEVEL)
sectionDepth = headingLevel
@@ -134,7 +165,9 @@ object MicronParser {
// Dividers
if (line.startsWith("-")) {
- val dividerChar = if (line.length >= 2) line[1] else '\u2500' // ─
+ var dividerChar = if (line.length == 2) line[1] else '\u2500' // ─
+ // D8: Replace control characters (ord < 32) with default
+ if (dividerChar.code < 32) dividerChar = '\u2500'
outputLines.add(
MicronLine(
elements = listOf(MicronElement.Divider(dividerChar)),
@@ -225,43 +258,7 @@ object MicronParser {
continue
}
- // Field:
- if (c == '<') {
- val fieldEnd = line.indexOf('>', i + 1)
- if (fieldEnd == -1) {
- textBuffer.append(c)
- i++
- continue
- }
- val fieldElement = parseField(line.substring(i + 1, fieldEnd), style)
- if (fieldElement != null) {
- flushText()
- elements.add(fieldElement)
- i = fieldEnd + 1
- continue
- }
- // Not a valid field — treat '<' as literal text
- textBuffer.append(c)
- i++
- continue
- }
-
- // Link: [label`destination`fields]
- if (c == '[') {
- val linkEnd = line.indexOf(']', i + 1)
- if (linkEnd == -1) {
- textBuffer.append(c)
- i++
- continue
- }
- flushText()
- val linkData = line.substring(i + 1, linkEnd)
- elements.add(parseLink(linkData, style))
- i = linkEnd + 1
- continue
- }
-
- // Backtick formatting command
+ // Backtick formatting command (also entry point for links/fields)
if (c == '`') {
if (i + 1 >= line.length) {
// Trailing backtick — reset all formatting
@@ -281,12 +278,37 @@ object MicronParser {
continue
}
- // Backtick before link `[...] or field `<...> — consume the
- // backtick (formatting-mode entry) and let the next character be
- // processed as a link/field opener on the next iteration.
- if (cmd == '[' || cmd == '<') {
- flushText()
- i++ // skip the backtick only; '[' or '<' handled next iteration
+ // Backtick before link `[...] — parse link inline
+ if (cmd == '[') {
+ val linkStart = i + 2 // after `[
+ val linkEnd = line.indexOf(']', linkStart)
+ if (linkEnd != -1) {
+ flushText()
+ val linkData = line.substring(linkStart, linkEnd)
+ elements.add(parseLink(linkData, style))
+ i = linkEnd + 1
+ continue
+ }
+ // No closing ] — treat backtick as formatting entry, [ as literal
+ textBuffer.append('[')
+ i += 2
+ continue
+ }
+
+ // Backtick before field `<...> — parse field inline
+ if (cmd == '<') {
+ val fieldStart = i + 2 // after `<
+ val fieldEnd = line.indexOf('>', fieldStart)
+ val fieldElement = if (fieldEnd != -1) parseField(line.substring(fieldStart, fieldEnd), style) else null
+ if (fieldElement != null) {
+ flushText()
+ elements.add(fieldElement)
+ i = fieldEnd + 1
+ continue
+ }
+ // Not a valid field — treat `< as literal
+ textBuffer.append('<')
+ i += 2
continue
}
diff --git a/micron/src/test/java/com/lxmf/messenger/micron/MicronParserTest.kt b/micron/src/test/java/com/lxmf/messenger/micron/MicronParserTest.kt
index 10f847dc5..3532da143 100644
--- a/micron/src/test/java/com/lxmf/messenger/micron/MicronParserTest.kt
+++ b/micron/src/test/java/com/lxmf/messenger/micron/MicronParserTest.kt
@@ -376,7 +376,7 @@ class MicronParserTest {
@Test
fun `simple link with destination only`() {
- val doc = MicronParser.parse("[/page/index.mu]")
+ val doc = MicronParser.parse("`[/page/index.mu]")
val link =
doc.lines[0]
.elements
@@ -389,7 +389,7 @@ class MicronParserTest {
@Test
fun `link with label and destination`() {
- val doc = MicronParser.parse("[Home Page`/page/index.mu]")
+ val doc = MicronParser.parse("`[Home Page`/page/index.mu]")
val link =
doc.lines[0]
.elements
@@ -401,7 +401,7 @@ class MicronParserTest {
@Test
fun `link with field submission`() {
- val doc = MicronParser.parse("[Submit`/page/form`username|password]")
+ val doc = MicronParser.parse("`[Submit`/page/form`username|password]")
val link =
doc.lines[0]
.elements
@@ -414,7 +414,7 @@ class MicronParserTest {
@Test
fun `link has underline style`() {
- val doc = MicronParser.parse("[Click me`/page]")
+ val doc = MicronParser.parse("`[Click me`/page]")
val link =
doc.lines[0]
.elements
@@ -432,7 +432,7 @@ class MicronParserTest {
@Test
fun `text before and after link`() {
- val doc = MicronParser.parse("Click [here`/page] for more")
+ val doc = MicronParser.parse("Click `[here`/page] for more")
val elements = doc.lines[0].elements
assertEquals("Click ", (elements[0] as MicronElement.Text).content)
assertEquals("here", (elements[1] as MicronElement.Link).label)
@@ -443,7 +443,7 @@ class MicronParserTest {
@Test
fun `text field with name and default value`() {
- val doc = MicronParser.parse("<|username`john>")
+ val doc = MicronParser.parse("`<|username`john>")
val field =
doc.lines[0]
.elements
@@ -457,7 +457,7 @@ class MicronParserTest {
@Test
fun `text field with custom width`() {
- val doc = MicronParser.parse("<32|email`user@example.com>")
+ val doc = MicronParser.parse("`<32|email`user@example.com>")
val field =
doc.lines[0]
.elements
@@ -470,7 +470,7 @@ class MicronParserTest {
@Test
fun `masked password field`() {
- val doc = MicronParser.parse("")
+ val doc = MicronParser.parse("`")
val field =
doc.lines[0]
.elements
@@ -482,7 +482,7 @@ class MicronParserTest {
@Test
fun `checkbox field`() {
- val doc = MicronParser.parse("|agree`yes`I agree to terms>")
+ val doc = MicronParser.parse("`|agree`yes`I agree to terms>")
val checkbox =
doc.lines[0]
.elements
@@ -496,7 +496,7 @@ class MicronParserTest {
@Test
fun `checkbox prechecked`() {
- val doc = MicronParser.parse("|agree`yes`I agree`*>")
+ val doc = MicronParser.parse("`|agree`yes`I agree`*>")
val checkbox =
doc.lines[0]
.elements
@@ -507,7 +507,7 @@ class MicronParserTest {
@Test
fun `radio button`() {
- val doc = MicronParser.parse("<^|color`red`Red option>")
+ val doc = MicronParser.parse("`<^|color`red`Red option>")
val radio =
doc.lines[0]
.elements
@@ -521,7 +521,7 @@ class MicronParserTest {
@Test
fun `radio button prechecked`() {
- val doc = MicronParser.parse("<^|color`blue`Blue`*>")
+ val doc = MicronParser.parse("`<^|color`blue`Blue`*>")
val radio =
doc.lines[0]
.elements
@@ -532,7 +532,10 @@ class MicronParserTest {
@Test
fun `unclosed field treated as text`() {
- val doc = MicronParser.parse(" means parseField fails — the < is treated as literal
val text = doc.lines[0].elements[0] as MicronElement.Text
assertEquals("Welcome
-
`!Bold text`! and `*italic`*
- [Visit`/page/about.mu]
- <|name`Enter name>
- [Submit`/page/submit`name]
+ `[Visit`/page/about.mu]
+ `<|name`Enter name>
+ `[Submit`/page/submit`name]
""".trimIndent()
val doc = MicronParser.parse(markup)
@@ -691,7 +694,7 @@ class MicronParserTest {
@Test
fun `multiple fields on same line`() {
- val doc = MicronParser.parse("<|first`John> <|last`Doe>")
+ val doc = MicronParser.parse("`<|first`John> `<|last`Doe>")
val fields = doc.lines[0].elements.filterIsInstance()
assertEquals(2, fields.size)
assertEquals("first", fields[0].name)
@@ -700,7 +703,7 @@ class MicronParserTest {
@Test
fun `link with empty label uses destination`() {
- val doc = MicronParser.parse("[`/page/home.mu]")
+ val doc = MicronParser.parse("`[`/page/home.mu]")
val link =
doc.lines[0]
.elements
@@ -867,7 +870,7 @@ class MicronParserTest {
@Test
fun `field width clamped to max`() {
- val doc = MicronParser.parse("<999|wide`>")
+ val doc = MicronParser.parse("`<999|wide`>")
val field =
doc.lines[0]
.elements
@@ -875,4 +878,157 @@ class MicronParserTest {
.first()
assertEquals(256, field.width)
}
+
+ // ==================== D1: Links/fields require backtick prefix ====================
+
+ @Test
+ fun `D1 - bare bracket is literal text not a link`() {
+ // In NomadNet, [ only starts a link when preceded by backtick (formatting mode)
+ val doc = MicronParser.parse("x < y and [some note]")
+ val elements = doc.lines[0].elements
+ // Should be a single text element with the literal content
+ val texts = elements.filterIsInstance()
+ val combined = texts.joinToString("") { it.content }
+ assertEquals("x < y and [some note]", combined)
+ // No links or fields should be parsed
+ assertTrue(elements.none { it is MicronElement.Link })
+ assertTrue(elements.none { it is MicronElement.Field })
+ }
+
+ @Test
+ fun `D1 - bare angle bracket is literal text not a field`() {
+ val doc = MicronParser.parse("if x < 10 then y > 5")
+ val texts = doc.lines[0].elements.filterIsInstance()
+ val combined = texts.joinToString("") { it.content }
+ assertEquals("if x < 10 then y > 5", combined)
+ assertTrue(doc.lines[0].elements.none { it is MicronElement.Field })
+ }
+
+ @Test
+ fun `D1 - backtick bracket still parses as link`() {
+ val doc = MicronParser.parse("`[Home`/page/index.mu]")
+ val link = doc.lines[0].elements.filterIsInstance().first()
+ assertEquals("Home", link.label)
+ assertEquals("/page/index.mu", link.destination)
+ }
+
+ @Test
+ fun `D1 - backtick angle bracket still parses as field`() {
+ val doc = MicronParser.parse("`<|username`john>")
+ val field = doc.lines[0].elements.filterIsInstance().first()
+ assertEquals("username", field.name)
+ assertEquals("john", field.defaultValue)
+ }
+
+ // ==================== D2: Depth reset re-parses remainder ====================
+
+ @Test
+ fun `D2 - depth reset with content after less-than`() {
+ // NomadNet: < resets depth and re-parses remainder of line
+ val doc = MicronParser.parse(">>Heading\n e is MicronElement.Text && e.content.contains("Content") } }
+ assertEquals(0, lastLine.indentLevel)
+ val text = lastLine.elements.filterIsInstance().first()
+ assertEquals("Content after reset", text.content)
+ }
+
+ @Test
+ fun `D2 - bare less-than still resets depth`() {
+ val doc = MicronParser.parse(">>Heading\n<\nAfter reset")
+ // Line after < should be at depth 0
+ val afterLine = doc.lines.last()
+ assertEquals(0, afterLine.indentLevel)
+ }
+
+ // ==================== D3: Literal mode exact match ====================
+
+ @Test
+ fun `D3 - indented literal toggle is not toggled`() {
+ // NomadNet requires exact match: line == "`=" (no leading whitespace)
+ val doc = MicronParser.parse(" `=\n`!bold text`!\n `=")
+ // Since " `=" should NOT toggle literal mode, `!bold text`! should be parsed as bold
+ val texts = doc.lines.flatMap { it.elements }.filterIsInstance()
+ assertTrue(texts.any { it.style.bold && it.content == "bold text" })
+ }
+
+ @Test
+ fun `D3 - exact literal toggle still works`() {
+ val doc = MicronParser.parse("`=\n`!not bold`!\n`=")
+ val text = doc.lines[0].elements[0] as MicronElement.Text
+ assertEquals("`!not bold`!", text.content)
+ }
+
+ // ==================== D5: Divider custom char only for 2-char lines ====================
+
+ @Test
+ fun `D5 - divider with exactly 2 chars uses custom character`() {
+ val doc = MicronParser.parse("-=")
+ val divider = doc.lines[0].elements[0] as MicronElement.Divider
+ assertEquals('=', divider.character)
+ }
+
+ @Test
+ fun `D5 - divider with more than 2 chars uses default character`() {
+ // NomadNet: only exactly 2-char lines use custom char; longer lines get default ─
+ val doc = MicronParser.parse("-Hello")
+ val divider = doc.lines[0].elements[0] as MicronElement.Divider
+ assertEquals('\u2500', divider.character)
+ }
+
+ @Test
+ fun `D5 - single dash uses default character`() {
+ val doc = MicronParser.parse("-")
+ val divider = doc.lines[0].elements[0] as MicronElement.Divider
+ assertEquals('\u2500', divider.character)
+ }
+
+ // ==================== D6: No trimEnd on lines ====================
+
+ @Test
+ fun `D6 - trailing whitespace preserved`() {
+ // Trailing spaces with background color are significant for pixel art
+ val doc = MicronParser.parse("`B00ftext ")
+ val texts = doc.lines[0].elements.filterIsInstance()
+ val combined = texts.joinToString("") { it.content }
+ assertEquals("text ", combined)
+ }
+
+ @Test
+ fun `D6 - trailing spaces not stripped to empty line`() {
+ // A line of only spaces should not become empty (LineBreak)
+ val doc = MicronParser.parse(" ")
+ val elements = doc.lines[0].elements
+ assertTrue(elements[0] is MicronElement.Text)
+ assertEquals(" ", (elements[0] as MicronElement.Text).content)
+ }
+
+ // ==================== D7: Heading + field sanitization ====================
+
+ @Test
+ fun `D7 - heading with field strips heading markers`() {
+ // NomadNet: if line starts with > and contains `<, strip leading > chars
+ val doc = MicronParser.parse(">`<|username`john>")
+ // Should NOT be a heading — the > should be stripped
+ assertFalse(doc.lines[0].isHeading)
+ val field = doc.lines[0].elements.filterIsInstance().first()
+ assertEquals("username", field.name)
+ }
+
+ // ==================== D8: Divider control character check ====================
+
+ @Test
+ fun `D8 - divider with control character uses default`() {
+ // NomadNet: if ord(divider_char) < 32, use default ─
+ val doc = MicronParser.parse("-\u0001")
+ val divider = doc.lines[0].elements[0] as MicronElement.Divider
+ assertEquals('\u2500', divider.character)
+ }
+
+ @Test
+ fun `D8 - divider with tab control character uses default`() {
+ val doc = MicronParser.parse("-\t")
+ val divider = doc.lines[0].elements[0] as MicronElement.Divider
+ assertEquals('\u2500', divider.character)
+ }
}
diff --git a/python/interface_lookup.py b/python/interface_lookup.py
index af837243f..a92170ca8 100644
--- a/python/interface_lookup.py
+++ b/python/interface_lookup.py
@@ -28,6 +28,16 @@ def format_interface_name(interface_obj) -> Optional[str]:
user_name = getattr(interface_obj, 'name', None)
if user_name and user_name != class_name:
return f"{class_name}[{user_name}]"
+ # For auto-discovered interfaces, name equals class_name — try target address
+ target = getattr(interface_obj, 'target_ip', None) or getattr(interface_obj, 'target_host', None)
+ port = getattr(interface_obj, 'target_port', None)
+ if target:
+ addr = f"{target}:{port}" if port else target
+ return f"{class_name}[{addr}]"
+ # Last resort: RNS __str__ may include useful info (e.g., "TCPInterface[addr]")
+ iface_str = str(interface_obj)
+ if "[" in iface_str:
+ return iface_str
return class_name
diff --git a/python/lxst_modules/call_manager.py b/python/lxst_modules/call_manager.py
index 6167dc937..60921b850 100644
--- a/python/lxst_modules/call_manager.py
+++ b/python/lxst_modules/call_manager.py
@@ -330,23 +330,14 @@ def call(self, destination_hash_hex, profile=None):
identity = RNS.Identity.recall(identity_hash)
RNS.log(f"Identity.recall() returned: {identity is not None}", RNS.LOG_DEBUG)
- # If identity not known locally, query the network (same pattern as LXMF messaging)
+ # If identity not known locally, request path (guarded) and fail immediately
if identity is None:
- RNS.log(f"Identity not found, requesting path to {destination_hash_hex[:16]}...", RNS.LOG_DEBUG)
- try:
- RNS.Transport.request_path(identity_hash)
- except Exception as e:
- RNS.log(f"Error requesting path: {e}", RNS.LOG_WARNING)
-
- # Wait up to 5 seconds for path response to resolve identity
- for attempt in range(10):
- if self._cancel_event.is_set():
- return {"success": False, "error": "Call cancelled"}
- time.sleep(0.5)
- identity = RNS.Identity.recall(identity_hash)
- if identity:
- RNS.log(f"Identity resolved after path request (attempt {attempt + 1})", RNS.LOG_DEBUG)
- break
+ RNS.log(f"Identity not found for {destination_hash_hex[:16]}, requesting path...", RNS.LOG_DEBUG)
+ if not RNS.Transport.has_path(identity_hash):
+ try:
+ RNS.Transport.request_path(identity_hash)
+ except Exception as e:
+ RNS.log(f"Error requesting path: {e}", RNS.LOG_WARNING)
if identity is None:
RNS.log(f"Unknown identity: {destination_hash_hex[:16]}...", RNS.LOG_WARNING)
diff --git a/python/reticulum_wrapper.py b/python/reticulum_wrapper.py
index 51599e9b3..c91cf46dd 100644
--- a/python/reticulum_wrapper.py
+++ b/python/reticulum_wrapper.py
@@ -3095,28 +3095,20 @@ def _on_lxmf_delivery(self, lxmf_message):
requester_identity = RNS.Identity.recall(lxmf_message.source_hash)
if requester_identity is None:
- # Identity not cached - request path from network (Sideband pattern)
- log_info("ReticulumWrapper", "_on_lxmf_delivery",
- f"Identity for {lxmf_message.source_hash.hex()[:16]} not recalled, requesting path...")
- RNS.Transport.request_path(lxmf_message.source_hash)
- # Queue for retry after path resolution
- def retry_send():
- import time
- time.sleep(2) # Wait for path resolution
- retry_identity = RNS.Identity.recall(lxmf_message.source_hash)
- if retry_identity:
- log_info("ReticulumWrapper", "_on_lxmf_delivery",
- f"Identity recalled on retry, sending telemetry stream")
- self._send_telemetry_stream_response(
- lxmf_message.source_hash,
- retry_identity,
- timebase
- )
- else:
- log_warning("ReticulumWrapper", "_on_lxmf_delivery",
- f"Still cannot recall identity for {lxmf_message.source_hash.hex()[:16]} after retry")
- import threading
- threading.Thread(target=retry_send, daemon=True).start()
+ # Try identity hash lookup — the sender's path exists
+ # (the message arrived), but the identity may be keyed
+ # differently in the recall cache.
+ requester_identity = RNS.Identity.recall(
+ lxmf_message.source_hash, from_identity_hash=True)
+
+ if requester_identity is None:
+ # Both lookups failed — silently drop the telemetry
+ # response. The sender's identity was never announced
+ # to us; a path request won't help since we already
+ # have the path (the message arrived over it).
+ log_warning("ReticulumWrapper", "_on_lxmf_delivery",
+ f"Cannot send telemetry stream — identity for "
+ f"{lxmf_message.source_hash.hex()[:16]} not recalled")
else:
self._send_telemetry_stream_response(
lxmf_message.source_hash,
@@ -3653,29 +3645,14 @@ def send_lxmf_message(self, dest_hash: bytes, content: str, source_identity_priv
log_info("ReticulumWrapper", "send_lxmf_message", f"✅ Retrieved identity from local cache")
if not recipient_identity:
- # Request path from network (triggers announces from peers who know destination)
+ # Fire-and-forget path request (guarded by has_path check)
log_info("ReticulumWrapper", "send_lxmf_message",
- f"Identity not found, requesting path to {dest_hash.hex()[:16]}...")
- try:
- RNS.Transport.request_path(dest_hash)
- except Exception as e:
- log_warning("ReticulumWrapper", "send_lxmf_message", f"Error requesting path: {e}")
+ f"Identity not found for {dest_hash.hex()[:16]}, requesting path...")
+ self._request_path_if_needed(dest_hash)
- # Wait up to 5 seconds for path response
- for attempt in range(10):
- time.sleep(0.5)
- recipient_identity = RNS.Identity.recall(dest_hash)
- if not recipient_identity:
- recipient_identity = RNS.Identity.recall(dest_hash, from_identity_hash=True)
- if recipient_identity:
- log_info("ReticulumWrapper", "send_lxmf_message",
- f"✅ Identity resolved after path request (attempt {attempt + 1})")
- break
-
- if not recipient_identity:
- error_msg = f"Cannot send message: Recipient identity {dest_hash.hex()[:16]} not known. Path requested but no response received."
- log_error("ReticulumWrapper", "send_lxmf_message", f"❌ {error_msg}")
- return {"success": False, "error": error_msg}
+ error_msg = f"Recipient identity {dest_hash.hex()[:16]} not known — path requested, retry shortly"
+ log_warning("ReticulumWrapper", "send_lxmf_message", error_msg)
+ return {"success": False, "error": error_msg}
# Create outgoing LXMF destination object from the recalled identity
# The router.handle_outbound() REQUIRES a destination object, not just a hash!
@@ -3961,29 +3938,14 @@ def send_location_telemetry(self, dest_hash: bytes, location_json: str, source_i
recipient_identity = self.identities[dest_hash_hex]
if not recipient_identity:
- # Request path from network (triggers announces from peers who know destination)
+ # Fire-and-forget path request (guarded by has_path check)
log_info("ReticulumWrapper", "send_location_telemetry",
- f"Identity not found, requesting path to {dest_hash.hex()[:16]}...")
- try:
- RNS.Transport.request_path(dest_hash)
- except Exception as e:
- log_warning("ReticulumWrapper", "send_location_telemetry", f"Error requesting path: {e}")
+ f"Identity not found for {dest_hash.hex()[:16]}, requesting path...")
+ self._request_path_if_needed(dest_hash)
- # Wait up to 5 seconds for path response
- for attempt in range(10):
- time.sleep(0.5)
- recipient_identity = RNS.Identity.recall(dest_hash)
- if not recipient_identity:
- recipient_identity = RNS.Identity.recall(dest_hash, from_identity_hash=True)
- if recipient_identity:
- log_info("ReticulumWrapper", "send_location_telemetry",
- f"✅ Identity resolved after path request (attempt {attempt + 1})")
- break
-
- if not recipient_identity:
- error_msg = f"Recipient identity {dest_hash.hex()[:16]} not known. Path requested but no response received."
- log_error("ReticulumWrapper", "send_location_telemetry", f"❌ {error_msg}")
- return {"success": False, "error": error_msg}
+ error_msg = f"Recipient identity {dest_hash.hex()[:16]} not known — path requested, retry shortly"
+ log_error("ReticulumWrapper", "send_location_telemetry", f"❌ {error_msg}")
+ return {"success": False, "error": error_msg}
# Create outgoing LXMF destination
recipient_lxmf_destination = RNS.Destination(
@@ -4138,29 +4100,14 @@ def send_telemetry_request(self, dest_hash: bytes, source_identity_private_key:
recipient_identity = self.identities[dest_hash_hex]
if not recipient_identity:
- # Request path from network (triggers announces from peers who know destination)
+ # Fire-and-forget path request (guarded by has_path check)
log_info("ReticulumWrapper", "send_telemetry_request",
- f"Identity not found, requesting path to {dest_hash.hex()[:16]}...")
- try:
- RNS.Transport.request_path(dest_hash)
- except Exception as e:
- log_warning("ReticulumWrapper", "send_telemetry_request", f"Error requesting path: {e}")
-
- # Wait up to 5 seconds for path response
- for attempt in range(10):
- time.sleep(0.5)
- recipient_identity = RNS.Identity.recall(dest_hash)
- if not recipient_identity:
- recipient_identity = RNS.Identity.recall(dest_hash, from_identity_hash=True)
- if recipient_identity:
- log_info("ReticulumWrapper", "send_telemetry_request",
- f"✅ Identity resolved after path request (attempt {attempt + 1})")
- break
+ f"Identity not found for {dest_hash.hex()[:16]}, requesting path...")
+ self._request_path_if_needed(dest_hash)
- if not recipient_identity:
- error_msg = f"Collector identity {dest_hash.hex()[:16]} not known. Path requested but no response received."
- log_error("ReticulumWrapper", "send_telemetry_request", f"❌ {error_msg}")
- return {"success": False, "error": error_msg}
+ error_msg = f"Collector identity {dest_hash.hex()[:16]} not known — path requested, retry shortly"
+ log_error("ReticulumWrapper", "send_telemetry_request", f"❌ {error_msg}")
+ return {"success": False, "error": error_msg}
# Create outgoing LXMF destination
recipient_lxmf_destination = RNS.Destination(
@@ -4533,27 +4480,12 @@ def send_lxmf_message_with_method(self, dest_hash: bytes, content: str, source_i
recipient_identity = self.identities[dest_hash.hex()]
if not recipient_identity:
- # Request path from network (triggers announces from peers who know destination)
+ # Fire-and-forget path request (guarded by has_path check)
log_info("ReticulumWrapper", "send_lxmf_message_with_method",
- f"Identity not found, requesting path to {dest_hash.hex()[:16]}...")
- try:
- RNS.Transport.request_path(dest_hash)
- except Exception as e:
- log_warning("ReticulumWrapper", "send_lxmf_message_with_method", f"Error requesting path: {e}")
-
- # Wait up to 5 seconds for path response
- for attempt in range(10):
- time.sleep(0.5)
- recipient_identity = RNS.Identity.recall(dest_hash)
- if not recipient_identity:
- recipient_identity = RNS.Identity.recall(dest_hash, from_identity_hash=True)
- if recipient_identity:
- log_info("ReticulumWrapper", "send_lxmf_message_with_method",
- f"✅ Identity resolved after path request (attempt {attempt + 1})")
- break
+ f"Identity not found for {dest_hash.hex()[:16]}, requesting path...")
+ self._request_path_if_needed(dest_hash)
- if not recipient_identity:
- return {"success": False, "error": f"Recipient identity {dest_hash.hex()[:16]} not known. Path requested but no response received.", "delivery_method": None}
+ return {"success": False, "error": f"Recipient identity {dest_hash.hex()[:16]} not known — path requested, retry shortly", "delivery_method": None}
# Create destination
recipient_lxmf_destination = RNS.Destination(
@@ -4888,26 +4820,11 @@ def send_reaction(self, dest_hash: bytes, target_message_id: str, emoji: str,
recipient_identity = self.identities[dest_hash.hex()]
if not recipient_identity:
- # Request path from network
+ # Fire-and-forget path request (guarded by has_path check)
log_info("ReticulumWrapper", "send_reaction",
- f"Identity not found, requesting path to {dest_hash.hex()[:16]}...")
- try:
- RNS.Transport.request_path(dest_hash)
- except Exception as e:
- log_warning("ReticulumWrapper", "send_reaction", f"Error requesting path: {e}")
-
- # Wait up to 5 seconds for path response
- wait_start = time.time()
- while time.time() - wait_start < 5:
- recipient_identity = RNS.Identity.recall(dest_hash)
- if not recipient_identity:
- recipient_identity = RNS.Identity.recall(dest_hash, from_identity_hash=True)
- if recipient_identity:
- break
- time.sleep(0.1)
-
- if not recipient_identity:
- return {"success": False, "error": f"Recipient identity {dest_hash.hex()[:16]} not known"}
+ f"Identity not found for {dest_hash.hex()[:16]}, requesting path...")
+ self._request_path_if_needed(dest_hash)
+ return {"success": False, "error": f"Recipient identity {dest_hash.hex()[:16]} not known — path requested, retry shortly"}
# Create destination
recipient_lxmf_destination = RNS.Destination(
@@ -6582,16 +6499,38 @@ def has_path(self, dest_hash: bytes) -> bool:
return RNS.Transport.has_path(dest_hash)
- def request_path(self, dest_hash: bytes) -> Dict:
- """Request a path to a destination"""
- try:
- if not RETICULUM_AVAILABLE:
- return {"success": True}
+ def _request_path_if_needed(self, dest_hash: bytes) -> bool:
+ """Request a path only if one doesn't already exist.
+ Returns True — path already present, or running in mock mode (no request fired).
+ Returns False — a path request was fired (or attempted); path not yet available.
+ Callers that need to wait for the path should poll has_path() themselves.
+ """
+ if not RETICULUM_AVAILABLE or not self.reticulum:
+ return True
+
+ if RNS.Transport.has_path(dest_hash):
+ return True
+
+ try:
RNS.Transport.request_path(dest_hash)
- return {"success": True}
except Exception as e:
- return {"success": False, "error": str(e)}
+ log_warning("ReticulumWrapper", "_request_path_if_needed",
+ f"Error requesting path to {dest_hash.hex()[:16]}: {e}")
+ return False
+
+ def request_path(self, dest_hash: bytes) -> Dict:
+ """Request a path to a destination (public API, used by Kotlin).
+
+ Always returns success=True — transport-level exceptions are caught
+ and logged by _request_path_if_needed, so callers cannot distinguish
+ a successful fire from a failed one.
+ """
+ if not RETICULUM_AVAILABLE:
+ return {"success": True}
+
+ self._request_path_if_needed(dest_hash)
+ return {"success": True}
def persist_transport_data(self) -> Dict:
"""Persist Reticulum's transport data (path table, destinations) to disk."""
@@ -7020,7 +6959,7 @@ def get_full_link_stats(link, already_existed: bool, hash_for_path) -> Dict:
if not has_path:
log_debug("ReticulumWrapper", "establish_link",
f"Requesting path to {recipient_dest.hash.hex()[:16]}...")
- RNS.Transport.request_path(recipient_dest.hash)
+ self._request_path_if_needed(recipient_dest.hash)
# Wait for path if we don't have one
if not has_path:
diff --git a/python/rns_api.py b/python/rns_api.py
index 0eebd87f0..c20a260df 100644
--- a/python/rns_api.py
+++ b/python/rns_api.py
@@ -17,8 +17,13 @@ class RnsApi:
def __init__(self):
self._cancel_flag = False
+ self._request_status = ""
self._identified_links = deque(maxlen=self.MAX_IDENTIFIED_LINKS)
+ def get_request_status(self):
+ """Return current request phase status for UI display."""
+ return self._request_status
+
def get_next_hop_interface_name(self, dest_hash):
"""Return formatted interface name for next hop to destination, or None."""
try:
@@ -49,8 +54,26 @@ def _ensure_link_cache(self, wrapper):
if not hasattr(wrapper, '_nomadnet_links'):
wrapper._nomadnet_links = {}
+ def _evict_cached_link(self, dest_hash_hex):
+ """Remove a cached link after failure/timeout so next request gets a fresh one."""
+ wrapper = self._get_wrapper()
+ if wrapper and hasattr(wrapper, '_nomadnet_links'):
+ old_link = wrapper._nomadnet_links.pop(dest_hash_hex, None)
+ if old_link is not None:
+ log_info("RnsApi", "request_nomadnet_page",
+ f"Evicted stale link to {dest_hash_hex[:16]}")
+ try:
+ old_link.teardown()
+ except Exception:
+ pass
+
+ def get_download_progress(self):
+ """Return current file download progress (0.0-1.0), or -1.0 if idle."""
+ return getattr(self, '_download_progress', -1.0)
+
def request_nomadnet_page(self, dest_hash, path="/page/index.mu",
- form_data_json=None, timeout_seconds=45.0):
+ form_data_json=None, timeout_seconds=45.0,
+ download_dir=None):
"""
Request a page from a NomadNet node.
@@ -76,6 +99,9 @@ def request_nomadnet_page(self, dest_hash, path="/page/index.mu",
if not wrapper or not wrapper.router:
return {"success": False, "error": "Not initialized"}
+ def _status(msg):
+ self._request_status = msg
+
try:
dest_hash = bytes(dest_hash)
dest_hash_hex = dest_hash.hex()
@@ -125,11 +151,12 @@ def request_nomadnet_page(self, dest_hash, path="/page/index.mu",
# Only recall identity and establish link if no cached active link
if link is not None:
+ _status("Reusing connection...")
log_info("RnsApi", "request_nomadnet_page",
f"Reusing cached active link to {dest_hash_hex[:16]}")
if link is None:
- link = self._establish_link(wrapper, dest_hash, dest_hash_hex, deadline)
+ link = self._establish_link(wrapper, dest_hash, dest_hash_hex, deadline, _status)
if isinstance(link, dict):
return link # Error dict
@@ -145,15 +172,19 @@ def request_nomadnet_page(self, dest_hash, path="/page/index.mu",
wrapper._nomadnet_links[dest_hash_hex] = link
# Make page request over the link
- return self._send_page_request(link, path, request_data, dest_hash_hex, deadline)
+ _status("Requesting page...")
+ return self._send_page_request(link, path, request_data, dest_hash_hex, deadline,
+ download_dir=download_dir)
except Exception as e:
log_error("RnsApi", "request_nomadnet_page", f"Error: {e}")
import traceback
traceback.print_exc()
return {"success": False, "error": str(e)}
+ finally:
+ self._request_status = ""
- def _establish_link(self, wrapper, dest_hash, dest_hash_hex, deadline):
+ def _establish_link(self, wrapper, dest_hash, dest_hash_hex, deadline, _status=None):
"""Establish a new RNS link to a NomadNet node.
Matches NomadNet TUI's proven sequence (Browser.py __load):
@@ -165,38 +196,41 @@ def _establish_link(self, wrapper, dest_hash, dest_hash_hex, deadline):
"""
import RNS
+ if _status is None:
+ _status = lambda msg: None
+
# ── Phase 1: Ensure path ──
# Match NomadNet TUI: check has_path FIRST, request only if missing.
# The path response is a cached announce that populates BOTH the
# path table AND known_destinations, so identity recall after this
# is guaranteed to succeed.
if not RNS.Transport.has_path(dest_hash):
+ _status("Looking up path...")
log_info("RnsApi", "request_nomadnet_page",
- f"No path to {dest_hash_hex[:16]}, requesting...")
- # Reserve 10s for link establishment + page request.
- # RNS await_path sends request_path ONCE with no retry.
- # If the packet or response is lost, the path never arrives.
- # Retry request_path every few seconds to work around this.
- path_deadline = time.time() + max(deadline - time.time() - 10, 10)
- PATH_RETRY_INTERVAL = 5
- attempt = 1
+ f"No path to {dest_hash_hex[:16]}, requesting... "
+ f"(hash_len={len(dest_hash)}, "
+ f"interfaces={len(RNS.Transport.interfaces)}, "
+ f"is_connected={RNS.Reticulum.get_instance().is_connected_to_shared_instance})")
+ RNS.Transport.request_path(dest_hash)
+
+ path_timeout = min(
+ RNS.Transport.first_hop_timeout(dest_hash) + 15,
+ deadline - time.time() - 10,
+ )
+ path_deadline = time.time() + max(path_timeout, 5)
+ log_debug("RnsApi", "request_nomadnet_page",
+ f"Waiting {path_timeout:.0f}s for path (first_hop_timeout="
+ f"{RNS.Transport.first_hop_timeout(dest_hash):.1f}s)")
while not RNS.Transport.has_path(dest_hash) and time.time() < path_deadline:
- if attempt > 1:
- log_info("RnsApi", "request_nomadnet_page",
- f"Path retry #{attempt} for {dest_hash_hex[:16]}")
- RNS.Transport.request_path(dest_hash)
- # Wait up to PATH_RETRY_INTERVAL for path to arrive
- wait_until = min(time.time() + PATH_RETRY_INTERVAL, path_deadline)
- while not RNS.Transport.has_path(dest_hash) and time.time() < wait_until:
- if self._cancel_flag:
- return {"success": False, "error": "Cancelled"}
- time.sleep(0.25)
- attempt += 1
+ if self._cancel_flag:
+ return {"success": False, "error": "Cancelled"}
+ time.sleep(0.25)
if not RNS.Transport.has_path(dest_hash):
return {"success": False, "error": "No path to node. It may be offline or unreachable."}
hops = RNS.Transport.hops_to(dest_hash)
+ _status(f"Path found ({hops} hops)")
log_info("RnsApi", "request_nomadnet_page",
f"Path to {dest_hash_hex[:16]} available (hops={hops})")
@@ -227,6 +261,7 @@ def _establish_link(self, wrapper, dest_hash, dest_hash_hex, deadline):
f"Destination hash mismatch! passed={dest_hash_hex} computed={node_dest.hash.hex()}")
# ── Phase 4: Establish link ──
+ _status(f"Connecting ({hops} hops)...")
log_info("RnsApi", "request_nomadnet_page",
f"Creating link to {dest_hash_hex[:16]} (hops={hops})")
@@ -284,7 +319,8 @@ def on_link_closed(closed_link):
return link
- def _send_page_request(self, link, path, request_data, dest_hash_hex, deadline):
+ def _send_page_request(self, link, path, request_data, dest_hash_hex, deadline,
+ download_dir=None):
"""Send a page request over an established link.
Returns a result dict with success/content/error.
@@ -292,12 +328,26 @@ def _send_page_request(self, link, path, request_data, dest_hash_hex, deadline):
response_event = threading.Event()
response_data = [None]
response_error = [None]
+ response_metadata = [None]
def response_received(request_receipt):
try:
- response_data[0] = request_receipt.response
- log_info("RnsApi", "request_nomadnet_page",
- f"Page received: {len(response_data[0])} bytes")
+ response_metadata[0] = request_receipt.metadata
+ if request_receipt.metadata:
+ # File response — read data NOW while handle is still open
+ # (RNS closes the handle after this callback returns)
+ raw = request_receipt.response
+ if hasattr(raw, 'read'):
+ response_data[0] = raw.read()
+ raw.close()
+ else:
+ response_data[0] = bytes(raw) if raw else b""
+ log_info("RnsApi", "request_nomadnet_page",
+ f"File response received for {path} ({len(response_data[0])} bytes)")
+ else:
+ response_data[0] = request_receipt.response
+ log_info("RnsApi", "request_nomadnet_page",
+ f"Page received: {len(response_data[0])} bytes")
except Exception as e:
response_error[0] = str(e)
response_event.set()
@@ -308,30 +358,53 @@ def request_failed(request_receipt=None):
f"Page request failed for {path}")
response_event.set()
+ self._download_progress = -1.0
+
+ def progress_update(request_receipt):
+ self._download_progress = request_receipt.progress
+
log_debug("RnsApi", "request_nomadnet_page",
f"Sending request for path: {path}")
- link.request(
+ receipt = link.request(
path,
data=request_data,
response_callback=response_received,
- failed_callback=request_failed
+ failed_callback=request_failed,
+ progress_callback=progress_update,
)
+ if not receipt:
+ log_warning("RnsApi", "request_nomadnet_page",
+ f"link.request() returned False for {path}")
+ self._evict_cached_link(dest_hash_hex)
+ return {"success": False, "error": "Request could not be sent"}
+
# Wait for response, polling cancel flag every 0.5s
while not response_event.is_set() and time.time() < deadline:
if self._cancel_flag:
+ try:
+ receipt.cancel()
+ except Exception:
+ pass
return {"success": False, "error": "Cancelled"}
response_event.wait(timeout=0.5)
if response_error[0]:
+ self._evict_cached_link(dest_hash_hex)
return {"success": False, "error": response_error[0]}
if response_data[0] is None:
log_warning("RnsApi", "request_nomadnet_page",
f"Page request timed out for {path} on {dest_hash_hex[:16]}")
+ self._evict_cached_link(dest_hash_hex)
return {"success": False, "error": "Page request timed out"}
+ # Check if this is a file response (has metadata with filename)
+ if response_metadata[0] is not None:
+ return self._save_file_response(
+ response_data[0], response_metadata[0], path, download_dir)
+
# Decode response
try:
content = response_data[0].decode("utf-8")
@@ -340,10 +413,52 @@ def request_failed(request_receipt=None):
return {
"success": True,
+ "type": "page",
"content": content,
"path": path
}
+ def _save_file_response(self, file_data, metadata, path, download_dir):
+ """Save a file response (already read as bytes) to disk and return result dict."""
+ import os
+
+ name_raw = metadata.get("name", b"download") if isinstance(metadata, dict) else b"download"
+ if isinstance(name_raw, bytes):
+ filename = name_raw.decode("utf-8", errors="replace")
+ else:
+ filename = str(name_raw)
+
+ # Sanitize filename to prevent path traversal
+ filename = os.path.basename(filename)
+ if not filename:
+ filename = "download"
+
+ if not download_dir:
+ download_dir = "/tmp/nomadnet_downloads"
+ os.makedirs(download_dir, exist_ok=True)
+ save_path = os.path.join(download_dir, filename)
+
+ try:
+ with open(save_path, "wb") as out_file:
+ out_file.write(file_data)
+
+ file_size = os.path.getsize(save_path)
+ log_info("RnsApi", "request_nomadnet_page",
+ f"File saved: {filename} ({file_size} bytes)")
+
+ return {
+ "success": True,
+ "type": "file",
+ "file_path": save_path,
+ "file_name": filename,
+ "file_size": file_size,
+ "path": path,
+ }
+ except Exception as e:
+ log_error("RnsApi", "request_nomadnet_page",
+ f"Failed to save file {filename}: {e}")
+ return {"success": False, "error": f"Failed to save file: {e}"}
+
def cancel_nomadnet_page_request(self):
"""Set cancellation flag for any in-progress NomadNet page request."""
self._cancel_flag = True
diff --git a/python/test_interface_lookup_format.py b/python/test_interface_lookup_format.py
new file mode 100644
index 000000000..db95542ae
--- /dev/null
+++ b/python/test_interface_lookup_format.py
@@ -0,0 +1,70 @@
+"""Tests for format_interface_name() in interface_lookup.py."""
+import unittest
+from unittest.mock import Mock
+
+from interface_lookup import format_interface_name
+
+
+class TestFormatInterfaceName(unittest.TestCase):
+ """Tests for the format_interface_name function."""
+
+ def test_none_returns_none(self):
+ self.assertIsNone(format_interface_name(None))
+
+ def test_user_configured_name(self):
+ iface = Mock()
+ type(iface).__name__ = "TCPInterface"
+ iface.name = "Sideband Server/192.168.1.100:4965"
+ self.assertEqual("TCPInterface[Sideband Server/192.168.1.100:4965]", format_interface_name(iface))
+
+ def test_name_equals_classname_with_target_ip(self):
+ """Auto-discovered interface: name == class name, but target_ip is set."""
+ iface = Mock()
+ type(iface).__name__ = "BackboneClientInterface"
+ iface.name = "BackboneClientInterface"
+ iface.target_ip = "10.0.4.63"
+ iface.target_port = 4243
+ self.assertEqual("BackboneClientInterface[10.0.4.63:4243]", format_interface_name(iface))
+
+ def test_name_equals_classname_with_target_host(self):
+ """Auto-discovered interface with hostname instead of IP."""
+ iface = Mock()
+ type(iface).__name__ = "TCPClientInterface"
+ iface.name = "TCPClientInterface"
+ iface.target_ip = None
+ iface.target_host = "rns.example.com"
+ iface.target_port = 4242
+ self.assertEqual("TCPClientInterface[rns.example.com:4242]", format_interface_name(iface))
+
+ def test_name_equals_classname_target_ip_no_port(self):
+ """Target IP without port."""
+ iface = Mock()
+ type(iface).__name__ = "TCPClientInterface"
+ iface.name = "TCPClientInterface"
+ iface.target_ip = "10.0.0.1"
+ iface.target_port = None
+ self.assertEqual("TCPClientInterface[10.0.0.1]", format_interface_name(iface))
+
+ def test_name_equals_classname_no_target_with_str(self):
+ """Falls back to str() when no target attributes, str() has brackets."""
+ iface = Mock()
+ type(iface).__name__ = "AutoInterface"
+ iface.name = "AutoInterface"
+ iface.target_ip = None
+ iface.target_host = None
+ iface.__str__ = Mock(return_value="AutoInterface[Local/fe80::1]")
+ self.assertEqual("AutoInterface[Local/fe80::1]", format_interface_name(iface))
+
+ def test_name_equals_classname_no_target_no_brackets_in_str(self):
+ """Falls back to class name when str() has no brackets."""
+ iface = Mock()
+ type(iface).__name__ = "AutoInterface"
+ iface.name = "AutoInterface"
+ iface.target_ip = None
+ iface.target_host = None
+ iface.__str__ = Mock(return_value="AutoInterface")
+ self.assertEqual("AutoInterface", format_interface_name(iface))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/python/test_path_request.py b/python/test_path_request.py
index e6ee81868..9ef8f77ee 100644
--- a/python/test_path_request.py
+++ b/python/test_path_request.py
@@ -35,39 +35,46 @@ class TestPathRequestIntegration(unittest.TestCase):
"""Integration tests that verify the path request code is present and structured correctly."""
def test_path_request_code_exists_in_send_lxmf_message(self):
- """Verify that send_lxmf_message contains path request logic"""
+ """Verify that send_lxmf_message delegates path requests to _request_path_if_needed"""
import inspect
source = inspect.getsource(reticulum_wrapper.ReticulumWrapper.send_lxmf_message)
- # Check for key patterns that indicate path request logic
- self.assertIn('Transport.request_path', source,
- "send_lxmf_message should call Transport.request_path")
- self.assertIn('Identity not found, requesting path', source,
- "send_lxmf_message should log path request")
- self.assertIn('Identity resolved after path request', source,
- "send_lxmf_message should log successful resolution")
+ # Send methods now delegate to _request_path_if_needed instead of calling Transport directly
+ self.assertIn('_request_path_if_needed', source,
+ "send_lxmf_message should call _request_path_if_needed")
+ self.assertIn('Identity not found', source,
+ "send_lxmf_message should log when identity is not found")
def test_path_request_code_exists_in_send_lxmf_message_with_method(self):
- """Verify that send_lxmf_message_with_method contains path request logic"""
+ """Verify that send_lxmf_message_with_method delegates path requests to _request_path_if_needed"""
import inspect
source = inspect.getsource(reticulum_wrapper.ReticulumWrapper.send_lxmf_message_with_method)
- # Check for key patterns
- self.assertIn('Transport.request_path', source,
- "send_lxmf_message_with_method should call Transport.request_path")
- self.assertIn('Identity not found, requesting path', source,
- "send_lxmf_message_with_method should log path request")
+ # Send methods now delegate to _request_path_if_needed instead of calling Transport directly
+ self.assertIn('_request_path_if_needed', source,
+ "send_lxmf_message_with_method should call _request_path_if_needed")
+ self.assertIn('Identity not found', source,
+ "send_lxmf_message_with_method should log when identity is not found")
- def test_retry_loop_has_correct_timing(self):
- """Verify the retry loop parameters (10 iterations, 0.5s sleep = 5s total)"""
+ def test_no_retry_loop_in_send_method(self):
+ """Verify send methods no longer contain blocking retry loops.
+
+ After the path-dedup refactor, send methods call _request_path_if_needed
+ and fail immediately if the identity isn't found, rather than polling
+ in a 5-second retry loop.
+ """
import inspect
source = inspect.getsource(reticulum_wrapper.ReticulumWrapper.send_lxmf_message_with_method)
- # Check for the retry loop structure
- self.assertIn('range(10)', source,
- "Retry loop should iterate 10 times")
- self.assertIn('sleep(0.5)', source,
- "Should sleep 0.5 seconds between retries")
+ # The retry loop (range(10) + sleep(0.5)) should be gone
+ self.assertNotIn('range(10)', source,
+ "send_lxmf_message_with_method should no longer have a retry loop")
+ self.assertNotIn('sleep(0.5)', source,
+ "send_lxmf_message_with_method should no longer sleep between retries")
+
+ # Instead, it should delegate to _request_path_if_needed
+ self.assertIn('_request_path_if_needed', source,
+ "send_lxmf_message_with_method should delegate to _request_path_if_needed")
class TestPathRequestErrorMessages(unittest.TestCase):
@@ -79,7 +86,7 @@ def test_error_message_mentions_path_requested(self):
source = inspect.getsource(reticulum_wrapper.ReticulumWrapper.send_lxmf_message_with_method)
# The error message should indicate that a path was requested
- self.assertIn('Path requested but no response received', source,
+ self.assertIn('path requested, retry shortly', source,
"Error message should mention path was requested")
diff --git a/python/test_telemetry_host_mode.py b/python/test_telemetry_host_mode.py
index b8ac6c034..23ba33830 100644
--- a/python/test_telemetry_host_mode.py
+++ b/python/test_telemetry_host_mode.py
@@ -1221,8 +1221,8 @@ def test_skips_field_commands_when_collector_disabled(self):
# Verify _send_telemetry_stream_response was NOT called
self.wrapper._send_telemetry_stream_response.assert_not_called()
- def test_handles_field_commands_with_identity_retry(self):
- """Should retry sending when identity is not immediately recalled."""
+ def test_skips_telemetry_response_when_identity_not_found(self):
+ """Should skip telemetry response when identity is not recalled (no retry thread)."""
commands = [{reticulum_wrapper.COMMAND_TELEMETRY_REQUEST: [0, True]}]
fields = {reticulum_wrapper.FIELD_COMMANDS: commands}
mock_message = self._create_mock_lxmf_message(fields=fields)
@@ -1230,29 +1230,16 @@ def test_handles_field_commands_with_identity_retry(self):
# Add requester to allowed list (source_hash is "a" * 32)
self.wrapper.telemetry_allowed_requesters = {"a" * 32}
- # First recall returns None, then returns identity on retry
- mock_identity = MagicMock()
- reticulum_wrapper.RNS.Identity.recall.side_effect = [None, mock_identity]
+ # Identity recall returns None — path will be requested but response skipped
+ reticulum_wrapper.RNS.Identity.recall.return_value = None
# Spy on _send_telemetry_stream_response
self.wrapper._send_telemetry_stream_response = MagicMock()
- # Patch threading and time.sleep to execute immediately
- with unittest.mock.patch('threading.Thread') as mock_thread:
- # Capture and execute the thread target immediately
- def run_target(*args, **kwargs):
- target = kwargs.get('target')
- if target:
- # Patch time.sleep inside the target
- with unittest.mock.patch('time.sleep'):
- target()
- return MagicMock()
- mock_thread.side_effect = run_target
-
- self.wrapper._on_lxmf_delivery(mock_message)
-
- # Verify _send_telemetry_stream_response was called after retry
- self.wrapper._send_telemetry_stream_response.assert_called()
+ self.wrapper._on_lxmf_delivery(mock_message)
+
+ # Verify _send_telemetry_stream_response was NOT called (identity unknown)
+ self.wrapper._send_telemetry_stream_response.assert_not_called()
def test_ignores_non_collector_request(self):
"""Should ignore telemetry requests with is_collector_request=False."""
diff --git a/python/test_wrapper_messaging.py b/python/test_wrapper_messaging.py
index 8383627b3..a9a7b6982 100644
--- a/python/test_wrapper_messaging.py
+++ b/python/test_wrapper_messaging.py
@@ -174,9 +174,14 @@ def test_send_message_with_image_attachment(self, mock_lxmf, mock_rns):
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_send_message_identity_not_found(self, mock_lxmf, mock_rns):
- """Test sending when recipient identity cannot be recalled"""
+ """Test sending when recipient identity cannot be recalled.
+
+ When identity is not found, send_lxmf_message calls _request_path_if_needed
+ which checks has_path before calling Transport.request_path.
+ """
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
+ wrapper.reticulum = Mock() # Must be truthy for _request_path_if_needed to proceed
wrapper.router = MagicMock()
mock_local_dest = MagicMock()
@@ -189,7 +194,10 @@ def test_send_message_identity_not_found(self, mock_lxmf, mock_rns):
# Empty identities cache
wrapper.identities = {}
- # Should still request path and attempt send
+ # has_path returns False so _request_path_if_needed will fire Transport.request_path
+ mock_rns.Transport.has_path = Mock(return_value=False)
+ mock_rns.Transport.request_path = Mock()
+
result = wrapper.send_lxmf_message(
dest_hash=b'0123456789abcdef',
content="Test message",
@@ -200,8 +208,8 @@ def test_send_message_identity_not_found(self, mock_lxmf, mock_rns):
self.assertFalse(result['success'])
self.assertIn('error', result)
- # Verify path request was attempted
- mock_rns.Transport.request_path.assert_called()
+ # Verify path request was attempted (through _request_path_if_needed)
+ mock_rns.Transport.request_path.assert_called_once_with(b'0123456789abcdef')
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
@@ -2328,25 +2336,26 @@ def tearDown(self):
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_requests_path_when_identity_not_found(self, mock_lxmf_module, mock_rns, mock_sleep):
- """Test that path is requested when identity recall initially fails"""
+ """Test that _request_path_if_needed fires when identity recall fails.
+
+ After the path-dedup refactor, the send method no longer retries in a loop.
+ It calls _request_path_if_needed (which checks has_path before requesting)
+ and returns an error immediately.
+ """
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
+ wrapper.reticulum = Mock() # Must be truthy for _request_path_if_needed to proceed
wrapper.router = MagicMock()
wrapper.local_lxmf_destination = MagicMock()
wrapper.display_name = "Test"
- # Identity found on 3rd attempt (after path request)
- mock_identity = MagicMock()
- mock_identity.hash = b'0123456789abcdef'
- # recall returns None twice, then identity on 3rd call
- mock_rns.Identity.recall.side_effect = [None, None, None, mock_identity]
-
- mock_dest = MagicMock()
- mock_rns.Destination.return_value = mock_dest
+ # Identity never found (no retry loop to eventually find it)
+ mock_rns.Identity.recall.return_value = None
+ wrapper.identities = {}
- mock_message = MagicMock()
- mock_message.hash = b'msghash123456789'
- mock_lxmf_module.LXMessage.return_value = mock_message
+ # has_path returns False so Transport.request_path fires
+ mock_rns.Transport.has_path = Mock(return_value=False)
+ mock_rns.Transport.request_path = Mock()
result = wrapper.send_lxmf_message_with_method(
dest_hash=b'0123456789abcdef',
@@ -2355,16 +2364,22 @@ def test_requests_path_when_identity_not_found(self, mock_lxmf_module, mock_rns,
delivery_method="direct"
)
- # Verify path was requested
- mock_rns.Transport.request_path.assert_called()
- # Verify sleep was called (retry loop)
- self.assertTrue(mock_sleep.called)
+ # Verify path was requested through _request_path_if_needed
+ mock_rns.Transport.request_path.assert_called_once_with(b'0123456789abcdef')
+ # Should return error immediately (no retry loop, no sleep)
+ self.assertFalse(result['success'])
+ self.assertIn('error', result)
+ self.assertFalse(mock_sleep.called, "No retry loop means no sleep calls")
@patch('reticulum_wrapper.time.sleep')
@patch('reticulum_wrapper.RNS')
@patch('reticulum_wrapper.LXMF')
def test_path_request_timeout_returns_error(self, mock_lxmf_module, mock_rns, mock_sleep):
- """Test error returned when path request times out after all retries"""
+ """Test error returned immediately when identity not found.
+
+ After the path-dedup refactor, there is no retry loop. The send method
+ calls _request_path_if_needed and returns an error immediately.
+ """
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
wrapper.initialized = True
wrapper.router = MagicMock()
@@ -2373,6 +2388,11 @@ def test_path_request_timeout_returns_error(self, mock_lxmf_module, mock_rns, mo
# Identity never found
mock_rns.Identity.recall.return_value = None
+ wrapper.identities = {}
+
+ # has_path returns False so the path request fires
+ mock_rns.Transport.has_path = Mock(return_value=False)
+ mock_rns.Transport.request_path = Mock()
result = wrapper.send_lxmf_message_with_method(
dest_hash=b'0123456789abcdef',
@@ -2384,9 +2404,9 @@ def test_path_request_timeout_returns_error(self, mock_lxmf_module, mock_rns, mo
# Should fail with "not known" error
self.assertFalse(result['success'])
self.assertIn('not known', result['error'].lower())
- # Verify retry attempts ran (at least 10 sleep calls for the retry loop)
- # Note: Use >= because background threads may also call time.sleep
- self.assertGreaterEqual(mock_sleep.call_count, 10)
+ # No retry loop means no sleep calls
+ self.assertEqual(mock_sleep.call_count, 0,
+ "No retry loop — should not sleep")
@patch('reticulum_wrapper.time.sleep')
@patch('reticulum_wrapper.RNS')
diff --git a/python/test_wrapper_path.py b/python/test_wrapper_path.py
index 6368d632f..0bfd9225e 100644
--- a/python/test_wrapper_path.py
+++ b/python/test_wrapper_path.py
@@ -122,34 +122,44 @@ def test_request_path_mock_mode_returns_success(self):
@patch('reticulum_wrapper.RETICULUM_AVAILABLE', True)
@patch('reticulum_wrapper.RNS')
def test_request_path_calls_rns_transport(self, mock_rns):
- """Test that request_path calls RNS.Transport.request_path"""
- # Mock RNS.Transport.request_path
+ """Test that request_path calls RNS.Transport.request_path when no path exists"""
+ # _request_path_if_needed checks has_path first; return False so the request fires
+ mock_rns.Transport.has_path = Mock(return_value=False)
mock_rns.Transport.request_path = Mock()
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
+ wrapper.reticulum = Mock() # Must be truthy for _request_path_if_needed to proceed
test_dest_hash = b'test_destination_hash'
result = wrapper.request_path(test_dest_hash)
- # Verify RNS.Transport.request_path was called
+ # Verify RNS.Transport.request_path was called through _request_path_if_needed
mock_rns.Transport.request_path.assert_called_once_with(test_dest_hash)
self.assertTrue(result['success'], "request_path should return success")
@patch('reticulum_wrapper.RETICULUM_AVAILABLE', True)
@patch('reticulum_wrapper.RNS')
def test_request_path_handles_exception(self, mock_rns):
- """Test that request_path handles exceptions gracefully"""
- # Mock RNS.Transport.request_path to raise an exception
+ """Test that request_path handles exceptions gracefully.
+
+ _request_path_if_needed catches Transport.request_path exceptions
+ internally (logging them), so request_path still returns success.
+ """
+ # has_path must return False so the Transport.request_path call fires
+ mock_rns.Transport.has_path = Mock(return_value=False)
mock_rns.Transport.request_path = Mock(side_effect=Exception("Network error"))
wrapper = reticulum_wrapper.ReticulumWrapper(self.temp_dir)
+ wrapper.reticulum = Mock() # Must be truthy for _request_path_if_needed to proceed
test_dest_hash = b'test_destination_hash'
result = wrapper.request_path(test_dest_hash)
- self.assertFalse(result['success'], "request_path should return failure on exception")
- self.assertIn('error', result, "Should include error message")
- self.assertEqual(result['error'], "Network error")
+ # The exception is caught inside _request_path_if_needed and logged,
+ # so request_path still returns success
+ self.assertTrue(result['success'],
+ "request_path should return success (exception is caught internally)")
+ mock_rns.Transport.request_path.assert_called_once_with(test_dest_hash)
@patch('reticulum_wrapper.RETICULUM_AVAILABLE', True)
@patch('reticulum_wrapper.RNS')
diff --git a/reticulum/build.gradle.kts b/reticulum/build.gradle.kts
index 4ac9db263..d408e3985 100644
--- a/reticulum/build.gradle.kts
+++ b/reticulum/build.gradle.kts
@@ -1,6 +1,5 @@
plugins {
id("com.android.library")
- kotlin("android")
id("com.google.devtools.ksp")
kotlin("plugin.parcelize")
kotlin("plugin.serialization")
@@ -10,7 +9,7 @@ plugins {
android {
namespace = "tech.torlando.columba.reticulum"
- compileSdk = 35
+ compileSdk = 36
defaultConfig {
minSdk = 24
@@ -76,7 +75,7 @@ dependencies {
implementation(libs.serialization.json)
// Crash Reporting - Sentry (for KotlinBLEBridge metrics)
- implementation("io.sentry:sentry-android:8.29.0")
+ implementation("io.sentry:sentry-android:8.31.0")
// Testing
testImplementation(libs.junit)
diff --git a/screenshot-tests/build.gradle.kts b/screenshot-tests/build.gradle.kts
index 246a4da9b..f9f0c0265 100644
--- a/screenshot-tests/build.gradle.kts
+++ b/screenshot-tests/build.gradle.kts
@@ -1,13 +1,12 @@
plugins {
id("com.android.library")
- id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("app.cash.paparazzi")
}
android {
namespace = "com.lxmf.messenger.screenshot"
- compileSdk = 35
+ compileSdk = 36
defaultConfig {
minSdk = 24