Merge 033e224bc72d38823aecd1b23c31501771d9e54f into 094657efa0aabbb6f71c9050149d1d01aee96400

This commit is contained in:
callebtc 2026-08-03 17:57:18 +03:00 committed by GitHub
commit 862f2d035d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 2956 additions and 569 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -5,6 +5,7 @@ import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
@ -13,16 +14,19 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
@ -34,20 +38,30 @@ 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.GlobeMapPresentationState
import com.bitchat.android.ui.globe.GlobeMapUiState
import com.bitchat.android.ui.globe.GlobeState
import com.bitchat.android.ui.globe.GlobeTileRequest
import com.bitchat.android.ui.globe.GlobeTileSelector
import com.bitchat.android.ui.globe.GlobeViewport
import com.bitchat.android.ui.globe.GlobeView
import com.bitchat.android.ui.globe.LandData
import com.bitchat.android.ui.globe.StreamedGlobeRepository
import com.bitchat.android.ui.theme.BASE_FONT_SIZE
import com.bitchat.android.ui.theme.BitchatFontFamily
import com.bitchat.android.ui.theme.BitchatTheme
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
class GeohashPickerActivity : OrientationAwareActivity() {
companion object {
const val EXTRA_INITIAL_GEOHASH = "initial_geohash"
const val EXTRA_RESULT_GEOHASH = "result_geohash"
private const val MAP_REQUEST_SETTLE_MS = 100L
private const val OPENSTREETMAP_COPYRIGHT_URL =
"https://www.openstreetmap.org/copyright"
}
override fun onCreate(savedInstanceState: Bundle?) {
@ -89,6 +103,7 @@ class GeohashPickerActivity : OrientationAwareActivity() {
setContent {
BitchatTheme {
val context = LocalContext.current
val uriHandler = LocalUriHandler.current
val scope = rememberCoroutineScope()
val globeState = remember {
@ -104,15 +119,83 @@ class GeohashPickerActivity : OrientationAwareActivity() {
LaunchedEffect(globeState) { globeState.attach(scope) }
val land by produceState<List<LandData.Ring>?>(initialValue = null) {
value = withContext(Dispatchers.IO) { LandData.load(context) }
val mapRepository = remember(context.applicationContext) {
StreamedGlobeRepository(context.applicationContext)
}
val borders by produceState<List<LandData.Ring>>(initialValue = emptyList()) {
value = withContext(Dispatchers.IO) { LandData.loadBorders(context) }
DisposableEffect(mapRepository) {
onDispose { mapRepository.close() }
}
val cities by produceState<List<LandData.City>>(initialValue = emptyList()) {
value = withContext(Dispatchers.IO) { LandData.loadCities(context) }
var mapPresentationState by remember {
mutableStateOf(GlobeMapPresentationState())
}
var mapRetryNonce by remember { mutableIntStateOf(0) }
LaunchedEffect(globeState, mapRepository, mapRetryNonce) {
if (mapPresentationState.uiState.data.oceanPolygons.isEmpty()) {
mapPresentationState = mapPresentationState.startLoading()
try {
val overview = mapRepository.load(
GlobeTileRequest(detailZoom = 0, detailTiles = emptySet())
)
mapPresentationState = mapPresentationState.copy(
uiState = GlobeMapUiState(
data = overview.data,
isLoading = false,
hasError = false
)
)
} catch (cancellation: CancellationException) {
throw cancellation
} catch (_: Exception) {
mapPresentationState = mapPresentationState.showError()
return@LaunchedEffect
}
}
snapshotFlow {
if (globeState.isInMotion) {
null
} else {
GlobeTileSelector.select(
GlobeViewport(
centerLat = globeState.centerLat.toDouble(),
centerLon = globeState.centerLon.toDouble(),
globeRadiusPx = globeState.globeRadiusPx,
widthPx = globeState.viewportWidthPx,
heightPx = globeState.viewportHeightPx
)
)
}
}
.distinctUntilChanged()
.collectLatest { request ->
// A null request is emitted as soon as motion begins. Keeping it
// in the flow (instead of filtering it) immediately cancels any
// obsolete HTTP/decode batch.
if (request == null) {
mapPresentationState =
mapPresentationState.cancelLoading()
return@collectLatest
}
// Let fast drag/zoom changes settle before starting another batch.
delay(MAP_REQUEST_SETTLE_MS)
mapPresentationState = mapPresentationState.startLoading()
try {
val result = mapRepository.load(request) { partial ->
mapPresentationState =
mapPresentationState.showPartial(partial)
}
mapPresentationState =
mapPresentationState.showComplete(result)
} catch (cancellation: CancellationException) {
throw cancellation
} catch (_: Exception) {
mapPresentationState = mapPresentationState.showError()
}
}
}
val mapUiState = mapPresentationState.uiState
val colorScheme = MaterialTheme.colorScheme
val dark = colorScheme.background.luminance() < 0.5f
@ -158,18 +241,14 @@ class GeohashPickerActivity : OrientationAwareActivity() {
.fillMaxSize()
.background(colorScheme.background)
) {
land?.let { rings ->
GlobeView(
state = globeState,
colors = globeColors,
land = rings,
borders = borders,
cities = cities,
labelTypeface = labelTypeface,
labelTypefaceBold = labelTypefaceBold,
modifier = Modifier.fillMaxSize()
)
}
GlobeView(
state = globeState,
colors = globeColors,
mapData = mapUiState.data,
labelTypeface = labelTypeface,
labelTypefaceBold = labelTypefaceBold,
modifier = Modifier.fillMaxSize()
)
// Floating info pill
Surface(
@ -194,6 +273,74 @@ class GeohashPickerActivity : OrientationAwareActivity() {
)
}
if (
(mapUiState.isLoading && !mapUiState.data.hasGeography) ||
mapUiState.hasError
) {
Surface(
modifier = Modifier
.align(Alignment.TopCenter)
.statusBarsPadding()
.padding(top = 146.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
shape = RoundedCornerShape(10.dp),
tonalElevation = 2.dp
) {
Row(
modifier = Modifier.padding(
start = 10.dp,
end = if (mapUiState.hasError) 4.dp else 10.dp,
top = 6.dp,
bottom = 6.dp
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (mapUiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 1.5.dp
)
}
Text(
text = stringResource(
if (mapUiState.hasError) {
R.string.globe_map_load_error
} else {
R.string.globe_map_loading
}
),
fontSize = 10.sp,
fontFamily = BitchatFontFamily,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (mapUiState.hasError) {
TextButton(onClick = { mapRetryNonce++ }) {
Text(
text = stringResource(R.string.retry),
fontSize = 10.sp,
fontFamily = BitchatFontFamily
)
}
}
}
}
}
Text(
text = stringResource(R.string.openstreetmap_attribution),
modifier = Modifier
.align(Alignment.BottomStart)
.navigationBarsPadding()
.clickable {
uriHandler.openUri(OPENSTREETMAP_COPYRIGHT_URL)
}
.padding(horizontal = 10.dp, vertical = 6.dp),
fontSize = 9.sp,
fontFamily = BitchatFontFamily,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f)
)
// Floating bottom controls
Column(
modifier = Modifier

View File

@ -0,0 +1,255 @@
package com.bitchat.android.ui.globe
import kotlin.math.acos
import kotlin.math.cos
import kotlin.math.sin
import kotlin.math.sqrt
/**
* Render-ready geographic data decoded from streamed OpenStreetMap vector tiles.
*
* Coordinates are retained as latitude/longitude so the existing orthographic globe
* projection remains the sole source of screen geometry and visual styling.
*/
data class GlobeMapData(
val oceanPolygons: List<OceanPolygon> = emptyList(),
val borders: List<BorderLine> = emptyList(),
val boundaryLabels: List<MapLabel> = emptyList(),
val placeLabels: List<MapLabel> = emptyList(),
val detailZoom: Int = 0
) {
val hasGeography: Boolean
get() = oceanPolygons.isNotEmpty() || borders.isNotEmpty() ||
boundaryLabels.isNotEmpty() || placeLabels.isNotEmpty()
companion object {
val EMPTY = GlobeMapData()
}
}
data class OceanPolygon(val rings: List<GeoRing>)
data class BorderLine(
val ring: GeoRing,
val maritime: Boolean,
val disputed: Boolean,
val adminLevel: Int?
)
enum class MapLabelKind {
COUNTRY,
STATE,
CAPITAL,
CITY,
TOWN,
VILLAGE,
OTHER
}
data class MapLabel(
val name: String,
val lat: Float,
val lon: Float,
val kind: MapLabelKind,
val importance: Long,
val projectionTerms: FloatArray = prepareProjectionTerms(
coords = floatArrayOf(lat, lon),
size = 1
)
) {
val rank: Int
get() = when {
kind == MapLabelKind.COUNTRY -> 0
kind == MapLabelKind.STATE -> 3
kind == MapLabelKind.CAPITAL -> 0
importance >= 5_000_000L -> 0
importance >= 1_000_000L -> 1
importance >= 500_000L -> 2
importance >= 100_000L -> 3
kind == MapLabelKind.CITY -> 4
kind == MapLabelKind.TOWN -> 6
kind == MapLabelKind.VILLAGE -> 8
else -> 10
}
val isCapital: Boolean get() = kind == MapLabelKind.CAPITAL
val isMegacity: Boolean get() = importance >= 5_000_000L
}
data class GeoRing(
val coords: FloatArray,
val size: Int,
/**
* Polygon role from MVT winding order. `true` is a water exterior and `false`
* is a land hole inside that water feature; lines and synthetic geometry use null.
*/
val isMvtExterior: Boolean? = null,
/**
* Per-point sin(latitude), cos(latitude), sin(longitude), cos(longitude).
* Preparing this once removes nearly all trigonometry from animated frames.
*/
val projectionTerms: FloatArray = prepareProjectionTerms(coords, size)
) {
/**
* Conservative spherical cap used to reject rings that cannot touch the viewport.
* The streamed z0 ocean tile contains thousands of tiny island rings, most of which
* are far outside a zoomed view.
*/
internal val sphericalBounds: SphericalRingBounds =
prepareSphericalBounds(projectionTerms, size)
}
internal data class SphericalRingBounds(
val centerX: Float,
val centerY: Float,
val centerZ: Float,
val angularRadius: Float
) {
fun mayIntersectView(
viewCenterX: Float,
viewCenterY: Float,
viewCenterZ: Float,
viewAngularRadius: Float
): Boolean {
// Large/degenerate rings can enclose the view even when their edge vertices are
// distant, so only cull compact rings.
if (angularRadius >= MAX_CULLABLE_RING_RADIUS_RADIANS) return true
val maximumDistance =
angularRadius + viewAngularRadius + RING_CULL_MARGIN_RADIANS
if (maximumDistance >= Math.PI.toFloat()) return true
val dot =
centerX * viewCenterX +
centerY * viewCenterY +
centerZ * viewCenterZ
return dot >= cos(maximumDistance)
}
}
internal fun prepareProjectionTerms(coords: FloatArray, size: Int): FloatArray {
val result = FloatArray(size * 4)
for (index in 0 until size) {
val latRadians = Math.toRadians(coords[index * 2].toDouble())
val lonRadians = Math.toRadians(coords[index * 2 + 1].toDouble())
result[index * 4] = sin(latRadians).toFloat()
result[index * 4 + 1] = cos(latRadians).toFloat()
result[index * 4 + 2] = sin(lonRadians).toFloat()
result[index * 4 + 3] = cos(lonRadians).toFloat()
}
return result
}
private fun prepareSphericalBounds(
projectionTerms: FloatArray,
size: Int
): SphericalRingBounds {
if (size <= 0) {
return SphericalRingBounds(0f, 0f, 1f, Math.PI.toFloat())
}
var sumX = 0.0
var sumY = 0.0
var sumZ = 0.0
for (index in 0 until size) {
val offset = index * 4
val sinLat = projectionTerms[offset]
val cosLat = projectionTerms[offset + 1]
val sinLon = projectionTerms[offset + 2]
val cosLon = projectionTerms[offset + 3]
sumX += cosLat * cosLon
sumY += cosLat * sinLon
sumZ += sinLat
}
val length = sqrt(sumX * sumX + sumY * sumY + sumZ * sumZ)
if (length < 1e-6) {
return SphericalRingBounds(0f, 0f, 1f, Math.PI.toFloat())
}
val centerX = (sumX / length).toFloat()
val centerY = (sumY / length).toFloat()
val centerZ = (sumZ / length).toFloat()
var angularRadius = 0f
for (index in 0 until size) {
val offset = index * 4
val pointX = projectionTerms[offset + 1] * projectionTerms[offset + 3]
val pointY = projectionTerms[offset + 1] * projectionTerms[offset + 2]
val pointZ = projectionTerms[offset]
val dot = (
centerX * pointX +
centerY * pointY +
centerZ * pointZ
).coerceIn(-1f, 1f)
angularRadius = maxOf(angularRadius, acos(dot))
}
return SphericalRingBounds(centerX, centerY, centerZ, angularRadius)
}
private const val MAX_CULLABLE_RING_RADIUS_RADIANS = 1.2f
private const val RING_CULL_MARGIN_RADIANS = 0.035f
data class GlobeMapLoadResult(
val data: GlobeMapData,
val requestedTileCount: Int,
val failedTileCount: Int
)
data class GlobeMapUiState(
val data: GlobeMapData = GlobeMapData.EMPTY,
val isLoading: Boolean = false,
val hasError: Boolean = false
)
/**
* Keeps progressive tile updates from replacing a complete map with a priority-only
* subset. The first request may still render progressively; after one full successful
* detail request, cancellation and tile failures retain that complete snapshot.
*/
internal data class GlobeMapPresentationState(
val uiState: GlobeMapUiState = GlobeMapUiState(),
val lastCompleteData: GlobeMapData? = null
) {
fun startLoading(): GlobeMapPresentationState = copy(
uiState = uiState.copy(isLoading = true, hasError = false)
)
fun showPartial(result: GlobeMapLoadResult): GlobeMapPresentationState = copy(
uiState = GlobeMapUiState(
data = lastCompleteData ?: result.data,
isLoading = true,
hasError = false
)
)
fun showComplete(result: GlobeMapLoadResult): GlobeMapPresentationState {
val completedWithoutFailures = result.failedTileCount == 0
val displayedData = if (completedWithoutFailures) {
result.data
} else {
lastCompleteData ?: result.data
}
return copy(
uiState = GlobeMapUiState(
data = displayedData,
isLoading = false,
hasError = !completedWithoutFailures
),
lastCompleteData = if (completedWithoutFailures) {
result.data
} else {
lastCompleteData
}
)
}
fun cancelLoading(): GlobeMapPresentationState = copy(
uiState = uiState.copy(
data = lastCompleteData ?: uiState.data,
isLoading = false
)
)
fun showError(): GlobeMapPresentationState = copy(
uiState = uiState.copy(
data = lastCompleteData ?: uiState.data,
isLoading = false,
hasError = true
)
)
}

View File

@ -47,6 +47,8 @@ class GlobeState(
internal var baseRadiusPx by mutableFloatStateOf(0f)
internal var screenMinPx by mutableFloatStateOf(0f)
internal var viewportWidthPx by mutableIntStateOf(0)
internal var viewportHeightPx by mutableIntStateOf(0)
private var scope: CoroutineScope? = null
private var animJob: Job? = null
@ -68,10 +70,24 @@ class GlobeState(
this.scope = scope
}
fun setViewport(baseRadiusPx: Float, screenMinPx: Float) {
if (baseRadiusPx <= 0f || screenMinPx <= 0f) return
fun setViewport(
baseRadiusPx: Float,
screenMinPx: Float,
widthPx: Int,
heightPx: Int
) {
if (
baseRadiusPx <= 0f ||
screenMinPx <= 0f ||
widthPx <= 0 ||
heightPx <= 0
) {
return
}
this.baseRadiusPx = baseRadiusPx
this.screenMinPx = screenMinPx
this.viewportWidthPx = widthPx
this.viewportHeightPx = heightPx
syncSelection()
}

View File

@ -0,0 +1,232 @@
package com.bitchat.android.ui.globe
import kotlin.math.PI
import kotlin.math.atan
import kotlin.math.floor
import kotlin.math.ln
import kotlin.math.min
import kotlin.math.sinh
import kotlin.math.tan
data class GlobeViewport(
val centerLat: Double,
val centerLon: Double,
val globeRadiusPx: Float,
val widthPx: Int,
val heightPx: Int
)
data class GlobeTileKey(val zoom: Int, val x: Int, val y: Int) {
init {
require(zoom in 0..GlobeTileSelector.MAX_TILE_ZOOM)
val dimension = 1 shl zoom
require(x in 0 until dimension)
require(y in 0 until dimension)
}
}
data class GlobeTileRequest(
val detailZoom: Int,
val detailTiles: Set<GlobeTileKey>,
/**
* The small center-first subset that should be decoded and displayed before the
* remaining visible tiles.
*/
val priorityTiles: Set<GlobeTileKey> = emptySet()
)
/**
* Converts the visible portion of the custom orthographic globe into a bounded set of
* Web-Mercator XYZ tiles. Sampling screen space avoids fragile latitude/longitude bounding
* boxes around the poles and antimeridian.
*/
object GlobeTileSelector {
const val MAX_TILE_ZOOM = 14
private const val BASE_DETAIL_ZOOM = 2
internal const val MAX_VISIBLE_TILES = 36
private const val SAMPLE_COLUMNS = 7
private const val SAMPLE_ROWS = 9
private const val FITTED_GLOBE_RADIUS_FRACTION = 0.44f
fun select(viewport: GlobeViewport): GlobeTileRequest? {
if (
viewport.globeRadiusPx <= 0f ||
viewport.widthPx <= 0 ||
viewport.heightPx <= 0
) {
return null
}
// When the entire sphere fits on screen, z2 is enough for the current simplified
// globe design and costs only sixteen small tiles for complete global coverage.
if (
viewport.globeRadiusPx * 2f <= viewport.widthPx &&
viewport.globeRadiusPx * 2f <= viewport.heightPx
) {
val tiles = allTilesAtZoom(BASE_DETAIL_ZOOM)
return GlobeTileRequest(
detailZoom = BASE_DETAIL_ZOOM,
detailTiles = tiles,
priorityTiles = priorityTiles(viewport, BASE_DETAIL_ZOOM, tiles)
)
}
// Tile detail follows user zoom, not physical display density. The previous
// circumference-based calculation requested z4 near the fitted view on a high-DPI
// phone, even though z2 has more than enough geometry at that visual scale.
val fittedRadius = min(viewport.widthPx, viewport.heightPx) *
FITTED_GLOBE_RADIUS_FRACTION
val zoomFactor = (viewport.globeRadiusPx / fittedRadius).coerceAtLeast(1f)
val scaleZoom = floor(
log2(zoomFactor.toDouble())
).toInt() + BASE_DETAIL_ZOOM
var detailZoom = scaleZoom.coerceIn(BASE_DETAIL_ZOOM, MAX_TILE_ZOOM)
var tiles = sampledTiles(viewport, detailZoom)
while (tiles.size > MAX_VISIBLE_TILES && detailZoom > BASE_DETAIL_ZOOM) {
detailZoom--
tiles = sampledTiles(viewport, detailZoom)
}
return GlobeTileRequest(
detailZoom = detailZoom,
detailTiles = tiles,
priorityTiles = priorityTiles(viewport, detailZoom, tiles)
)
}
private fun sampledTiles(
viewport: GlobeViewport,
zoom: Int
): Set<GlobeTileKey> {
val keys = linkedSetOf<GlobeTileKey>()
val cx = viewport.widthPx / 2.0
val cy = viewport.heightPx / 2.0
val radius = viewport.globeRadiusPx.toDouble()
for (row in 0 until SAMPLE_ROWS) {
val screenY = viewport.heightPx * row.toDouble() / (SAMPLE_ROWS - 1)
for (column in 0 until SAMPLE_COLUMNS) {
val screenX = viewport.widthPx * column.toDouble() / (SAMPLE_COLUMNS - 1)
val location = GlobeMath.unproject(
x = (screenX - cx) / radius,
y = (screenY - cy) / radius,
centerLatDeg = viewport.centerLat,
centerLonDeg = viewport.centerLon
) ?: continue
addTileAndNeighbors(keys, zoom, location.first, location.second)
}
}
addTileAndNeighbors(
keys,
zoom,
viewport.centerLat,
viewport.centerLon
)
return keys
}
private fun addTileAndNeighbors(
output: MutableSet<GlobeTileKey>,
zoom: Int,
latitude: Double,
longitude: Double
) {
val dimension = 1 shl zoom
val centerX = longitudeToTileX(longitude, zoom)
val centerY = latitudeToTileY(latitude, zoom)
for (dy in -1..1) {
val y = centerY + dy
if (y !in 0 until dimension) continue
for (dx in -1..1) {
val x = floorMod(centerX + dx, dimension)
output.add(GlobeTileKey(zoom, x, y))
}
}
}
private fun priorityTiles(
viewport: GlobeViewport,
zoom: Int,
visibleTiles: Set<GlobeTileKey>
): Set<GlobeTileKey> {
val dimension = 1 shl zoom
val centerX = longitudeToTileX(viewport.centerLon, zoom)
val centerY = latitudeToTileY(viewport.centerLat, zoom)
val offsets = arrayOf(
0 to 0,
-1 to 0,
1 to 0,
0 to -1,
0 to 1
)
return buildSet(offsets.size) {
for ((dx, dy) in offsets) {
val y = centerY + dy
if (y !in 0 until dimension) continue
val key = GlobeTileKey(zoom, floorMod(centerX + dx, dimension), y)
if (key in visibleTiles) add(key)
}
}
}
internal fun longitudeToTileX(longitude: Double, zoom: Int): Int {
val dimension = 1 shl zoom
val normalized = GlobeMath.normalizeLon(longitude)
return floor((normalized + 180.0) / 360.0 * dimension)
.toInt()
.coerceIn(0, dimension - 1)
}
internal fun latitudeToTileY(latitude: Double, zoom: Int): Int {
val dimension = 1 shl zoom
val clamped = latitude.coerceIn(-WEB_MERCATOR_MAX_LAT, WEB_MERCATOR_MAX_LAT)
val radians = Math.toRadians(clamped)
val mercator = ln(tan(radians) + 1.0 / kotlin.math.cos(radians))
return floor((1.0 - mercator / PI) / 2.0 * dimension)
.toInt()
.coerceIn(0, dimension - 1)
}
internal fun tilePointToLongitude(
tileX: Int,
localX: Int,
extent: Int,
zoom: Int
): Double {
val dimension = (1 shl zoom).toDouble()
return (tileX + localX.toDouble() / extent) / dimension * 360.0 - 180.0
}
internal fun tilePointToLatitude(
tileY: Int,
localY: Int,
extent: Int,
zoom: Int
): Double {
val dimension = (1 shl zoom).toDouble()
val worldY = (tileY + localY.toDouble() / extent) / dimension
return Math.toDegrees(atan(sinh(PI * (1.0 - 2.0 * worldY))))
}
private fun allTilesAtZoom(zoom: Int): Set<GlobeTileKey> {
val dimension = 1 shl zoom
return buildSet(dimension * dimension) {
for (y in 0 until dimension) {
for (x in 0 until dimension) {
add(GlobeTileKey(zoom, x, y))
}
}
}
}
private fun floorMod(value: Int, modulus: Int): Int {
val remainder = value % modulus
return if (remainder < 0) remainder + modulus else remainder
}
private fun log2(value: Double): Double = ln(value) / ln(2.0)
private const val WEB_MERCATOR_MAX_LAT = 85.05112878
}

File diff suppressed because it is too large Load Diff

View File

@ -1,164 +0,0 @@
package com.bitchat.android.ui.globe
import android.content.Context
import org.json.JSONObject
import kotlin.math.cos
import kotlin.math.sin
/**
* Loads the bundled Natural Earth 110m land polygons (public domain) from assets
* and exposes them as flat rings of lat/lon pairs for vector globe rendering.
*/
object LandData {
data class Ring(
val coords: FloatArray,
val size: Int,
/**
* Per-point sin(latitude), cos(latitude), sin(longitude), cos(longitude).
* Preparing this once removes nearly all trigonometry from animated frames.
*/
val projectionTerms: FloatArray = prepareProjectionTerms(coords, size)
)
data class City(
val name: String,
val lat: Float,
val lon: Float,
val rank: Int,
val capital: Boolean,
val megacity: Boolean,
val projectionTerms: FloatArray = prepareProjectionTerms(
floatArrayOf(lat, lon),
size = 1
)
)
@Volatile
private var cached: List<Ring>? = null
@Volatile
private var cachedBorders: List<Ring>? = null
@Volatile
private var cachedCities: List<City>? = null
/** Returns land polygon rings; each ring is a flat array of (lat, lon) pairs. */
fun load(context: Context): List<Ring> {
cached?.let { return it }
synchronized(this) {
cached?.let { return it }
val rings = mutableListOf<Ring>()
val text = context.assets.open("world_land.geojson").bufferedReader().use { it.readText() }
val root = JSONObject(text)
val geometries = root.getJSONArray("geometries")
for (i in 0 until geometries.length()) {
val geom = geometries.getJSONObject(i)
when (geom.getString("type")) {
"Polygon" -> parsePolygon(geom.getJSONArray("coordinates"), rings)
"MultiPolygon" -> {
val polys = geom.getJSONArray("coordinates")
for (j in 0 until polys.length()) {
parsePolygon(polys.getJSONArray(j), rings)
}
}
}
}
cached = rings
return rings
}
}
/** Returns country border lines (Natural Earth admin-0 boundary lines, public domain). */
fun loadBorders(context: Context): List<Ring> {
cachedBorders?.let { return it }
synchronized(this) {
cachedBorders?.let { return it }
val lines = mutableListOf<Ring>()
val text = context.assets.open("world_borders.geojson").bufferedReader().use { it.readText() }
val root = JSONObject(text)
val geometries = root.getJSONArray("geometries")
for (i in 0 until geometries.length()) {
val geom = geometries.getJSONObject(i)
when (geom.getString("type")) {
"LineString" -> parseLine(geom.getJSONArray("coordinates"), lines)
"MultiLineString" -> {
val parts = geom.getJSONArray("coordinates")
for (j in 0 until parts.length()) {
parseLine(parts.getJSONArray(j), lines)
}
}
}
}
cachedBorders = lines
return lines
}
}
/** Returns populated places (Natural Earth 50m, public domain) with name and scale rank. */
fun loadCities(context: Context): List<City> {
cachedCities?.let { return it }
synchronized(this) {
cachedCities?.let { return it }
val cities = mutableListOf<City>()
val text = context.assets.open("world_cities.geojson").bufferedReader().use { it.readText() }
val root = JSONObject(text)
val arr = root.getJSONArray("cities")
for (i in 0 until arr.length()) {
val c = arr.getJSONObject(i)
cities.add(
City(
name = c.getString("n"),
lat = c.getDouble("lat").toFloat(),
lon = c.getDouble("lon").toFloat(),
rank = c.getInt("r"),
capital = c.optInt("cap", 0) == 1,
megacity = c.optInt("mega", 0) == 1
)
)
}
cachedCities = cities
return cities
}
}
private fun parseLine(lineJson: org.json.JSONArray, out: MutableList<Ring>) {
val n = lineJson.length()
if (n < 2) return
val coords = FloatArray(n * 2)
for (p in 0 until n) {
val pt = lineJson.getJSONArray(p)
coords[p * 2] = pt.getDouble(1).toFloat() // lat
coords[p * 2 + 1] = pt.getDouble(0).toFloat() // lon
}
out.add(Ring(coords, n))
}
private fun parsePolygon(ringsJson: org.json.JSONArray, out: MutableList<Ring>) {
for (r in 0 until ringsJson.length()) {
val ringJson = ringsJson.getJSONArray(r)
val n = ringJson.length()
if (n < 3) continue
val coords = FloatArray(n * 2)
for (p in 0 until n) {
val pt = ringJson.getJSONArray(p)
coords[p * 2] = pt.getDouble(1).toFloat() // lat
coords[p * 2 + 1] = pt.getDouble(0).toFloat() // lon
}
out.add(Ring(coords, n))
}
}
private fun prepareProjectionTerms(coords: FloatArray, size: Int): FloatArray {
val result = FloatArray(size * 4)
for (index in 0 until size) {
val latRadians = Math.toRadians(coords[index * 2].toDouble())
val lonRadians = Math.toRadians(coords[index * 2 + 1].toDouble())
result[index * 4] = sin(latRadians).toFloat()
result[index * 4 + 1] = cos(latRadians).toFloat()
result[index * 4 + 2] = sin(lonRadians).toFloat()
result[index * 4 + 3] = cos(lonRadians).toFloat()
}
return result
}
}

View File

@ -0,0 +1,614 @@
package com.bitchat.android.ui.globe
import java.nio.charset.StandardCharsets
import java.util.Locale
import kotlin.math.roundToLong
internal data class DecodedGlobeTile(
val oceanPolygons: List<OceanPolygon> = emptyList(),
val borders: List<BorderLine> = emptyList(),
val boundaryLabels: List<MapLabel> = emptyList(),
val placeLabels: List<MapLabel> = emptyList()
)
/**
* Small, defensive Mapbox Vector Tile decoder for the four Shortbread layers used by the
* globe. Keeping this decoder focused avoids shipping a second rendering engine or a
* protobuf runtime simply to turn streamed coordinates back into latitude/longitude.
*/
internal class MvtDecoder(
private val preferredLanguage: String = Locale.getDefault().language
) {
fun decode(bytes: ByteArray, tile: GlobeTileKey): DecodedGlobeTile {
if (bytes.size > MAX_TILE_BYTES) {
throw MvtDecodingException("Vector tile exceeds the supported size")
}
val result = MutableDecodedTile()
val reader = ProtoReader(bytes)
var layerCount = 0
while (!reader.isAtEnd) {
val tag = reader.readTag()
if (tag.fieldNumber == TILE_LAYER_FIELD && tag.wireType == WIRE_LENGTH_DELIMITED) {
layerCount++
if (layerCount > MAX_LAYERS) {
throw MvtDecodingException("Vector tile contains too many layers")
}
decodeLayer(reader.readBytes(), tile, result)
} else {
reader.skip(tag.wireType)
}
}
return result.freeze()
}
private fun decodeLayer(
bytes: ByteArray,
tile: GlobeTileKey,
output: MutableDecodedTile
) {
// Shortbread contains many rendering layers (roads, buildings, POIs, and more).
// Find the layer name without copying its features, then completely skip layers
// this globe never uses.
val layerName = readLayerName(bytes) ?: return
if (layerName !in SUPPORTED_LAYERS) return
val reader = ProtoReader(bytes)
var name = layerName
var extent = DEFAULT_EXTENT
val keys = ArrayList<String>()
val values = ArrayList<Any?>()
val rawFeatures = ArrayList<ByteArray>()
while (!reader.isAtEnd) {
val tag = reader.readTag()
when {
tag.fieldNumber == LAYER_NAME_FIELD &&
tag.wireType == WIRE_LENGTH_DELIMITED -> {
name = reader.readString()
}
tag.fieldNumber == LAYER_FEATURE_FIELD &&
tag.wireType == WIRE_LENGTH_DELIMITED -> {
if (rawFeatures.size >= MAX_FEATURES_PER_LAYER) {
throw MvtDecodingException("Vector tile layer contains too many features")
}
rawFeatures.add(reader.readBytes())
}
tag.fieldNumber == LAYER_KEY_FIELD &&
tag.wireType == WIRE_LENGTH_DELIMITED -> {
if (keys.size >= MAX_DICTIONARY_ENTRIES) {
throw MvtDecodingException("Vector tile key dictionary is too large")
}
keys.add(reader.readString())
}
tag.fieldNumber == LAYER_VALUE_FIELD &&
tag.wireType == WIRE_LENGTH_DELIMITED -> {
if (values.size >= MAX_DICTIONARY_ENTRIES) {
throw MvtDecodingException("Vector tile value dictionary is too large")
}
values.add(decodeValue(reader.readBytes()))
}
tag.fieldNumber == LAYER_EXTENT_FIELD && tag.wireType == WIRE_VARINT -> {
extent = reader.readVarint().toInt()
if (extent !in 1..MAX_EXTENT) {
throw MvtDecodingException("Unsupported vector tile extent")
}
}
else -> reader.skip(tag.wireType)
}
}
rawFeatures.forEach { featureBytes ->
decodeFeature(name, featureBytes, keys, values, extent, tile, output)
}
}
private fun readLayerName(bytes: ByteArray): String? {
val reader = ProtoReader(bytes)
while (!reader.isAtEnd) {
val tag = reader.readTag()
if (
tag.fieldNumber == LAYER_NAME_FIELD &&
tag.wireType == WIRE_LENGTH_DELIMITED
) {
return reader.readString()
}
reader.skip(tag.wireType)
}
return null
}
private fun decodeFeature(
layerName: String,
bytes: ByteArray,
keys: List<String>,
values: List<Any?>,
extent: Int,
tile: GlobeTileKey,
output: MutableDecodedTile
) {
val reader = ProtoReader(bytes)
var geometryType = GEOMETRY_UNKNOWN
var rawTags = IntArray(0)
var geometry = IntArray(0)
while (!reader.isAtEnd) {
val tag = reader.readTag()
when {
tag.fieldNumber == FEATURE_TAGS_FIELD -> {
rawTags = reader.readPackedUInt32(tag.wireType, MAX_TAG_VALUES)
}
tag.fieldNumber == FEATURE_TYPE_FIELD && tag.wireType == WIRE_VARINT -> {
geometryType = reader.readVarint().toInt()
}
tag.fieldNumber == FEATURE_GEOMETRY_FIELD -> {
geometry = reader.readPackedUInt32(tag.wireType, MAX_GEOMETRY_VALUES)
}
else -> reader.skip(tag.wireType)
}
}
if (geometry.isEmpty()) return
val properties = decodeProperties(rawTags, keys, values)
when (layerName) {
OCEAN_LAYER -> {
if (geometryType != GEOMETRY_POLYGON) return
val paths = decodeGeometry(geometry, geometryType)
val rings = paths.mapNotNull { path ->
path.toGeoRing(
tile = tile,
extent = extent,
minimumPoints = 3,
isMvtExterior = path.signedAreaTwice() > 0L
)
}
if (rings.isNotEmpty()) output.oceanPolygons.add(OceanPolygon(rings))
}
BOUNDARIES_LAYER -> {
if (geometryType != GEOMETRY_LINESTRING) return
val maritime = properties.booleanValue("maritime")
val disputed = properties.booleanValue("disputed")
val adminLevel = properties.intValue("admin_level")
decodeGeometry(geometry, geometryType).forEach { path ->
val ring = path.toGeoRing(tile, extent, minimumPoints = 2) ?: return@forEach
output.borders.add(
BorderLine(
ring = ring,
maritime = maritime,
disputed = disputed,
adminLevel = adminLevel
)
)
}
}
BOUNDARY_LABELS_LAYER -> {
if (geometryType != GEOMETRY_POINT) return
val name = properties.localizedName() ?: return
val adminLevel = properties.intValue("admin_level")
val kind = if (adminLevel == 2) MapLabelKind.COUNTRY else MapLabelKind.STATE
val importance = properties.longValue("way_area") ?: 0L
decodeGeometry(geometry, geometryType).forEach { path ->
val point = path.firstOrNull() ?: return@forEach
output.boundaryLabels.add(
point.toMapLabel(tile, extent, name, kind, importance)
)
}
}
PLACE_LABELS_LAYER -> {
if (geometryType != GEOMETRY_POINT) return
val name = properties.localizedName() ?: return
val kind = mapPlaceKind(properties.stringValue("kind"))
val population = properties.longValue("population") ?: 0L
decodeGeometry(geometry, geometryType).forEach { path ->
val point = path.firstOrNull() ?: return@forEach
output.placeLabels.add(
point.toMapLabel(tile, extent, name, kind, population)
)
}
}
}
}
private fun decodeProperties(
rawTags: IntArray,
keys: List<String>,
values: List<Any?>
): Map<String, Any?> {
if (rawTags.size % 2 != 0) {
throw MvtDecodingException("Vector tile feature has malformed tags")
}
return buildMap(rawTags.size / 2) {
var index = 0
while (index < rawTags.size) {
val keyIndex = rawTags[index]
val valueIndex = rawTags[index + 1]
if (keyIndex !in keys.indices || valueIndex !in values.indices) {
throw MvtDecodingException("Vector tile feature references an invalid tag")
}
put(keys[keyIndex], values[valueIndex])
index += 2
}
}
}
private fun decodeValue(bytes: ByteArray): Any? {
val reader = ProtoReader(bytes)
var value: Any? = null
while (!reader.isAtEnd) {
val tag = reader.readTag()
value = when {
tag.fieldNumber == VALUE_STRING_FIELD &&
tag.wireType == WIRE_LENGTH_DELIMITED -> reader.readString()
tag.fieldNumber == VALUE_FLOAT_FIELD &&
tag.wireType == WIRE_FIXED32 -> Float.fromBits(reader.readFixed32())
tag.fieldNumber == VALUE_DOUBLE_FIELD &&
tag.wireType == WIRE_FIXED64 -> Double.fromBits(reader.readFixed64())
tag.fieldNumber == VALUE_INT_FIELD &&
tag.wireType == WIRE_VARINT -> reader.readVarint()
tag.fieldNumber == VALUE_UINT_FIELD &&
tag.wireType == WIRE_VARINT -> reader.readVarint()
tag.fieldNumber == VALUE_SINT_FIELD &&
tag.wireType == WIRE_VARINT -> decodeZigZag64(reader.readVarint())
tag.fieldNumber == VALUE_BOOL_FIELD &&
tag.wireType == WIRE_VARINT -> reader.readVarint() != 0L
else -> {
reader.skip(tag.wireType)
value
}
}
}
return value
}
private fun decodeGeometry(encoded: IntArray, geometryType: Int): List<List<TilePoint>> {
val paths = ArrayList<MutableList<TilePoint>>()
var currentPath: MutableList<TilePoint>? = null
var cursorX = 0
var cursorY = 0
var index = 0
while (index < encoded.size) {
val commandInteger = encoded[index++]
val command = commandInteger and COMMAND_ID_MASK
val count = commandInteger ushr COMMAND_COUNT_SHIFT
if (count <= 0 || count > MAX_COMMAND_COUNT) {
throw MvtDecodingException("Vector tile geometry has an invalid command count")
}
when (command) {
COMMAND_MOVE_TO, COMMAND_LINE_TO -> {
repeat(count) {
if (index + 1 >= encoded.size) {
throw MvtDecodingException("Vector tile geometry is truncated")
}
cursorX += decodeZigZag32(encoded[index++])
cursorY += decodeZigZag32(encoded[index++])
val targetPath = if (
geometryType == GEOMETRY_POINT ||
command == COMMAND_MOVE_TO ||
currentPath == null
) {
ArrayList<TilePoint>().also { newPath ->
currentPath = newPath
paths.add(newPath)
}
} else {
requireNotNull(currentPath)
}
targetPath.add(TilePoint(cursorX, cursorY))
}
}
COMMAND_CLOSE_PATH -> {
if (geometryType != GEOMETRY_POLYGON || count != 1 || currentPath == null) {
throw MvtDecodingException("Vector tile geometry has an invalid close command")
}
}
else -> throw MvtDecodingException("Vector tile geometry uses an unknown command")
}
}
return paths
}
private fun List<TilePoint>.toGeoRing(
tile: GlobeTileKey,
extent: Int,
minimumPoints: Int,
isMvtExterior: Boolean? = null
): GeoRing? {
if (size < minimumPoints) return null
val coords = FloatArray(size * 2)
forEachIndexed { index, point ->
coords[index * 2] = GlobeTileSelector.tilePointToLatitude(
tile.y, point.y, extent, tile.zoom
).toFloat()
coords[index * 2 + 1] = GlobeTileSelector.tilePointToLongitude(
tile.x, point.x, extent, tile.zoom
).toFloat()
}
return GeoRing(
coords = coords,
size = size,
isMvtExterior = isMvtExterior
)
}
private fun List<TilePoint>.signedAreaTwice(): Long {
if (size < 3) return 0L
var area = 0L
var previous = last()
for (point in this) {
area += previous.x.toLong() * point.y - point.x.toLong() * previous.y
previous = point
}
return area
}
private fun TilePoint.toMapLabel(
tile: GlobeTileKey,
extent: Int,
name: String,
kind: MapLabelKind,
importance: Long
): MapLabel {
return MapLabel(
name = name,
lat = GlobeTileSelector.tilePointToLatitude(
tile.y, y, extent, tile.zoom
).toFloat(),
lon = GlobeTileSelector.tilePointToLongitude(
tile.x, x, extent, tile.zoom
).toFloat(),
kind = kind,
importance = importance
)
}
private fun Map<String, Any?>.localizedName(): String? {
val preferred = preferredLanguage
.takeIf { it.matches(LANGUAGE_CODE) }
?.let { stringValue("name_$it") }
return sequenceOf(preferred, stringValue("name"), stringValue("name_en"))
.filterNotNull()
.map(String::trim)
.firstOrNull { it.isNotEmpty() }
}
private fun Map<String, Any?>.stringValue(key: String): String? = get(key) as? String
private fun Map<String, Any?>.intValue(key: String): Int? {
return when (val value = get(key)) {
is Number -> value.toInt()
is String -> value.toIntOrNull()
else -> null
}
}
private fun Map<String, Any?>.longValue(key: String): Long? {
return when (val value = get(key)) {
is Number -> value.toDouble().roundToLong()
is String -> value.toDoubleOrNull()?.roundToLong()
else -> null
}
}
private fun Map<String, Any?>.booleanValue(key: String): Boolean {
return when (val value = get(key)) {
is Boolean -> value
is Number -> value.toInt() != 0
is String -> value == "1" || value.equals("true", ignoreCase = true)
else -> false
}
}
private fun mapPlaceKind(kind: String?): MapLabelKind {
return when (kind) {
"capital", "state_capital" -> MapLabelKind.CAPITAL
"city" -> MapLabelKind.CITY
"town" -> MapLabelKind.TOWN
"village", "hamlet" -> MapLabelKind.VILLAGE
else -> MapLabelKind.OTHER
}
}
private data class TilePoint(val x: Int, val y: Int)
private class MutableDecodedTile {
val oceanPolygons = ArrayList<OceanPolygon>()
val borders = ArrayList<BorderLine>()
val boundaryLabels = ArrayList<MapLabel>()
val placeLabels = ArrayList<MapLabel>()
fun freeze() = DecodedGlobeTile(
oceanPolygons = oceanPolygons,
borders = borders,
boundaryLabels = boundaryLabels,
placeLabels = placeLabels
)
}
companion object {
internal const val MAX_TILE_BYTES = 2 * 1024 * 1024
private const val MAX_LAYERS = 64
private const val MAX_FEATURES_PER_LAYER = 100_000
private const val MAX_DICTIONARY_ENTRIES = 100_000
private const val MAX_TAG_VALUES = 100_000
private const val MAX_GEOMETRY_VALUES = 1_000_000
private const val MAX_COMMAND_COUNT = 1_000_000
private const val MAX_EXTENT = 65_536
private const val DEFAULT_EXTENT = 4096
private const val TILE_LAYER_FIELD = 3
private const val LAYER_NAME_FIELD = 1
private const val LAYER_FEATURE_FIELD = 2
private const val LAYER_KEY_FIELD = 3
private const val LAYER_VALUE_FIELD = 4
private const val LAYER_EXTENT_FIELD = 5
private const val FEATURE_TAGS_FIELD = 2
private const val FEATURE_TYPE_FIELD = 3
private const val FEATURE_GEOMETRY_FIELD = 4
private const val VALUE_STRING_FIELD = 1
private const val VALUE_FLOAT_FIELD = 2
private const val VALUE_DOUBLE_FIELD = 3
private const val VALUE_INT_FIELD = 4
private const val VALUE_UINT_FIELD = 5
private const val VALUE_SINT_FIELD = 6
private const val VALUE_BOOL_FIELD = 7
private const val OCEAN_LAYER = "ocean"
private const val BOUNDARIES_LAYER = "boundaries"
private const val BOUNDARY_LABELS_LAYER = "boundary_labels"
private const val PLACE_LABELS_LAYER = "place_labels"
private val SUPPORTED_LAYERS = setOf(
OCEAN_LAYER,
BOUNDARIES_LAYER,
BOUNDARY_LABELS_LAYER,
PLACE_LABELS_LAYER
)
private const val GEOMETRY_UNKNOWN = 0
private const val GEOMETRY_POINT = 1
private const val GEOMETRY_LINESTRING = 2
private const val GEOMETRY_POLYGON = 3
private const val COMMAND_MOVE_TO = 1
private const val COMMAND_LINE_TO = 2
private const val COMMAND_CLOSE_PATH = 7
private const val COMMAND_ID_MASK = 0x7
private const val COMMAND_COUNT_SHIFT = 3
private val LANGUAGE_CODE = Regex("[a-z]{2,3}")
private fun decodeZigZag32(value: Int): Int = (value ushr 1) xor -(value and 1)
private fun decodeZigZag64(value: Long): Long = (value ushr 1) xor -(value and 1L)
}
}
internal class MvtDecodingException(message: String) : Exception(message)
private data class ProtoTag(val fieldNumber: Int, val wireType: Int)
private class ProtoReader(private val bytes: ByteArray) {
private var position = 0
val isAtEnd: Boolean get() = position >= bytes.size
fun readTag(): ProtoTag {
val rawTag = readVarint()
val fieldNumber = (rawTag ushr 3).toInt()
val wireType = (rawTag and 0x7).toInt()
if (fieldNumber <= 0) throw MvtDecodingException("Invalid protobuf field")
return ProtoTag(fieldNumber, wireType)
}
fun readVarint(): Long {
var result = 0L
var shift = 0
while (shift < 64) {
if (position >= bytes.size) throw MvtDecodingException("Truncated protobuf value")
val value = bytes[position++].toInt() and 0xff
result = result or ((value and 0x7f).toLong() shl shift)
if ((value and 0x80) == 0) return result
shift += 7
}
throw MvtDecodingException("Malformed protobuf value")
}
fun readFixed32(): Int {
requireAvailable(4)
val result =
(bytes[position].toInt() and 0xff) or
((bytes[position + 1].toInt() and 0xff) shl 8) or
((bytes[position + 2].toInt() and 0xff) shl 16) or
((bytes[position + 3].toInt() and 0xff) shl 24)
position += 4
return result
}
fun readFixed64(): Long {
requireAvailable(8)
var result = 0L
for (offset in 0 until 8) {
result = result or ((bytes[position + offset].toLong() and 0xffL) shl (offset * 8))
}
position += 8
return result
}
fun readBytes(): ByteArray {
val length = readLength()
requireAvailable(length)
return bytes.copyOfRange(position, position + length).also { position += length }
}
fun readString(): String {
val value = readBytes()
if (value.size > MAX_STRING_BYTES) {
throw MvtDecodingException("Vector tile string is too long")
}
return String(value, StandardCharsets.UTF_8)
}
fun readPackedUInt32(wireType: Int, maximumValues: Int): IntArray {
return when (wireType) {
WIRE_VARINT -> intArrayOf(readVarint().checkedUInt32())
WIRE_LENGTH_DELIMITED -> {
val packedReader = ProtoReader(readBytes())
val result = ArrayList<Int>()
while (!packedReader.isAtEnd) {
if (result.size >= maximumValues) {
throw MvtDecodingException("Packed protobuf field is too large")
}
result.add(packedReader.readVarint().checkedUInt32())
}
result.toIntArray()
}
else -> throw MvtDecodingException("Unexpected protobuf wire type")
}
}
fun skip(wireType: Int) {
when (wireType) {
WIRE_VARINT -> readVarint()
WIRE_FIXED64 -> {
requireAvailable(8)
position += 8
}
WIRE_LENGTH_DELIMITED -> {
val length = readLength()
requireAvailable(length)
position += length
}
WIRE_FIXED32 -> {
requireAvailable(4)
position += 4
}
else -> throw MvtDecodingException("Unsupported protobuf wire type")
}
}
private fun readLength(): Int {
val length = readVarint()
if (length < 0L || length > Int.MAX_VALUE) {
throw MvtDecodingException("Invalid protobuf length")
}
return length.toInt()
}
private fun requireAvailable(count: Int) {
if (count < 0 || position > bytes.size - count) {
throw MvtDecodingException("Truncated protobuf field")
}
}
private fun Long.checkedUInt32(): Int {
if (this < 0L || this > Int.MAX_VALUE) {
throw MvtDecodingException("Protobuf integer exceeds uint32")
}
return toInt()
}
companion object {
private const val MAX_STRING_BYTES = 16 * 1024
}
}
private const val WIRE_VARINT = 0
private const val WIRE_FIXED64 = 1
private const val WIRE_LENGTH_DELIMITED = 2
private const val WIRE_FIXED32 = 5

View File

@ -0,0 +1,264 @@
package com.bitchat.android.ui.globe
import android.content.Context
import com.bitchat.android.BuildConfig
import com.bitchat.android.net.OkHttpProvider
import java.io.ByteArrayOutputStream
import java.io.Closeable
import java.io.IOException
import java.util.LinkedHashMap
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import okhttp3.Cache
import okhttp3.OkHttpClient
import okhttp3.Request
/**
* Streams only the visible Shortbread vector tiles from OpenStreetMap.
*
* The disk cache follows server cache headers (with a seven-day fallback), while the small
* decoded LRU prevents repeated protobuf work during globe rotation. No map dataset is
* bundled with or permanently stored by the app.
*/
internal class StreamedGlobeRepository(
context: Context,
private val tileUrl: (GlobeTileKey) -> String = { tile ->
"$DEFAULT_TILE_ROOT/${tile.zoom}/${tile.x}/${tile.y}.mvt"
},
private val decoder: MvtDecoder = MvtDecoder()
) : Closeable {
private val tileCache = Cache(
directory = context.cacheDir.resolve(CACHE_DIRECTORY),
maxSize = DISK_CACHE_BYTES
)
private val fetchSemaphore = Semaphore(MAX_CONCURRENT_FETCHES)
private val decodeSemaphore = Semaphore(MAX_CONCURRENT_DECODES)
private val decodedTiles = object :
LinkedHashMap<GlobeTileKey, DecodedGlobeTile>(
MEMORY_CACHE_ENTRIES,
0.75f,
true
) {
override fun removeEldestEntry(
eldest: MutableMap.MutableEntry<GlobeTileKey, DecodedGlobeTile>?
): Boolean = size > MEMORY_CACHE_ENTRIES
}
suspend fun load(
request: GlobeTileRequest,
onProgress: suspend (GlobeMapLoadResult) -> Unit = {}
): GlobeMapLoadResult {
val priorityTiles = buildList {
add(GLOBAL_OCEAN_TILE)
request.priorityTiles
.asSequence()
.filterNot { it == GLOBAL_OCEAN_TILE }
.forEach(::add)
}
val remainingTiles = request.detailTiles
.asSequence()
.filterNot { it == GLOBAL_OCEAN_TILE || it in request.priorityTiles }
.sortedWith(compareBy(GlobeTileKey::zoom, GlobeTileKey::y, GlobeTileKey::x))
.toList()
val client = currentTileClient()
val outcomes = ArrayList<TileOutcome>(priorityTiles.size + remainingTiles.size)
outcomes += loadTiles(client, priorityTiles)
if (remainingTiles.isNotEmpty()) {
onProgress(assembleResult(request, outcomes))
outcomes += loadTiles(client, remainingTiles)
}
return assembleResult(request, outcomes)
}
private suspend fun loadTiles(
client: OkHttpClient,
tiles: List<GlobeTileKey>
): List<TileOutcome> = coroutineScope {
tiles.map { tile ->
async {
try {
TileOutcome(tile, fetchTile(client, tile), null)
} catch (cancellation: CancellationException) {
throw cancellation
} catch (error: Exception) {
TileOutcome(tile, null, error)
}
}
}.awaitAll()
}
private suspend fun assembleResult(
request: GlobeTileRequest,
outcomes: List<TileOutcome>
): GlobeMapLoadResult = withContext(Dispatchers.Default) {
val successful = outcomes.filter { it.decoded != null }
if (successful.isEmpty()) {
throw IOException("OpenStreetMap vector tiles are unavailable")
}
val globalOcean = successful
.firstOrNull { it.tile == GLOBAL_OCEAN_TILE }
?.decoded
?.oceanPolygons
.orEmpty()
if (globalOcean.isEmpty()) {
throw IOException("The global OpenStreetMap ocean layer is unavailable")
}
val detailTiles = successful.filter { it.tile != GLOBAL_OCEAN_TILE }
val boundaryLabels = deduplicateLabels(
detailTiles.flatMap { it.decoded?.boundaryLabels.orEmpty() }
)
val placeLabels = deduplicateLabels(
detailTiles.flatMap { it.decoded?.placeLabels.orEmpty() }
)
GlobeMapLoadResult(
data = GlobeMapData(
oceanPolygons = globalOcean,
borders = detailTiles.flatMap { it.decoded?.borders.orEmpty() },
boundaryLabels = boundaryLabels,
placeLabels = placeLabels,
detailZoom = request.detailZoom
),
requestedTileCount = outcomes.size,
failedTileCount = outcomes.count { it.error != null }
)
}
private fun currentTileClient(): OkHttpClient {
return OkHttpProvider.httpClient()
.newBuilder()
.cache(tileCache)
.addNetworkInterceptor { chain ->
val response = chain.proceed(chain.request())
if (response.header("Cache-Control") != null) {
response
} else {
response.newBuilder()
.header("Cache-Control", "public, max-age=$FALLBACK_CACHE_SECONDS")
.build()
}
}
.build()
}
private suspend fun fetchTile(
client: OkHttpClient,
tile: GlobeTileKey
): DecodedGlobeTile {
synchronized(decodedTiles) {
decodedTiles[tile]?.let { return it }
}
val bytes = fetchSemaphore.withPermit {
runInterruptible(Dispatchers.IO) {
val request = Request.Builder()
.url(tileUrl(tile))
.header("Accept", VECTOR_TILE_MEDIA_TYPE)
.header("User-Agent", USER_AGENT)
.get()
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException(
"OpenStreetMap tile request failed (${response.code})"
)
}
response.body.readBytesWithLimit(MvtDecoder.MAX_TILE_BYTES)
}
}
}
val decoded = decodeSemaphore.withPermit {
withContext(Dispatchers.Default) {
decoder.decode(bytes, tile)
}
}
synchronized(decodedTiles) {
decodedTiles[tile] = decoded
}
return decoded
}
override fun close() {
tileCache.close()
synchronized(decodedTiles) {
decodedTiles.clear()
}
}
private fun okhttp3.ResponseBody.readBytesWithLimit(maximumBytes: Int): ByteArray {
contentLength().takeIf { it >= 0L }?.let { length ->
if (length > maximumBytes) {
throw IOException("OpenStreetMap tile response is too large")
}
}
byteStream().use { input ->
val output = ByteArrayOutputStream()
val buffer = ByteArray(8 * 1024)
var total = 0
while (true) {
val count = input.read(buffer)
if (count < 0) break
total += count
if (total > maximumBytes) {
throw IOException("OpenStreetMap tile response is too large")
}
output.write(buffer, 0, count)
}
return output.toByteArray()
}
}
private fun deduplicateLabels(labels: List<MapLabel>): List<MapLabel> {
return labels
.sortedWith(
compareBy<MapLabel> { it.rank }
.thenByDescending { it.importance }
.thenBy { it.name }
)
.distinctBy { label ->
LabelIdentity(
name = label.name,
roundedLatitude = (label.lat * LABEL_DEDUPLICATION_SCALE).toInt(),
roundedLongitude = (label.lon * LABEL_DEDUPLICATION_SCALE).toInt()
)
}
}
private data class TileOutcome(
val tile: GlobeTileKey,
val decoded: DecodedGlobeTile?,
val error: Exception?
)
private data class LabelIdentity(
val name: String,
val roundedLatitude: Int,
val roundedLongitude: Int
)
companion object {
private const val DEFAULT_TILE_ROOT =
"https://vector.openstreetmap.org/shortbread_v1"
private const val CACHE_DIRECTORY = "openstreetmap_globe_tiles"
private const val DISK_CACHE_BYTES = 32L * 1024L * 1024L
private const val MEMORY_CACHE_ENTRIES = 64
private const val MAX_CONCURRENT_FETCHES = 4
private const val MAX_CONCURRENT_DECODES = 2
private const val FALLBACK_CACHE_SECONDS = 7L * 24L * 60L * 60L
private const val LABEL_DEDUPLICATION_SCALE = 100
private const val VECTOR_TILE_MEDIA_TYPE = "application/vnd.mapbox-vector-tile"
private val GLOBAL_OCEAN_TILE = GlobeTileKey(0, 0, 0)
private val USER_AGENT =
"Bitchat-Android/${BuildConfig.VERSION_NAME} " +
"(+https://github.com/permissionlesstech/bitchat-android)"
}
}

View File

@ -542,6 +542,9 @@
<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_map_loading">Loading OpenStreetMap data…</string>
<string name="globe_map_load_error">Map data is temporarily unavailable</string>
<string name="openstreetmap_attribution">© OpenStreetMap contributors</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

@ -46,7 +46,7 @@ class GlobeGeometryTest {
@Test
fun ringContainsLocation_distinguishesInsideAndOutsideCoordinates() {
val ring = LandData.Ring(
val ring = GeoRing(
coords = floatArrayOf(
-10f, -10f,
-10f, 10f,
@ -59,4 +59,68 @@ class GlobeGeometryTest {
assertTrue(ringContainsLocation(ring, latitude = 0.0, longitude = 0.0))
assertFalse(ringContainsLocation(ring, latitude = 20.0, longitude = 0.0))
}
@Test
fun compactRingBoundsCullOnlyDistantViews() {
val ring = GeoRing(
coords = floatArrayOf(
-2f, -2f,
-2f, 2f,
2f, 2f,
2f, -2f
),
size = 4
)
assertTrue(
ring.sphericalBounds.mayIntersectView(
viewCenterX = 1f,
viewCenterY = 0f,
viewCenterZ = 0f,
viewAngularRadius = 0.1f
)
)
assertFalse(
ring.sphericalBounds.mayIntersectView(
viewCenterX = -1f,
viewCenterY = 0f,
viewCenterZ = 0f,
viewAngularRadius = 0.1f
)
)
}
@Test
fun limbPoint_intersectsTheTrueProjectionHorizon() {
val point = limbPoint(
DiscPt(x = 0.2f, y = 0f, front = false, depth = -0.8f),
DiscPt(x = 0.6f, y = 0f, front = true, depth = 0.8f)
)
assertEquals(1f, point.first, 0.0001f)
assertEquals(0f, point.second, 0.0001f)
}
@Test
fun fillPolygon_replacesBacksideVerticesWithOneSmoothLimbArc() {
val polygon = buildFillPolygon(
pts = listOf(
DiscPt(-0.6f, -0.2f, front = true, depth = 0.5f),
DiscPt(-0.2f, 0.1f, front = false, depth = -0.5f),
DiscPt(0.2f, -0.1f, front = false, depth = -0.5f),
DiscPt(0.6f, -0.2f, front = true, depth = 0.5f),
DiscPt(0f, -0.6f, front = true, depth = 0.5f)
),
cx = 0f,
cy = 0f,
r = 1f
)
assertTrue(polygon.size >= 5)
assertTrue(polygon.all { (x, y) -> x * x + y * y <= 1.0001f })
assertFalse(polygon.any { (x, y) ->
kotlin.math.abs(x + 0.2f) < 0.0001f &&
kotlin.math.abs(y - 0.1f) < 0.0001f
})
}
}

View File

@ -12,54 +12,78 @@ class GlobeInteractionPolicyTest {
fun fullDetail_preservesAllGlobeFeatures() {
val detail = globeFrameDetail(GlobeMotionDetail.FULL)
assertTrue(detail.showGraticule)
assertEquals(1, detail.landPointStride)
assertEquals(0f, detail.minimumLandRingRadiusPx)
assertTrue(detail.showBorders)
assertNull(detail.cityMaxRank)
assertTrue(detail.showCityLabels)
assertTrue(detail.showStateLabels)
assertNull(detail.maximumBoundaryLabels)
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() {
fun fastDetail_preservesTheCompleteVisualDesignDuringMovement() {
val detail = globeFrameDetail(GlobeMotionDetail.FAST)
assertEquals(2, detail.landPointStride)
assertFalse(detail.showBorders)
assertEquals(-1, detail.cityMaxRank)
assertFalse(detail.showCityLabels)
assertFalse(detail.showGeohashGrid)
assertTrue(detail.showGraticule)
assertEquals(1, detail.landPointStride)
assertEquals(0f, detail.minimumLandRingRadiusPx)
assertTrue(detail.showBorders)
assertNull(detail.cityMaxRank)
assertTrue(detail.showCityLabels)
assertTrue(detail.showStateLabels)
assertNull(detail.maximumBoundaryLabels)
assertTrue(detail.showGeohashGrid)
assertTrue(detail.showNeighborCells)
assertEquals(globeFrameDetail(GlobeMotionDetail.FULL), detail)
}
@Test
fun adaptiveDetail_degradesOnSlowFramesAndRecoversWithHysteresis() {
fun labelTransition_keepsCurrentOrderThenFadesRemovedPlaces() {
val retainedBefore = city("Retained", 12f, 34f)
val removed = city("Removed", 20f, 40f)
val retainedAfter = city("Retained", 12f, 34f)
val added = city("Added", 30f, 50f)
val transition = buildMapLabelTransition(
previous = listOf(retainedBefore, removed),
current = listOf(retainedAfter, added)
)
assertTrue(transition.hasChanges)
assertEquals(
GlobeMotionDetail.BALANCED,
nextGlobeMotionDetail(GlobeMotionDetail.FULL, averageFrameMillis = 24f)
listOf("Retained", "Added", "Removed"),
transition.labels.map { it.label.name }
)
assertEquals(
GlobeMotionDetail.FAST,
nextGlobeMotionDetail(GlobeMotionDetail.BALANCED, averageFrameMillis = 32f)
)
assertEquals(
GlobeMotionDetail.BALANCED,
nextGlobeMotionDetail(GlobeMotionDetail.FAST, averageFrameMillis = 17f)
)
assertEquals(
GlobeMotionDetail.FULL,
nextGlobeMotionDetail(GlobeMotionDetail.BALANCED, averageFrameMillis = 17f)
listOf(
MapLabelTransitionPhase.STABLE,
MapLabelTransitionPhase.ENTERING,
MapLabelTransitionPhase.EXITING
),
transition.labels.map { it.phase }
)
}
@Test
fun labelTransition_matchesRedecodedLabelsByContent() {
val transition = buildMapLabelTransition(
previous = listOf(city("Stable", 12f, 34f)),
current = listOf(city("Stable", 12f, 34f))
)
assertFalse(transition.hasChanges)
assertEquals(MapLabelTransitionPhase.STABLE, transition.labels.single().phase)
}
private fun city(name: String, lat: Float, lon: Float) = MapLabel(
name = name,
lat = lat,
lon = lon,
kind = MapLabelKind.CITY,
importance = 100_000L
)
}

View File

@ -0,0 +1,59 @@
package com.bitchat.android.ui.globe
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
class GlobeMapPresentationStateTest {
@Test
fun partialUpdateAfterCompleteLoadKeepsCompleteMapThroughCancellation() {
val completeData = GlobeMapData(detailZoom = 6)
val priorityOnlyData = GlobeMapData(detailZoom = 7)
var state = GlobeMapPresentationState().showComplete(
result(completeData)
)
state = state.startLoading().showPartial(result(priorityOnlyData))
assertSame(completeData, state.uiState.data)
assertTrue(state.uiState.isLoading)
state = state.cancelLoading()
assertSame(completeData, state.uiState.data)
assertFalse(state.uiState.isLoading)
}
@Test
fun failedReplacementAfterCompleteLoadKeepsCompleteMap() {
val completeData = GlobeMapData(detailZoom = 6)
val incompleteData = GlobeMapData(detailZoom = 7)
val state = GlobeMapPresentationState()
.showComplete(result(completeData))
.showComplete(result(incompleteData, failedTileCount = 1))
assertSame(completeData, state.uiState.data)
assertTrue(state.uiState.hasError)
assertSame(completeData, state.lastCompleteData)
}
@Test
fun firstLoadCanStillDisplayPriorityTilesProgressively() {
val priorityOnlyData = GlobeMapData(detailZoom = 4)
val state = GlobeMapPresentationState()
.showPartial(result(priorityOnlyData))
assertSame(priorityOnlyData, state.uiState.data)
assertTrue(state.uiState.isLoading)
}
private fun result(
data: GlobeMapData,
failedTileCount: Int = 0
): GlobeMapLoadResult = GlobeMapLoadResult(
data = data,
requestedTileCount = 5,
failedTileCount = failedTileCount
)
}

View File

@ -0,0 +1,95 @@
package com.bitchat.android.ui.globe
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class GlobeTileSelectorTest {
@Test
fun wholeGlobeRequestsAllZoomTwoTiles() {
val request = GlobeTileSelector.select(
GlobeViewport(
centerLat = 20.0,
centerLon = 170.0,
globeRadiusPx = 400f,
widthPx = 1080,
heightPx = 1920
)
)
requireNotNull(request)
assertEquals(2, request.detailZoom)
assertEquals(16, request.detailTiles.size)
assertEquals((0..3).toSet(), request.detailTiles.map { it.x }.toSet())
assertEquals((0..3).toSet(), request.detailTiles.map { it.y }.toSet())
assertTrue(request.priorityTiles.isNotEmpty())
assertTrue(request.priorityTiles.all { it in request.detailTiles })
}
@Test
fun nearFittedGlobeUsesOverviewDetailRegardlessOfDisplayDensity() {
val request = GlobeTileSelector.select(
GlobeViewport(
centerLat = 20.0,
centerLon = 0.0,
globeRadiusPx = 1_000f,
widthPx = 1_344,
heightPx = 2_992
)
)
requireNotNull(request)
assertEquals(2, request.detailZoom)
assertTrue(request.detailTiles.size <= GlobeTileSelector.MAX_VISIBLE_TILES)
}
@Test
fun zoomedGlobeKeepsVisibleRequestsBoundedAcrossAntimeridian() {
val request = GlobeTileSelector.select(
GlobeViewport(
centerLat = 35.0,
centerLon = 179.8,
globeRadiusPx = 18_000f,
widthPx = 1080,
heightPx = 1920
)
)
requireNotNull(request)
assertTrue(request.detailZoom in 2..GlobeTileSelector.MAX_TILE_ZOOM)
assertTrue(request.detailTiles.size <= GlobeTileSelector.MAX_VISIBLE_TILES)
val dimension = 1 shl request.detailZoom
assertTrue(request.detailTiles.all { it.x in 0 until dimension })
assertTrue(request.detailTiles.all { it.y in 0 until dimension })
assertTrue(request.detailTiles.any { it.x == 0 })
assertTrue(request.detailTiles.any { it.x == dimension - 1 })
}
@Test
fun tileCoordinateRoundTripIsAccurate() {
val zoom = 8
val extent = 4096
val tileX = GlobeTileSelector.longitudeToTileX(13.405, zoom)
val tileY = GlobeTileSelector.latitudeToTileY(52.52, zoom)
val dimension = 1 shl zoom
val worldX = (13.405 + 180.0) / 360.0 * dimension
val worldY = (
1.0 - kotlin.math.ln(
kotlin.math.tan(Math.toRadians(52.52)) +
1.0 / kotlin.math.cos(Math.toRadians(52.52))
) / Math.PI
) / 2.0 * dimension
val localX = ((worldX - tileX) * extent).toInt()
val localY = ((worldY - tileY) * extent).toInt()
val longitude = GlobeTileSelector.tilePointToLongitude(
tileX, localX, extent, zoom
)
val latitude = GlobeTileSelector.tilePointToLatitude(
tileY, localY, extent, zoom
)
assertEquals(13.405, longitude, 0.001)
assertEquals(52.52, latitude, 0.001)
}
}

View File

@ -0,0 +1,237 @@
package com.bitchat.android.ui.globe
import java.io.ByteArrayOutputStream
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class MvtDecoderTest {
@Test
fun decodesShortbreadGlobeLayersAndLocalizedNames() {
val tile = GlobeTileKey(2, 2, 1)
val bytes = message(
bytesField(3, oceanLayer()),
bytesField(3, boundaryLayer()),
bytesField(3, boundaryLabelLayer()),
bytesField(3, placeLabelLayer())
)
val decoded = MvtDecoder(preferredLanguage = "de").decode(bytes, tile)
assertEquals(1, decoded.oceanPolygons.size)
assertEquals(1, decoded.oceanPolygons.single().rings.size)
assertEquals(4, decoded.oceanPolygons.single().rings.single().size)
assertEquals(true, decoded.oceanPolygons.single().rings.single().isMvtExterior)
val border = decoded.borders.single()
assertEquals(2, border.adminLevel)
assertFalse(border.maritime)
assertTrue(border.disputed)
assertEquals(2, border.ring.size)
val country = decoded.boundaryLabels.single()
assertEquals("Testland", country.name)
assertEquals(MapLabelKind.COUNTRY, country.kind)
assertEquals(8_000_000L, country.importance)
val capital = decoded.placeLabels.single()
assertEquals("Prüfstadt", capital.name)
assertEquals(MapLabelKind.CAPITAL, capital.kind)
assertEquals(3_700_000L, capital.importance)
assertTrue(capital.lat in -85.1f..85.1f)
assertTrue(capital.lon in -180f..180f)
}
@Test(expected = MvtDecodingException::class)
fun rejectsOversizedTilesBeforeParsing() {
MvtDecoder().decode(
ByteArray(MvtDecoder.MAX_TILE_BYTES + 1),
GlobeTileKey(0, 0, 0)
)
}
@Test
fun skipsUnsupportedLayersBeforeParsingTheirFeatures() {
val malformedFeature = byteArrayOf(0)
val bytes = message(
bytesField(
3,
layer(
name = "streets",
keys = emptyList(),
values = emptyList(),
features = listOf(malformedFeature)
)
),
bytesField(3, oceanLayer())
)
val decoded = MvtDecoder().decode(bytes, GlobeTileKey(0, 0, 0))
assertEquals(1, decoded.oceanPolygons.size)
}
private fun oceanLayer(): ByteArray {
val geometry = intArrayOf(
command(MOVE_TO, 1),
zigZag(200),
zigZag(200),
command(LINE_TO, 3),
zigZag(3000),
zigZag(0),
zigZag(0),
zigZag(3000),
zigZag(-3000),
zigZag(0),
command(CLOSE_PATH, 1)
)
return layer(
name = "ocean",
keys = emptyList(),
values = emptyList(),
features = listOf(feature(type = 3, geometry = geometry))
)
}
private fun boundaryLayer(): ByteArray {
val keys = listOf("admin_level", "maritime", "disputed")
val values = listOf(uintValue(2), boolValue(false), boolValue(true))
val geometry = intArrayOf(
command(MOVE_TO, 1),
zigZag(400),
zigZag(1000),
command(LINE_TO, 1),
zigZag(2500),
zigZag(0)
)
return layer(
name = "boundaries",
keys = keys,
values = values,
features = listOf(
feature(
tags = intArrayOf(0, 0, 1, 1, 2, 2),
type = 2,
geometry = geometry
)
)
)
}
private fun boundaryLabelLayer(): ByteArray {
val keys = listOf("admin_level", "name", "way_area")
val values = listOf(uintValue(2), stringValue("Testland"), uintValue(8_000_000))
return layer(
name = "boundary_labels",
keys = keys,
values = values,
features = listOf(
feature(
tags = intArrayOf(0, 0, 1, 1, 2, 2),
type = 1,
geometry = pointGeometry(1900, 1800)
)
)
)
}
private fun placeLabelLayer(): ByteArray {
val keys = listOf("name", "name_de", "kind", "population")
val values = listOf(
stringValue("Test City"),
stringValue("Prüfstadt"),
stringValue("capital"),
uintValue(3_700_000)
)
return layer(
name = "place_labels",
keys = keys,
values = values,
features = listOf(
feature(
tags = intArrayOf(0, 0, 1, 1, 2, 2, 3, 3),
type = 1,
geometry = pointGeometry(2100, 2200)
)
)
)
}
private fun layer(
name: String,
keys: List<String>,
values: List<ByteArray>,
features: List<ByteArray>
): ByteArray = message(
varintField(15, 2),
bytesField(1, name.encodeToByteArray()),
*features.map { bytesField(2, it) }.toTypedArray(),
*keys.map { bytesField(3, it.encodeToByteArray()) }.toTypedArray(),
*values.map { bytesField(4, it) }.toTypedArray(),
varintField(5, EXTENT.toLong())
)
private fun feature(
tags: IntArray = intArrayOf(),
type: Int,
geometry: IntArray
): ByteArray = message(
if (tags.isEmpty()) byteArrayOf() else bytesField(2, packed(tags)),
varintField(3, type.toLong()),
bytesField(4, packed(geometry))
)
private fun pointGeometry(x: Int, y: Int): IntArray = intArrayOf(
command(MOVE_TO, 1),
zigZag(x),
zigZag(y)
)
private fun stringValue(value: String): ByteArray =
bytesField(1, value.encodeToByteArray())
private fun uintValue(value: Long): ByteArray = varintField(5, value)
private fun boolValue(value: Boolean): ByteArray =
varintField(7, if (value) 1 else 0)
private fun bytesField(number: Int, bytes: ByteArray): ByteArray =
message(varint((number shl 3 or 2).toLong()), varint(bytes.size.toLong()), bytes)
private fun varintField(number: Int, value: Long): ByteArray =
message(varint((number shl 3).toLong()), varint(value))
private fun packed(values: IntArray): ByteArray =
message(*values.map { varint(it.toLong()) }.toTypedArray())
private fun command(id: Int, count: Int): Int = count shl 3 or id
private fun zigZag(value: Int): Int = (value shl 1) xor (value shr 31)
private fun message(vararg pieces: ByteArray): ByteArray {
val output = ByteArrayOutputStream()
pieces.forEach(output::write)
return output.toByteArray()
}
private fun varint(value: Long): ByteArray {
var remaining = value
val output = ByteArrayOutputStream()
while (true) {
if ((remaining and -128L) == 0L) {
output.write(remaining.toInt())
return output.toByteArray()
}
output.write((remaining.toInt() and 0x7f) or 0x80)
remaining = remaining ushr 7
}
}
companion object {
private const val EXTENT = 4096
private const val MOVE_TO = 1
private const val LINE_TO = 2
private const val CLOSE_PATH = 7
}
}