diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index ce6a5ad1..03ae1431 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -12,7 +12,9 @@ android { defaultConfig { applicationId = "com.bitchat.watch" - minSdk = 30 // Wear OS 3 (Pixel Watch 1); BLE APIs match the phone app's usage + minSdk = 33 // Wear OS 4 (Pixel Watch 1+): the S+ Bluetooth permissions the app + // declares only exist from API 31, and API 30 would additionally require location + // for BLE scan results, which the app deliberately refuses. targetSdk = libs.versions.targetSdk.get().toInt() versionCode = 1 versionName = "0.1.0" diff --git a/wear/src/main/java/com/bitchat/watch/MainActivity.kt b/wear/src/main/java/com/bitchat/watch/MainActivity.kt index b261a77a..7770a6f4 100644 --- a/wear/src/main/java/com/bitchat/watch/MainActivity.kt +++ b/wear/src/main/java/com/bitchat/watch/MainActivity.kt @@ -233,6 +233,7 @@ fun WearNavHost(openDmPeer: String?, onOpenDmHandled: () -> Unit) { ) is WearScreen.TextInput -> { val mesh = WearMeshService.peek() + val sendScope = androidx.compose.runtime.rememberCoroutineScope() com.bitchat.watch.ui.TextInputScreen( onSend = { text -> mesh?.let { m -> @@ -240,7 +241,7 @@ fun WearNavHost(openDmPeer: String?, onOpenDmHandled: () -> Unit) { sendPublicMessage(m, text) } else { val nick = m.getPeerNickname(current.peerID) ?: current.peerID - sendPrivateMessage(m, current.peerID, nick, text) + sendPrivateMessage(m, current.peerID, nick, text, sendScope) } } goBack() diff --git a/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt index 5e151809..71d707f1 100644 --- a/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt +++ b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt @@ -50,7 +50,7 @@ class WearMeshService private constructor(private val context: Context) { private val bleTransport = BleTransport() private val meshCore: MeshCore - private val connectionManager: BluetoothConnectionManager + private var connectionManager: BluetoothConnectionManager @Volatile var nickname: String = loadNickname() @@ -246,6 +246,15 @@ class WearMeshService private constructor(private val context: Context) { Log.w(TAG, "Mesh already active, ignoring duplicate start") return } + if (!connectionManager.isReusable()) { + // A previous stopServices() cancelled the manager's coroutine scope; the shared + // API marks such managers single-use, so build a fresh one instead of starting + // a zombie mesh that reports active while scanning nothing. + Log.i(TAG, "Recreating BluetoothConnectionManager after terminal stop") + connectionManager = BluetoothConnectionManager(context, myPeerID, meshCore.fragmentManager) + bleTransport.connectionManager = connectionManager + wireBluetoothDelegate() + } val started = connectionManager.startServices() if (started) { isActive = true diff --git a/wear/src/main/java/com/bitchat/watch/ui/SendHelpers.kt b/wear/src/main/java/com/bitchat/watch/ui/SendHelpers.kt index 07820f7a..2b79c98f 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/SendHelpers.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/SendHelpers.kt @@ -8,6 +8,9 @@ import com.bitchat.android.services.AppStateStore import com.bitchat.watch.mesh.WearMeshService import java.io.File import java.util.Date +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch internal fun sendPublicMessage(mesh: WearMeshService, content: String) { mesh.sendMessage(content) @@ -22,23 +25,49 @@ internal fun sendPublicMessage(mesh: WearMeshService, content: String) { ) } +/** + * DM send with honest delivery state. MeshCore drops pre-handshake content (it only kicks + * off the Noise handshake), so when no session exists we must not echo "Sent": the echo + * stays "Sending" while a retry loop waits for the session and completes the send. + */ internal fun sendPrivateMessage( mesh: WearMeshService, peerID: String, recipientNickname: String, - content: String + content: String, + scope: CoroutineScope ) { - mesh.sendPrivateMessage(content, peerID, recipientNickname) + val established = mesh.hasEstablishedSession(peerID) + val messageID = java.util.UUID.randomUUID().toString() + if (established) { + mesh.sendPrivateMessageWithId(content, peerID, recipientNickname, messageID) + } else { + mesh.initiateNoiseHandshake(peerID) + scope.launch { + val deadline = System.currentTimeMillis() + 15_000 + while (System.currentTimeMillis() < deadline) { + if (mesh.hasEstablishedSession(peerID)) { + mesh.sendPrivateMessageWithId(content, peerID, recipientNickname, messageID) + AppStateStore.updatePrivateMessageStatus(messageID, DeliveryStatus.Sent) + return@launch + } + delay(400) + } + // Session never came up: the echo honestly stays "Sending" (AppStateStore + // refuses status downgrades, so it cannot be marked Failed from here). + } + } AppStateStore.addPrivateMessage( peerID, BitchatMessage( + id = messageID, sender = mesh.nickname, content = content, timestamp = Date(), isPrivate = true, recipientNickname = recipientNickname, senderPeerID = mesh.myPeerID, - deliveryStatus = DeliveryStatus.Sent + deliveryStatus = if (established) DeliveryStatus.Sent else DeliveryStatus.Sending ) ) }