globe fixes

This commit is contained in:
callebtc 2026-07-30 23:09:53 +02:00
parent daee720738
commit 87184eddde
9 changed files with 212 additions and 242 deletions

View File

@ -15,9 +15,6 @@ import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.*
@ -37,8 +34,6 @@ import com.bitchat.android.geohash.Geohash
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
import com.bitchat.android.ui.globe.GlobeColors
import com.bitchat.android.ui.globe.GlobeRenderQuality
import com.bitchat.android.ui.globe.GlobeRenderQualityPreference
import com.bitchat.android.ui.globe.GlobeState
import com.bitchat.android.ui.globe.GlobeView
import com.bitchat.android.ui.globe.LandData
@ -95,18 +90,13 @@ class GeohashPickerActivity : OrientationAwareActivity() {
BitchatTheme {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val initialRenderQuality = remember(context) {
GlobeRenderQualityPreference.load(context)
}
var renderQuality by remember { mutableStateOf(initialRenderQuality) }
val globeState = remember {
GlobeState(
targetLat = targetLat,
targetLon = targetLon,
initialPrecision = initialPrecision,
startZoomedOut = true,
initialRenderQuality = initialRenderQuality
startZoomedOut = true
).apply {
introTarget = Triple(targetLat, targetLon, initialPrecision)
}
@ -193,62 +183,15 @@ class GeohashPickerActivity : OrientationAwareActivity() {
tonalElevation = 3.dp,
shadowElevation = 6.dp
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
Text(
text = stringResource(R.string.pan_zoom_instruction),
fontSize = 12.sp,
textAlign = TextAlign.Center,
fontFamily = BitchatFontFamily,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.padding(horizontal = 14.dp, vertical = 10.dp)
) {
Text(
text = stringResource(R.string.pan_zoom_instruction),
fontSize = 12.sp,
textAlign = TextAlign.Center,
fontFamily = BitchatFontFamily,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(Modifier.height(8.dp))
val qualities = GlobeRenderQuality.entries
SingleChoiceSegmentedButtonRow(
modifier = Modifier.fillMaxWidth()
) {
qualities.forEachIndexed { index, quality ->
val selected = renderQuality == quality
SegmentedButton(
selected = selected,
onClick = {
globeState.setRenderQuality(quality)
renderQuality = quality
GlobeRenderQualityPreference.save(context, quality)
},
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = qualities.size
),
icon = {},
label = {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
if (selected) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
modifier = Modifier
.align(Alignment.CenterStart)
.size(18.dp)
)
}
Text(
text = stringResource(quality.labelResource),
fontSize = 11.sp,
fontFamily = BitchatFontFamily
)
}
}
)
}
}
}
)
}
// Floating bottom controls
@ -347,13 +290,6 @@ class GeohashPickerActivity : OrientationAwareActivity() {
}
}
private val GlobeRenderQuality.labelResource: Int
get() = when (this) {
GlobeRenderQuality.FAST -> R.string.globe_render_quality_fast
GlobeRenderQuality.MEDIUM -> R.string.globe_render_quality_medium
GlobeRenderQuality.HIGH -> R.string.globe_render_quality_high
}
private fun levelForLength(length: Int): GeohashChannelLevel {
return when (length) {
in 0..2 -> GeohashChannelLevel.REGION

View File

@ -137,6 +137,18 @@ fun LocationChannelsSheet(
var customGeohash by remember { mutableStateOf("") }
var customError by remember { mutableStateOf<String?>(null) }
val teleportToGeohash: (String) -> Unit = { value ->
val channel = channelForManualGeohash(value)
if (channel != null) {
customError = null
locationManager.selectManual(channel)
onDismiss()
} else {
customGeohash = value.trim().lowercase().replace("#", "")
customError = context.getString(R.string.invalid_geohash)
}
}
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val coroutineScope = rememberCoroutineScope()
@ -158,8 +170,7 @@ fun LocationChannelsSheet(
if (result.resultCode == android.app.Activity.RESULT_OK) {
val gh = result.data?.getStringExtra(GeohashPickerActivity.EXTRA_RESULT_GEOHASH)
if (!gh.isNullOrBlank()) {
customGeohash = gh
customError = null
teleportToGeohash(gh)
}
}
}
@ -503,15 +514,7 @@ fun LocationChannelsSheet(
mapPickerLauncher.launch(intent)
},
onTeleport = {
val normalized = customGeohash.trim().lowercase().replace("#", "")
if (validateGeohash(normalized)) {
val level = levelForLength(normalized.length)
val channel = GeohashChannel(level = level, geohash = normalized)
locationManager.selectManual(channel)
onDismiss()
} else {
customError = context.getString(R.string.invalid_geohash)
}
teleportToGeohash(customGeohash)
}
)
}
@ -1173,6 +1176,15 @@ private fun validateGeohash(geohash: String): Boolean {
return geohash.all { it in allowed }
}
internal fun channelForManualGeohash(value: String): GeohashChannel? {
val normalized = value.trim().lowercase().replace("#", "")
if (!validateGeohash(normalized)) return null
return GeohashChannel(
level = levelForLength(normalized.length),
geohash = normalized
)
}
private fun levelForLength(length: Int): GeohashChannelLevel {
return when (length) {
in 0..2 -> GeohashChannelLevel.REGION

View File

@ -1,33 +0,0 @@
package com.bitchat.android.ui.globe
import android.content.Context
enum class GlobeRenderQuality {
FAST,
MEDIUM,
HIGH;
companion object {
fun fromStoredValue(value: String?): GlobeRenderQuality =
entries.firstOrNull { it.name == value } ?: MEDIUM
}
}
object GlobeRenderQualityPreference {
private const val PREFERENCES_NAME = "bitchat_settings"
private const val KEY_RENDER_QUALITY = "geohash_globe_render_quality"
fun load(context: Context): GlobeRenderQuality {
val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
return GlobeRenderQuality.fromStoredValue(
preferences.getString(KEY_RENDER_QUALITY, GlobeRenderQuality.MEDIUM.name)
)
}
fun save(context: Context, quality: GlobeRenderQuality) {
context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
.edit()
.putString(KEY_RENDER_QUALITY, quality.name)
.apply()
}
}

View File

@ -28,8 +28,7 @@ class GlobeState(
targetLat: Double,
targetLon: Double,
initialPrecision: Int,
startZoomedOut: Boolean,
initialRenderQuality: GlobeRenderQuality = GlobeRenderQuality.MEDIUM
startZoomedOut: Boolean
) {
var centerLat by mutableFloatStateOf(if (startZoomedOut) (targetLat * 0.4).toFloat() else targetLat.toFloat())
private set
@ -48,8 +47,6 @@ class GlobeState(
internal var baseRadiusPx by mutableFloatStateOf(0f)
internal var screenMinPx by mutableFloatStateOf(0f)
internal var renderQuality = initialRenderQuality
private set
private var scope: CoroutineScope? = null
private var animJob: Job? = null
@ -71,14 +68,6 @@ class GlobeState(
this.scope = scope
}
/**
* Rendering quality only affects moving frames. This is deliberately not snapshot state:
* changing it must not invalidate the expensive stationary globe beneath the selector.
*/
fun setRenderQuality(quality: GlobeRenderQuality) {
renderQuality = quality
}
fun setViewport(baseRadiusPx: Float, screenMinPx: Float) {
if (baseRadiusPx <= 0f || screenMinPx <= 0f) return
this.baseRadiusPx = baseRadiusPx

View File

@ -18,8 +18,11 @@ import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
@ -39,10 +42,7 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.sp
import com.bitchat.android.geohash.Geohash
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlin.math.ceil
import kotlin.math.min
@ -76,12 +76,15 @@ internal data class GlobeFrameDetail(
val showNeighborCells: Boolean
)
internal fun globeFrameDetail(
quality: GlobeRenderQuality,
isMoving: Boolean
): GlobeFrameDetail {
if (!isMoving || quality == GlobeRenderQuality.HIGH) {
return GlobeFrameDetail(
internal enum class GlobeMotionDetail {
FULL,
BALANCED,
FAST
}
internal fun globeFrameDetail(motionDetail: GlobeMotionDetail): GlobeFrameDetail =
when (motionDetail) {
GlobeMotionDetail.FULL -> GlobeFrameDetail(
graticuleStepDegrees = 4.0,
landPointStride = 1,
showBorders = true,
@ -90,18 +93,7 @@ internal fun globeFrameDetail(
showGeohashGrid = true,
showNeighborCells = true
)
}
return when (quality) {
GlobeRenderQuality.FAST -> GlobeFrameDetail(
graticuleStepDegrees = 10.0,
landPointStride = 2,
showBorders = false,
cityMaxRank = -1,
showCityLabels = false,
showGeohashGrid = false,
showNeighborCells = false
)
GlobeRenderQuality.MEDIUM -> GlobeFrameDetail(
GlobeMotionDetail.BALANCED -> GlobeFrameDetail(
graticuleStepDegrees = 8.0,
landPointStride = 2,
showBorders = true,
@ -110,7 +102,44 @@ internal fun globeFrameDetail(
showGeohashGrid = true,
showNeighborCells = false
)
GlobeRenderQuality.HIGH -> error("Handled above")
GlobeMotionDetail.FAST -> GlobeFrameDetail(
graticuleStepDegrees = 10.0,
landPointStride = 2,
showBorders = false,
cityMaxRank = -1,
showCityLabels = false,
showGeohashGrid = false,
showNeighborCells = false
)
}
/**
* Adjusts moving-frame detail from measured frame cadence. Hysteresis keeps the renderer
* from oscillating between levels when timings sit near a boundary.
*/
internal fun nextGlobeMotionDetail(
current: GlobeMotionDetail,
averageFrameMillis: Float
): GlobeMotionDetail {
if (!averageFrameMillis.isFinite()) return GlobeMotionDetail.FAST
return when (current) {
GlobeMotionDetail.FULL -> when {
averageFrameMillis > SEVERELY_SLOW_FRAME_MILLIS -> GlobeMotionDetail.FAST
averageFrameMillis > SLOW_FRAME_MILLIS -> GlobeMotionDetail.BALANCED
else -> GlobeMotionDetail.FULL
}
GlobeMotionDetail.BALANCED -> when {
averageFrameMillis > VERY_SLOW_FRAME_MILLIS -> GlobeMotionDetail.FAST
averageFrameMillis < SMOOTH_FRAME_MILLIS -> GlobeMotionDetail.FULL
else -> GlobeMotionDetail.BALANCED
}
GlobeMotionDetail.FAST -> {
if (averageFrameMillis < RECOVERING_FRAME_MILLIS) {
GlobeMotionDetail.BALANCED
} else {
GlobeMotionDetail.FAST
}
}
}
}
@ -169,19 +198,48 @@ fun GlobeView(
}
LaunchedEffect(state) {
snapshotFlow { state.selectedGeohash to state.isInMotion }
.distinctUntilChanged()
snapshotFlow { state.selectedGeohash }
.drop(1)
.collectLatest { (geohash, inMotion) ->
if (geohash.isNotEmpty() && !inMotion) {
delay(SETTLED_HAPTIC_DELAY_MS)
if (!state.isInMotion && state.selectedGeohash == geohash) {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
}
}
.collect {
@Suppress("DEPRECATION")
view.performHapticFeedback(
HapticFeedbackConstants.KEYBOARD_TAP,
HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING
)
}
}
var motionDetail by remember(state) { mutableStateOf(GlobeMotionDetail.BALANCED) }
LaunchedEffect(state, state.isInMotion) {
if (!state.isInMotion) {
motionDetail = GlobeMotionDetail.FULL
return@LaunchedEffect
}
// Balanced is a safe first frame; measured cadence then moves detail up or down.
motionDetail = GlobeMotionDetail.BALANCED
var previousFrameNanos = withFrameNanos { it }
var sampledFrameMillis = 0f
var sampledFrameCount = 0
while (state.isInMotion) {
val frameNanos = withFrameNanos { it }
val frameMillis = ((frameNanos - previousFrameNanos) / 1_000_000f)
.coerceIn(1f, MAX_SAMPLED_FRAME_MILLIS)
previousFrameNanos = frameNanos
sampledFrameMillis += frameMillis
sampledFrameCount++
if (sampledFrameCount >= FRAME_SAMPLE_COUNT) {
motionDetail = nextGlobeMotionDetail(
current = motionDetail,
averageFrameMillis = sampledFrameMillis / sampledFrameCount
)
sampledFrameMillis = 0f
sampledFrameCount = 0
}
}
}
val labelTextSize = with(density) { 12.5.sp.toPx() }
val labelTextSizeSmall = with(density) { 10.sp.toPx() }
@ -325,7 +383,9 @@ fun GlobeView(
val clip = ClipRect(-size.width, -size.height, size.width * 2f, size.height * 2f)
val frameDetail = globeFrameDetail(state.renderQuality, state.isInMotion)
val frameDetail = globeFrameDetail(
if (state.isInMotion) motionDetail else GlobeMotionDetail.FULL
)
// Graticule
drawGraticule(
@ -433,7 +493,13 @@ fun GlobeView(
}
}
private const val SETTLED_HAPTIC_DELAY_MS = 80L
private const val FRAME_SAMPLE_COUNT = 8
private const val MAX_SAMPLED_FRAME_MILLIS = 50f
private const val SMOOTH_FRAME_MILLIS = 18.5f
private const val SLOW_FRAME_MILLIS = 22f
private const val RECOVERING_FRAME_MILLIS = 22f
private const val VERY_SLOW_FRAME_MILLIS = 29f
private const val SEVERELY_SLOW_FRAME_MILLIS = 34f
private fun DrawScope.drawGraticule(
cx: Float,

View File

@ -540,9 +540,6 @@
<string name="nobody_around">Nobody around…</string>
<string name="you_suffix"> (you)</string>
<string name="pan_zoom_instruction">Drag to spin · Pinch to zoom · Tap to focus</string>
<string name="globe_render_quality_fast">Fast</string>
<string name="globe_render_quality_medium">Medium</string>
<string name="globe_render_quality_high">High</string>
<string name="select">Select</string>
<string name="type_a_message_placeholder">Type a message…</string>
<string name="mention_suggestion_at">@%1$s</string>

View File

@ -42,6 +42,22 @@ class LocationChannelsSheetTest {
)
}
@Test
fun `globe result resolves to a manual teleport channel`() {
assertEquals(
GeohashChannel(
level = GeohashChannelLevel.CITY,
geohash = "u33dc"
),
channelForManualGeohash(" #U33DC ")
)
}
@Test
fun `invalid globe result cannot create a teleport channel`() {
assertNull(channelForManualGeohash("not-a-geohash"))
}
private fun channel(geohash: String) = GeohashChannel(
level = GeohashChannelLevel.CITY,
geohash = geohash

View File

@ -0,0 +1,65 @@
package com.bitchat.android.ui.globe
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class GlobeInteractionPolicyTest {
@Test
fun fullDetail_preservesAllGlobeFeatures() {
val detail = globeFrameDetail(GlobeMotionDetail.FULL)
assertEquals(1, detail.landPointStride)
assertTrue(detail.showBorders)
assertNull(detail.cityMaxRank)
assertTrue(detail.showCityLabels)
assertTrue(detail.showGeohashGrid)
assertTrue(detail.showNeighborCells)
}
@Test
fun balancedDetail_preservesOrientationAndSelection() {
val detail = globeFrameDetail(GlobeMotionDetail.BALANCED)
assertEquals(2, detail.landPointStride)
assertTrue(detail.showBorders)
assertEquals(1, detail.cityMaxRank)
assertFalse(detail.showCityLabels)
assertTrue(detail.showGeohashGrid)
assertFalse(detail.showNeighborCells)
}
@Test
fun fastDetail_usesMinimumMovingFrameWork() {
val detail = globeFrameDetail(GlobeMotionDetail.FAST)
assertEquals(2, detail.landPointStride)
assertFalse(detail.showBorders)
assertEquals(-1, detail.cityMaxRank)
assertFalse(detail.showCityLabels)
assertFalse(detail.showGeohashGrid)
}
@Test
fun adaptiveDetail_degradesOnSlowFramesAndRecoversWithHysteresis() {
assertEquals(
GlobeMotionDetail.BALANCED,
nextGlobeMotionDetail(GlobeMotionDetail.FULL, averageFrameMillis = 24f)
)
assertEquals(
GlobeMotionDetail.FAST,
nextGlobeMotionDetail(GlobeMotionDetail.BALANCED, averageFrameMillis = 32f)
)
assertEquals(
GlobeMotionDetail.BALANCED,
nextGlobeMotionDetail(GlobeMotionDetail.FAST, averageFrameMillis = 17f)
)
assertEquals(
GlobeMotionDetail.FULL,
nextGlobeMotionDetail(GlobeMotionDetail.BALANCED, averageFrameMillis = 17f)
)
}
}

View File

@ -1,78 +0,0 @@
package com.bitchat.android.ui.globe
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class GlobeRenderQualityTest {
@Test
fun invalidStoredValue_defaultsToMedium() {
assertEquals(GlobeRenderQuality.MEDIUM, GlobeRenderQuality.fromStoredValue(null))
assertEquals(GlobeRenderQuality.MEDIUM, GlobeRenderQuality.fromStoredValue("UNKNOWN"))
}
@Test
fun globeState_usesAndUpdatesRenderQualityWithoutReplacingState() {
val state = GlobeState(
targetLat = 0.0,
targetLon = 0.0,
initialPrecision = 2,
startZoomedOut = false,
initialRenderQuality = GlobeRenderQuality.FAST
)
assertEquals(GlobeRenderQuality.FAST, state.renderQuality)
state.setRenderQuality(GlobeRenderQuality.HIGH)
assertEquals(GlobeRenderQuality.HIGH, state.renderQuality)
}
@Test
fun stationaryFrame_alwaysUsesFullDetail() {
GlobeRenderQuality.entries.forEach { quality ->
val detail = globeFrameDetail(quality, isMoving = false)
assertEquals(1, detail.landPointStride)
assertTrue(detail.showBorders)
assertNull(detail.cityMaxRank)
assertTrue(detail.showCityLabels)
assertTrue(detail.showGeohashGrid)
assertTrue(detail.showNeighborCells)
}
}
@Test
fun movingFastFrame_usesMinimumDetail() {
val detail = globeFrameDetail(GlobeRenderQuality.FAST, isMoving = true)
assertEquals(2, detail.landPointStride)
assertFalse(detail.showBorders)
assertEquals(-1, detail.cityMaxRank)
assertFalse(detail.showGeohashGrid)
}
@Test
fun movingMediumFrame_preservesOrientationAndSelection() {
val detail = globeFrameDetail(GlobeRenderQuality.MEDIUM, isMoving = true)
assertEquals(2, detail.landPointStride)
assertTrue(detail.showBorders)
assertEquals(1, detail.cityMaxRank)
assertFalse(detail.showCityLabels)
assertTrue(detail.showGeohashGrid)
assertFalse(detail.showNeighborCells)
}
@Test
fun movingHighFrame_usesFullDetail() {
val detail = globeFrameDetail(GlobeRenderQuality.HIGH, isMoving = true)
assertEquals(1, detail.landPointStride)
assertTrue(detail.showBorders)
assertNull(detail.cityMaxRank)
assertTrue(detail.showCityLabels)
assertTrue(detail.showNeighborCells)
}
}