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 @@ -alt text +

+ alt text +

# 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 + +![Total Downloads](https://img.shields.io/github/downloads/torlando-tech/columba/total?label=total%20downloads) ![Latest](https://img.shields.io/github/downloads/torlando-tech/columba/latest/total?label=latest%20downloads) ![Pre-release](https://img.shields.io/github/downloads-pre/torlando-tech/columba/latest/total?label=pre-release%20downloads) + + +[![Star History Chart](https://api.star-history.com/svg?repos=torlando-tech/columba&type=Date)](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("") + val doc = MicronParser.parse("`") val checkbox = doc.lines[0] .elements @@ -496,7 +496,7 @@ class MicronParserTest { @Test fun `checkbox prechecked`() { - val doc = MicronParser.parse("") + val doc = MicronParser.parse("`") 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