Merge c464d60bb156001fbe9c3f8f6c2b1b6f469c263d into 1f24973b06ee77492742ba522002e028f38f4381

This commit is contained in:
ΔLΣX 🌸 2026-11-08 06:20:45 +00:00 committed by GitHub
commit 62f63d031b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 158 additions and 1 deletions

View File

@ -127,6 +127,12 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="bitchat" android:host="verify" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="bitchat" android:host="geohash" />
</intent-filter>
</activity>
<!-- Persistent foreground service to run the mesh in background -->

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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 }

View File

@ -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/<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)

View File

@ -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())
}
}

View File

@ -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")))
}
}