diff --git a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt index d5db98bb..7760a9c1 100644 --- a/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/MediaSendingManager.kt @@ -628,8 +628,9 @@ class MediaSendingManager( senderPeerID = meshService.myPeerID ) - // Preparation already built and admitted the exact final packet. Map - // progress before commit so the first asynchronous event cannot race us. + // Preparation already built the exact final packet. Map the transfer before commit so the + // first asynchronous event cannot race us, but keep the local status at Sending until a + // real progress event proves transport admission. if (!messageManager.addPrivateMessageDurably(conversationID, msg, forceRead = true)) { Log.e(TAG, "Prepared private-media message could not be persisted; send aborted") addPrivateMediaSystemMessage( @@ -644,7 +645,7 @@ class MediaSendingManager( } messageManager.updateMessageDeliveryStatus( msg.id, - com.bitchat.android.model.DeliveryStatus.PartiallyDelivered(0, 100) + com.bitchat.android.model.DeliveryStatus.Sending ) if (!preparation.transfer.commit()) { @@ -708,10 +709,10 @@ class MediaSendingManager( messageTransferMap[message.id] = transferId } - // Seed progress so animations start immediately + // Seed the pending state without fabricating delivery progress. messageManager.updateMessageDeliveryStatus( message.id, - com.bitchat.android.model.DeliveryStatus.PartiallyDelivered(0, 100) + com.bitchat.android.model.DeliveryStatus.Sending ) withContext(mediaWorkDispatcher) { diff --git a/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt index 51204f30..3f3a7e6e 100644 --- a/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/ui/MediaSendingManagerMigrationTest.kt @@ -445,6 +445,30 @@ class MediaSendingManagerMigrationTest { } } + @Test + fun `public transfer failure replaces the local sending seed`() { + manager.sendImageNote(null, null, file.absolutePath) + + val fileEcho = state.getMessagesValue() + .single { it.type == BitchatMessageType.Image } + assertTrue(fileEcho.deliveryStatus is DeliveryStatus.Sending) + + manager.updateTransferProgress("failed-transfer", fileEcho.id) + manager.handleTransferProgressEvent( + com.bitchat.android.mesh.TransferProgressEvent( + transferId = "failed-transfer", + sent = 0, + total = 100, + completed = true, + failed = true + ) + ) + + val failedEcho = state.getMessagesValue() + .single { it.id == fileEcho.id } + assertTrue(failedEcho.deliveryStatus is DeliveryStatus.Failed) + } + @Test fun `cancelled consent cannot later send or echo`() { whenever(mesh.prepareFilePrivate(eq(peerID), any(), any(), eq(false))) diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index 819fc5cd..fba5159d 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -98,6 +98,9 @@ val sharedSourceIncludes = listOf( ) val sharedSourceExcludes = listOf( "com/bitchat/android/model/FileSharingManager.kt", + // The phone gate reads phone-only BuildConfig fields. Wear shares mesh consumers of the + // gate, but does not ship NDR, so it provides a same-FQN fail-closed implementation. + "com/bitchat/android/model/NdrFeatureGate.kt", // Legacy phone monolith and Wi-Fi Aware multiplexer; the watch composes its own service // (MeshCore-style) in M2 instead of reusing these. "com/bitchat/android/mesh/BluetoothMeshService.kt", diff --git a/wear/src/main/java/com/bitchat/android/model/NdrFeatureGate.kt b/wear/src/main/java/com/bitchat/android/model/NdrFeatureGate.kt new file mode 100644 index 00000000..b71e9ecf --- /dev/null +++ b/wear/src/main/java/com/bitchat/android/model/NdrFeatureGate.kt @@ -0,0 +1,21 @@ +package com.bitchat.android.model + +import com.bitchat.watch.BuildConfig + +/** + * Wear OS does not currently ship the Nostr double-ratchet runtime. + * + * Keep shared mesh consumers fail-closed while retaining the debug-only override used by the + * shared JVM tests. Production Wear builds can never advertise or accept NDR through this gate. + */ +object NdrFeatureGate { + @Volatile + private var debugTestOverride = false + + fun isEnabled(): Boolean = BuildConfig.DEBUG && debugTestOverride + + internal fun setEnabledForTests(enabled: Boolean) { + check(BuildConfig.DEBUG) { "The NDR test override is unavailable in release builds" } + debugTestOverride = enabled + } +} diff --git a/wear/src/test/java/com/bitchat/android/model/NdrFeatureGateTest.kt b/wear/src/test/java/com/bitchat/android/model/NdrFeatureGateTest.kt new file mode 100644 index 00000000..3c7e3fac --- /dev/null +++ b/wear/src/test/java/com/bitchat/android/model/NdrFeatureGateTest.kt @@ -0,0 +1,20 @@ +package com.bitchat.android.model + +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Test + +class NdrFeatureGateTest { + @After + fun resetGate() { + NdrFeatureGate.setEnabledForTests(false) + } + + @Test + fun `wear does not advertise NDR by default`() { + NdrFeatureGate.setEnabledForTests(false) + + assertFalse(NdrFeatureGate.isEnabled()) + assertFalse(PeerCapabilities.LOCAL_SUPPORTED.contains(PeerCapabilities.NOSTR_DOUBLE_RATCHET)) + } +}