diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt index 6c20e39b..ce450ded 100644 --- a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt +++ b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt @@ -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 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 5cf204b8..2b4d6514 100644 --- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt @@ -137,6 +137,18 @@ fun LocationChannelsSheet( var customGeohash by remember { mutableStateOf("") } var customError by remember { mutableStateOf(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 diff --git a/app/src/main/java/com/bitchat/android/ui/globe/GlobeRenderQuality.kt b/app/src/main/java/com/bitchat/android/ui/globe/GlobeRenderQuality.kt deleted file mode 100644 index 7a2ba578..00000000 --- a/app/src/main/java/com/bitchat/android/ui/globe/GlobeRenderQuality.kt +++ /dev/null @@ -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() - } -} diff --git a/app/src/main/java/com/bitchat/android/ui/globe/GlobeState.kt b/app/src/main/java/com/bitchat/android/ui/globe/GlobeState.kt index 302434d8..6c62d0ef 100644 --- a/app/src/main/java/com/bitchat/android/ui/globe/GlobeState.kt +++ b/app/src/main/java/com/bitchat/android/ui/globe/GlobeState.kt @@ -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 diff --git a/app/src/main/java/com/bitchat/android/ui/globe/GlobeView.kt b/app/src/main/java/com/bitchat/android/ui/globe/GlobeView.kt index 0a09ae0a..7066cf92 100644 --- a/app/src/main/java/com/bitchat/android/ui/globe/GlobeView.kt +++ b/app/src/main/java/com/bitchat/android/ui/globe/GlobeView.kt @@ -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, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index aa240bc4..f705c5ad 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -540,9 +540,6 @@ Nobody around… (you) Drag to spin · Pinch to zoom · Tap to focus - Fast - Medium - High Select Type a message… @%1$s diff --git a/app/src/test/java/com/bitchat/android/ui/LocationChannelsSheetTest.kt b/app/src/test/java/com/bitchat/android/ui/LocationChannelsSheetTest.kt index e53665ca..b5d556e3 100644 --- a/app/src/test/java/com/bitchat/android/ui/LocationChannelsSheetTest.kt +++ b/app/src/test/java/com/bitchat/android/ui/LocationChannelsSheetTest.kt @@ -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 diff --git a/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeInteractionPolicyTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeInteractionPolicyTest.kt new file mode 100644 index 00000000..c4e59cd3 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeInteractionPolicyTest.kt @@ -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) + ) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeRenderQualityTest.kt b/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeRenderQualityTest.kt deleted file mode 100644 index 1581e38f..00000000 --- a/app/src/test/kotlin/com/bitchat/android/ui/globe/GlobeRenderQualityTest.kt +++ /dev/null @@ -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) - } -}