mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-08-08 06:46:11 +00:00
Merge 033e224bc72d38823aecd1b23c31501771d9e54f into 094657efa0aabbb6f71c9050149d1d01aee96400
This commit is contained in:
commit
862f2d035d
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
@ -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
|
||||
|
||||
255
app/src/main/java/com/bitchat/android/ui/globe/GlobeMapData.kt
Normal file
255
app/src/main/java/com/bitchat/android/ui/globe/GlobeMapData.kt
Normal 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
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
|
||||
|
||||
@ -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
@ -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
|
||||
}
|
||||
}
|
||||
614
app/src/main/java/com/bitchat/android/ui/globe/MvtDecoder.kt
Normal file
614
app/src/main/java/com/bitchat/android/ui/globe/MvtDecoder.kt
Normal 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
|
||||
@ -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)"
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user