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..71dd8113a 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,16 @@ 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)
+ }
+
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/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/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/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())
+ }
+}
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")))
+ }
+}