From b3e93d537e08f5d5ac5c4febd882bd0a755f9898 Mon Sep 17 00:00:00 2001 From: petitbonney Date: Fri, 6 Nov 2026 11:00:11 +0100 Subject: [PATCH 1/2] Open location channels from bitchat://geohash links Match the iOS client, which already opens these links and includes them in channel invites. Links are validated the same way: lowercase, 2 to 12 characters, geohash base32 only. --- app/src/main/AndroidManifest.xml | 6 ++ .../java/com/bitchat/android/MainActivity.kt | 11 ++++ .../android/ui/LocationChannelsSheet.kt | 2 +- .../android/ui/MessageInteractionUtils.kt | 9 +++ .../android/ui/GeohashLinkRobolectricTest.kt | 66 +++++++++++++++++++ 5 files changed, 93 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/com/bitchat/android/ui/GeohashLinkRobolectricTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1f9a8c49e..764dfd454 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -127,6 +127,12 @@ + + + + + + diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 6f80052a3..8d6ae496c 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -42,6 +42,7 @@ import com.bitchat.android.onboarding.PermissionManager import com.bitchat.android.ui.ChatScreen import com.bitchat.android.ui.ChatViewModel import com.bitchat.android.ui.OrientationAwareActivity +import com.bitchat.android.ui.channelForGeohashLink import com.bitchat.android.ui.theme.BitchatTheme import com.bitchat.android.wifiaware.WifiAwareController import com.bitchat.android.nostr.PoWPreferenceManager @@ -705,6 +706,7 @@ class MainActivity : OrientationAwareActivity() { // Handle any notification intent handleNotificationIntent(intent) handleVerificationIntent(intent) + handleGeohashLinkIntent(intent) // Small delay to ensure mesh service is fully initialized delay(500) @@ -734,6 +736,7 @@ class MainActivity : OrientationAwareActivity() { if (mainViewModel.onboardingState.value == OnboardingState.COMPLETE) { handleNotificationIntent(intent) handleVerificationIntent(intent) + handleGeohashLinkIntent(intent) } } @@ -848,6 +851,14 @@ class MainActivity : OrientationAwareActivity() { } } + private fun handleGeohashLinkIntent(intent: Intent) { + val channel = intent.data?.let(::channelForGeohashLink) ?: return + + chatViewModel.selectLocationChannel(com.bitchat.android.geohash.ChannelID.Location(channel)) + chatViewModel.setCurrentGeohash(channel.geohash) + chatViewModel.clearNotificationsForGeohash(channel.geohash) + } + private fun handleVerificationIntent(intent: Intent) { val uri = intent.data ?: return if (uri.scheme != "bitchat" || uri.host != "verify") return diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt index 2b4d6514f..9b66bc2a2 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -1170,7 +1170,7 @@ internal fun selectedLocationChannelOutsideNearby( } } -private fun validateGeohash(geohash: String): Boolean { +internal fun validateGeohash(geohash: String): Boolean { if (geohash.isEmpty() || geohash.length > 12) return false val allowed = "0123456789bcdefghjkmnpqrstuvwxyz".toSet() return geohash.all { it in allowed } diff --git a/app/src/main/java/com/bitchat/android/ui/MessageInteractionUtils.kt b/app/src/main/java/com/bitchat/android/ui/MessageInteractionUtils.kt index 2caff0fee..ab4abb276 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageInteractionUtils.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageInteractionUtils.kt @@ -2,6 +2,7 @@ package com.bitchat.android.ui import android.content.Context import android.content.Intent +import android.net.Uri import androidx.core.net.toUri import com.bitchat.android.geohash.GeohashChannel import com.bitchat.android.geohash.GeohashChannelLevel @@ -44,6 +45,14 @@ internal fun channelForGeohash(geohash: String): GeohashChannel { return GeohashChannel(level, geohash.lowercase()) } +/** Channel named by a `bitchat://geohash/` link, or null if the link is not valid. */ +internal fun channelForGeohashLink(uri: Uri): GeohashChannel? { + if (uri.scheme != "bitchat" || uri.host != "geohash") return null + val geohash = uri.path.orEmpty().trim('/').lowercase() + if (geohash.length < 2 || !validateGeohash(geohash)) return null + return channelForManualGeohash(geohash) +} + internal fun navigateToGeohash(context: Context, geohash: String): Boolean = runCatching { val locationManager = LocationChannelManager.getInstance(context) diff --git a/app/src/test/java/com/bitchat/android/ui/GeohashLinkRobolectricTest.kt b/app/src/test/java/com/bitchat/android/ui/GeohashLinkRobolectricTest.kt new file mode 100644 index 000000000..4a7134e86 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/GeohashLinkRobolectricTest.kt @@ -0,0 +1,66 @@ +package com.bitchat.android.ui + +import android.net.Uri +import com.bitchat.android.geohash.GeohashChannelLevel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class GeohashLinkRobolectricTest { + @Test + fun `geohash link selects the channel it names`() { + val channel = channelForGeohashLink(Uri.parse("bitchat://geohash/9q8yyk8")) + + assertEquals("9q8yyk8", channel?.geohash) + assertEquals(GeohashChannelLevel.BLOCK, channel?.level) + } + + @Test + fun `geohash link level follows geohash length`() { + val expectedLevels = mapOf( + "9q" to GeohashChannelLevel.REGION, + "9q8" to GeohashChannelLevel.PROVINCE, + "9q8y" to GeohashChannelLevel.PROVINCE, + "9q8yy" to GeohashChannelLevel.CITY, + "9q8yyk" to GeohashChannelLevel.NEIGHBORHOOD, + "9q8yyk8" to GeohashChannelLevel.BLOCK, + "9q8yyk8y" to GeohashChannelLevel.BUILDING, + "9q8yyk8ytpxr" to GeohashChannelLevel.BUILDING, + ) + + expectedLevels.forEach { (geohash, level) -> + assertEquals(geohash, level, channelForGeohashLink(Uri.parse("bitchat://geohash/$geohash"))?.level) + } + } + + @Test + fun `geohash link is normalized like the iOS client`() { + assertEquals("9q8yy", channelForGeohashLink(Uri.parse("bitchat://geohash/9Q8YY"))?.geohash) + assertEquals("9q8yy", channelForGeohashLink(Uri.parse("bitchat://geohash/9q8yy/"))?.geohash) + } + + @Test + fun `malformed geohash links select nothing`() { + val rejected = listOf( + "bitchat://geohash", + "bitchat://geohash/", + "bitchat://geohash/9", + "bitchat://geohash/9q8yyk8ytpxrz", + "bitchat://geohash/9q8ayy", + "bitchat://geohash/9q/8y", + "bitchat://geohash/9q%238y", + "bitchat://geohash/%209q8y", + ) + + rejected.forEach { link -> assertNull(link, channelForGeohashLink(Uri.parse(link))) } + } + + @Test + fun `links for other destinations select nothing`() { + assertNull(channelForGeohashLink(Uri.parse("bitchat://verify/9q8yy"))) + assertNull(channelForGeohashLink(Uri.parse("https://geohash/9q8yy"))) + } +} From c464d60bb156001fbe9c3f8f6c2b1b6f469c263d Mon Sep 17 00:00:00 2001 From: petitbonney Date: Fri, 6 Nov 2026 11:58:11 +0100 Subject: [PATCH 2/2] Leave the joined channel when opening a geohash link A joined channel stayed on screen while messages went to the linked location channel. Clear only the joined channel, because switchToChannel(null) also resets the location and the open private chat. --- .../java/com/bitchat/android/MainActivity.kt | 2 + .../com/bitchat/android/ui/ChannelManager.kt | 5 ++ .../com/bitchat/android/ui/ChatViewModel.kt | 4 ++ .../com/bitchat/android/ui/ChannelExitTest.kt | 54 +++++++++++++++++++ 4 files changed, 65 insertions(+) create mode 100644 app/src/test/java/com/bitchat/android/ui/ChannelExitTest.kt diff --git a/app/src/main/java/com/bitchat/android/MainActivity.kt b/app/src/main/java/com/bitchat/android/MainActivity.kt index 8d6ae496c..71dd8113a 100644 --- a/app/src/main/java/com/bitchat/android/MainActivity.kt +++ b/app/src/main/java/com/bitchat/android/MainActivity.kt @@ -854,6 +854,8 @@ class MainActivity : OrientationAwareActivity() { private fun handleGeohashLinkIntent(intent: Intent) { val channel = intent.data?.let(::channelForGeohashLink) ?: return + // A joined channel stays on screen otherwise, while messages go to the location channel. + chatViewModel.exitCurrentChannel() chatViewModel.selectLocationChannel(com.bitchat.android.geohash.ChannelID.Location(channel)) chatViewModel.setCurrentGeohash(channel.geohash) chatViewModel.clearNotificationsForGeohash(channel.geohash) diff --git a/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt b/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt index 43bc2085c..7dfd7612c 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChannelManager.kt @@ -108,6 +108,11 @@ class ChannelManager( saveChannelData() } + /** Leaves the joined channel's timeline without changing the location or private chat. */ + fun exitCurrentChannel() { + state.setCurrentChannel(null) + } + fun switchToChannel(channel: String?) { state.setCurrentChannel(channel) state.setSelectedPrivateChatPeer(null) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index b7f38eb07..d66d48360 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -684,6 +684,10 @@ class ChatViewModel( channelManager.switchToChannel(channel) } + fun exitCurrentChannel() { + channelManager.exitCurrentChannel() + } + fun leaveChannel(channel: String) { channelManager.leaveChannel(channel) mesh.sendMessage("left $channel", emptyList(), null) diff --git a/app/src/test/java/com/bitchat/android/ui/ChannelExitTest.kt b/app/src/test/java/com/bitchat/android/ui/ChannelExitTest.kt new file mode 100644 index 000000000..4d3fdd396 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/ChannelExitTest.kt @@ -0,0 +1,54 @@ +package com.bitchat.android.ui + +import com.bitchat.android.geohash.ChannelID +import com.bitchat.android.geohash.GeohashChannel +import com.bitchat.android.geohash.GeohashChannelLevel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.mockito.kotlin.mock + +class ChannelExitTest { + @OptIn(ExperimentalCoroutinesApi::class) + private val testScope = TestScope(UnconfinedTestDispatcher()) + private val state = ChatState(scope = testScope) + private val channelManager = ChannelManager( + state = state, + messageManager = MessageManager(state = state), + dataManager = mock(), + coroutineScope = testScope, + ) + + @Test + fun `exiting a joined channel clears it from the timeline`() { + state.setCurrentChannel("#test") + + channelManager.exitCurrentChannel() + + assertNull(state.getCurrentChannelValue()) + } + + @Test + fun `exiting a joined channel keeps the selected location`() { + val location = ChannelID.Location(GeohashChannel(GeohashChannelLevel.CITY, "9q8yy")) + state.setCurrentChannel("#test") + state.setSelectedLocationChannel(location) + + channelManager.exitCurrentChannel() + + assertEquals(location, state.selectedLocationChannel.value) + } + + @Test + fun `exiting a joined channel keeps the open private chat`() { + state.setCurrentChannel("#test") + state.setSelectedPrivateChatPeer("peer-a") + + channelManager.exitCurrentChannel() + + assertEquals("peer-a", state.getSelectedPrivateChatPeerValue()) + } +}