Merge pull request #823 from permissionlesstech/ui/geohash-globe-picker

feat: animated 3D globe geohash picker with borders and cities
This commit is contained in:
callebtc 2026-07-29 21:55:07 +02:00 committed by GitHub
commit 2176160ac0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1491 additions and 422 deletions

View File

@ -1,225 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
:root { --text: #333; }
html, body, #map { height: 100%; margin: 0; padding: 0; background: #ffffff; }
.leaflet-container { background: #ffffff; }
.leaflet-div-icon { background: transparent; border: none; }
.gh-label { background: transparent; border: none; pointer-events: none; filter: none; }
.gh-text {
color: #444444;
font-weight: 700;
font-size: 14px;
line-height: 1;
text-shadow: 0 0 2px #ffffff, 0 0 2px #ffffff, 0 0 2px #ffffff;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.dark .gh-text {
color: #dddddd;
text-shadow: 0 0 2px #000000, 0 0 2px #000000, 0 0 2px #000000;
}
.gh-text-selected {
color: #00C851 !important;
}
</style>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
</head>
<body>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
// Minimal geohash (bounds/encode/adjacent)
(function () {
const base32 = "0123456789bcdefghjkmnpqrstuvwxyz";
function bounds(geohash) {
let evenBit = true; let latMin = -90, latMax = 90, lonMin = -180, lonMax = 180;
geohash = geohash.toLowerCase();
for (let i = 0; i < geohash.length; i++) {
const idx = base32.indexOf(geohash.charAt(i));
if (idx == -1) throw new Error("Invalid geohash");
for (let n = 4; n >= 0; n--) {
const bitN = (idx >> n) & 1;
if (evenBit) { const lonMid = (lonMin + lonMax) / 2; if (bitN == 1) lonMin = lonMid; else lonMax = lonMid; }
else { const latMid = (latMin + latMax) / 2; if (bitN == 1) latMin = latMid; else latMax = latMid; }
evenBit = !evenBit;
}
}
return { sw: { lat: latMin, lng: lonMin }, ne: { lat: latMax, lng: lonMax } };
}
function encode(lat, lon, precision) {
let idx = 0, bit = 0, evenBit = true, hash = "";
let latMin = -90, latMax = 90, lonMin = -180, lonMax = 180;
while (hash.length < precision) {
if (evenBit) { const lonMid = (lonMin + lonMax) / 2; if (lon >= lonMid) { idx = idx * 2 + 1; lonMin = lonMid; } else { idx = idx * 2; lonMax = lonMid; } }
else { const latMid = (latMin + latMax) / 2; if (lat >= latMid) { idx = idx * 2 + 1; latMin = latMid; } else { idx = idx * 2; latMax = latMid; } }
evenBit = !evenBit; if (++bit == 5) { hash += base32.charAt(idx); bit = 0; idx = 0; }
}
return hash;
}
function adjacent(hash, dir) {
const neighbour = { n:["p0r21436x8zb9dcf5h7kjnmqesgutwvy","bc01fg45238967deuvhjyznpkmstqrwx"], s:["14365h7k9dcfesgujnmqp0r2twvyx8zb","238967debc01fg45kmstqrwxuvhjyznp"], e:["bc01fg45238967deuvhjyznpkmstqrwx","p0r21436x8zb9dcf5h7kjnmqesgutwvy"], w:["238967debc01fg45kmstqrwxuvhjyznp","14365h7k9dcfesgujnmqp0r2twvyx8zb"] };
const border = { n:["prxz","bcfguvyz"], s:["028b","0145hjnp"], e:["bcfguvyz","prxz"], w:["0145hjnp","028b"] };
hash = hash.toLowerCase(); const lastCh = hash.slice(-1); let parent = hash.slice(0, -1); const type = hash.length % 2;
if (border[dir][type].indexOf(lastCh) != -1 && parent != "") parent = adjacent(parent, dir);
return parent + base32.charAt(neighbour[dir][type].indexOf(lastCh));
}
window.__geohash = { bounds, encode, adjacent };
})();
const map = L.map("map", { zoomControl: true, minZoom: 2, maxZoom: 21 }).setView([0, 0], 3);
L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png", { maxZoom: 21, attribution: "&copy; OpenStreetMap &copy; Carto", opacity: 1.0 }).addTo(map);
let selectedGeohash = "";
let gridLayer = L.layerGroup().addTo(map);
let pinnedPrecision = null;
let outlineColor = "#00C851";
function getNeighbors(hash) {
const neighbors = [];
// N, S, E, W
neighbors.push(window.__geohash.adjacent(hash, 'n'));
neighbors.push(window.__geohash.adjacent(hash, 's'));
neighbors.push(window.__geohash.adjacent(hash, 'e'));
neighbors.push(window.__geohash.adjacent(hash, 'w'));
// Diagonals
neighbors.push(window.__geohash.adjacent(window.__geohash.adjacent(hash, 'n'), 'e'));
neighbors.push(window.__geohash.adjacent(window.__geohash.adjacent(hash, 'n'), 'w'));
neighbors.push(window.__geohash.adjacent(window.__geohash.adjacent(hash, 's'), 'e'));
neighbors.push(window.__geohash.adjacent(window.__geohash.adjacent(hash, 's'), 'w'));
return neighbors;
}
function pickPrecisionForViewport() {
const c = map.getCenter();
const minPx = 80;
const maxPx = 240;
let chosen = 1;
let lastAboveMin = 1;
for (let p = 1; p <= 12; p++) {
const gh = window.__geohash.encode(c.lat, c.lng, p);
const b = window.__geohash.bounds(gh);
const pSw = map.latLngToLayerPoint([b.sw.lat, b.sw.lng]);
const pNe = map.latLngToLayerPoint([b.ne.lat, b.ne.lng]);
const cellPx = Math.min(Math.abs(pNe.x - pSw.x), Math.abs(pSw.y - pNe.y));
if (cellPx >= minPx && cellPx <= maxPx) { chosen = p; break; }
if (cellPx >= minPx) { lastAboveMin = p; }
if (cellPx < minPx) { chosen = lastAboveMin; break; }
if (p === 12) { chosen = 12; }
}
return chosen;
}
function notifySelection() {
if (window.Android && window.Android.onGeohashChanged && selectedGeohash) {
window.Android.onGeohashChanged(selectedGeohash);
}
}
function zoomForPrecision(p) {
if (p <= 1) return 1; if (p === 2) return 2; if (p === 3) return 3; if (p === 4) return 4;
if (p === 5) return 5; if (p === 6) return 7; if (p === 7) return 9; if (p === 8) return 11;
if (p === 9) return 13; if (p === 10) return 15; if (p === 11) return 17;
return 18;
}
function updateOverlay() {
gridLayer.clearLayers();
const c = map.getCenter();
const usePinned = pinnedPrecision !== null;
const p = usePinned ? pinnedPrecision : pickPrecisionForViewport();
selectedGeohash = window.__geohash.encode(c.lat, c.lng, p);
notifySelection();
const centerBounds = window.__geohash.bounds(selectedGeohash);
const centerLon = (centerBounds.sw.lng + centerBounds.ne.lng) / 2;
const centerLat = (centerBounds.sw.lat + centerBounds.ne.lat) / 2;
const allHashes = [selectedGeohash, ...getNeighbors(selectedGeohash)];
const filteredHashes = allHashes.filter(gh => {
if (!gh) return false;
try {
const b = window.__geohash.bounds(gh);
const lon = (b.sw.lng + b.ne.lng) / 2;
const lat = (b.sw.lat + b.ne.lat) / 2;
if (Math.abs(lon - centerLon) > 180) return false; // anti-meridian wrap
if (Math.abs(lat - centerLat) > 90) return false; // pole wrap
return true;
} catch (e) { return false; }
});
filteredHashes.forEach(gh => {
const b = window.__geohash.bounds(gh);
const sw = [b.sw.lat, b.sw.lng];
const ne = [b.ne.lat, b.ne.lng];
const isSelected = (gh === selectedGeohash);
const rect = L.rectangle([sw, ne], {
color: isSelected ? outlineColor : '#cccccc',
weight: isSelected ? 3 : 1,
fillOpacity: 0.0,
opacity: 0.9,
interactive: false
});
gridLayer.addLayer(rect);
const center = [(b.sw.lat + b.ne.lat) / 2, (b.sw.lng + b.ne.lng) / 2];
const labelClass = isSelected ? 'gh-text gh-text-selected' : 'gh-text';
const label = L.marker(center, {
icon: L.divIcon({
className: 'gh-label',
html: `<span class="${labelClass}">${gh}</span>`
}),
interactive: false
});
gridLayer.addLayer(label);
});
}
map.on("movestart", () => { pinnedPrecision = null; });
map.on("zoomstart", () => { pinnedPrecision = null; });
map.on("moveend", updateOverlay);
map.on("zoomend", updateOverlay);
function setCenter(lat, lng) { map.setView([lat, lng], map.getZoom()); }
function setPrecision(p) {
const clamped = Math.max(1, Math.min(12, p|0));
const targetZoom = zoomForPrecision(clamped);
map.setZoom(targetZoom);
}
function focusGeohash(gh) {
if (!gh || typeof gh !== 'string') return;
const g = gh.toLowerCase();
const b = window.__geohash.bounds(g);
pinnedPrecision = g.length;
map.fitBounds([[b.sw.lat, b.sw.lng],[b.ne.lat, b.ne.lng]], { animate: false, padding: [8,8] });
selectedGeohash = g;
}
function getGeohash() { return selectedGeohash; }
// Android side will call this with 'dark' or 'light'
function setMapTheme(theme) {
document.body.className = theme;
}
window.setCenter = setCenter;
window.setPrecision = setPrecision;
window.focusGeohash = focusGeohash;
window.getGeohash = getGeohash;
window.setMapTheme = setMapTheme;
function cleanup() {
try { map.off(); } catch (_) {}
try { gridLayer.clearLayers(); } catch (_) {}
try { map.remove(); } catch (_) {}
}
window.cleanup = cleanup;
map.whenReady(updateOverlay);
</script>
</body>
</html>

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

@ -1,50 +1,48 @@
package com.bitchat.android.ui
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Remove
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
import android.view.ViewGroup
import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebResourceRequest
import android.webkit.WebViewClient
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
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.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.bitchat.android.ui.theme.BitchatTheme
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.res.stringResource
import com.bitchat.android.ui.theme.BitchatFontFamily
import androidx.core.content.res.ResourcesCompat
import com.bitchat.android.R
import androidx.core.view.updateLayoutParams
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.GlobeState
import com.bitchat.android.ui.globe.GlobeView
import com.bitchat.android.ui.globe.LandData
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
@OptIn(ExperimentalMaterial3Api::class)
class GeohashPickerActivity : OrientationAwareActivity() {
companion object {
@ -52,13 +50,13 @@ class GeohashPickerActivity : OrientationAwareActivity() {
const val EXTRA_RESULT_GEOHASH = "result_geohash"
}
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val initialGeohash = intent.getStringExtra(EXTRA_INITIAL_GEOHASH)?.trim()?.lowercase()
var geohashToFocus: String? = null
var (initLat, initLon) = 0.0 to 0.0
var initLat = 20.0
var initLon = 0.0
if (!initialGeohash.isNullOrEmpty()) {
geohashToFocus = initialGeohash
@ -84,201 +82,206 @@ class GeohashPickerActivity : OrientationAwareActivity() {
}
}
val initialPrecision = geohashToFocus?.length ?: 5
val initialPrecision = (geohashToFocus?.length ?: 2).coerceIn(1, 12)
val targetLat = initLat
val targetLon = initLon
setContent {
BitchatTheme {
var currentGeohash by remember { mutableStateOf(geohashToFocus ?: "") }
var precision by remember { mutableStateOf(initialPrecision.coerceIn(1, 12)) }
var webViewRef by remember { mutableStateOf<WebView?>(null) }
val context = LocalContext.current
val scope = rememberCoroutineScope()
val globeState = remember {
GlobeState(
targetLat = targetLat,
targetLon = targetLon,
initialPrecision = initialPrecision,
startZoomedOut = true
).apply {
introTarget = Triple(targetLat, targetLon, initialPrecision)
}
}
LaunchedEffect(globeState) { globeState.attach(scope) }
val land by produceState<List<LandData.Ring>?>(initialValue = null) {
value = withContext(Dispatchers.IO) { LandData.load(context) }
}
val borders by produceState<List<LandData.Ring>>(initialValue = emptyList()) {
value = withContext(Dispatchers.IO) { LandData.loadBorders(context) }
}
val cities by produceState<List<LandData.City>>(initialValue = emptyList()) {
value = withContext(Dispatchers.IO) { LandData.loadCities(context) }
}
val colorScheme = MaterialTheme.colorScheme
val standardGreen = colorScheme.primary
Scaffold { padding ->
Box(Modifier.fillMaxSize()) {
AndroidView(
factory = { context ->
WebView(context).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
settings.cacheMode = WebSettings.LOAD_DEFAULT
settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
settings.allowFileAccess = true
settings.allowContentAccess = true
webChromeClient = WebChromeClient()
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
val url = request?.url?.toString() ?: return true
// Block navigation away from the local geohash picker asset
return !url.startsWith("file:///android_asset/geohash_picker.html")
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
// Initialize to last/initial geohash if provided, otherwise center
if (!geohashToFocus.isNullOrEmpty()) {
evaluateJavascript(
"window.focusGeohash('${geohashToFocus}')",
null
)
} else {
evaluateJavascript(
"window.setCenter(${initLat}, ${initLon})",
null
)
}
// Apply theme to map tiles
val nightModeFlags = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
val theme = if (nightModeFlags == Configuration.UI_MODE_NIGHT_YES) "dark" else "light"
evaluateJavascript("window.setMapTheme('" + theme + "')", null)
}
}
addJavascriptInterface(object {
@JavascriptInterface
fun onGeohashChanged(geohash: String) {
runOnUiThread {
currentGeohash = geohash
}
}
}, "Android")
loadUrl("file:///android_asset/geohash_picker.html")
}
},
modifier = Modifier
.fillMaxSize()
.padding(padding),
update = { webView ->
webViewRef = webView
// ensure it fills parent
webView.updateLayoutParams<ViewGroup.LayoutParams> {
width = ViewGroup.LayoutParams.MATCH_PARENT
height = ViewGroup.LayoutParams.MATCH_PARENT
}
},
onRelease = { webView ->
// Best-effort cleanup to avoid leaks and timers
try { webView.evaluateJavascript("window.cleanup && window.cleanup()", null) } catch (_: Throwable) {}
try { webView.stopLoading() } catch (_: Throwable) {}
try { webView.clearHistory() } catch (_: Throwable) {}
try { webView.clearCache(true) } catch (_: Throwable) {}
try { webView.loadUrl("about:blank") } catch (_: Throwable) {}
try { webView.removeAllViews() } catch (_: Throwable) {}
try { webView.destroy() } catch (_: Throwable) {}
}
val dark = colorScheme.background.luminance() < 0.5f
val globeColors = remember(colorScheme, dark) {
if (dark) {
GlobeColors(
accent = colorScheme.primary,
land = Color(0xFF16241B),
coastline = colorScheme.primary.copy(alpha = 0.45f),
border = colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
oceanCenter = Color(0xFF0A1410),
oceanEdge = Color(0xFF020604),
atmosphere = colorScheme.primary,
graticule = colorScheme.onSurface.copy(alpha = 0.055f),
grid = colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
label = colorScheme.onSurfaceVariant,
labelHalo = colorScheme.background,
star = colorScheme.onSurface
)
} else {
GlobeColors(
accent = colorScheme.primary,
land = Color(0xFFBCD2C0),
coastline = colorScheme.primary.copy(alpha = 0.5f),
border = colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
oceanCenter = Color(0xFFEAF2EC),
oceanEdge = Color(0xFFD4E2D7),
atmosphere = colorScheme.primary,
graticule = colorScheme.onSurface.copy(alpha = 0.08f),
grid = colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
label = colorScheme.onSurfaceVariant,
labelHalo = colorScheme.background,
star = colorScheme.onSurfaceVariant
)
}
}
// Floating info pill
Surface(
val labelTypeface = remember { ResourcesCompat.getFont(context, R.font.geist_mono_medium) }
val labelTypefaceBold = remember { ResourcesCompat.getFont(context, R.font.geist_mono_semibold) }
Box(
Modifier
.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()
)
}
// Floating info pill
Surface(
modifier = Modifier
.align(Alignment.TopCenter)
.statusBarsPadding()
.padding(top = 20.dp)
.fillMaxWidth(0.8f),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
shape = RoundedCornerShape(12.dp),
tonalElevation = 3.dp,
shadowElevation = 6.dp
) {
Text(
text = stringResource(R.string.pan_zoom_instruction),
fontSize = 12.sp,
textAlign = TextAlign.Center,
fontFamily = BitchatFontFamily,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 20.dp)
.fillMaxWidth(0.75f),
.padding(horizontal = 14.dp, vertical = 10.dp)
)
}
// Floating bottom controls
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(bottom = 20.dp, start = 16.dp, end = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Geohash label (monospace, app style)
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
shape = RoundedCornerShape(12.dp),
tonalElevation = 3.dp,
shadowElevation = 6.dp
) {
Text(
text = stringResource(R.string.pan_zoom_instruction),
fontSize = 12.sp,
textAlign = TextAlign.Center,
fontFamily = BitchatFontFamily,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.padding(horizontal = 14.dp, vertical = 10.dp)
)
}
// Floating bottom controls
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 20.dp, start = 16.dp, end = 16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Geohash label (monospace, app style)
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
shape = RoundedCornerShape(12.dp),
tonalElevation = 3.dp,
shadowElevation = 6.dp
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text(
text = if (currentGeohash.isNotEmpty()) "#${currentGeohash}" else "select location",
text = if (globeState.selectedGeohash.isNotEmpty()) "#${globeState.selectedGeohash}" else "select location",
fontSize = BASE_FONT_SIZE.sp,
fontFamily = BitchatFontFamily,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.padding(horizontal = 14.dp, vertical = 10.dp)
color = MaterialTheme.colorScheme.onSurface
)
if (globeState.selectedGeohash.isNotEmpty()) {
Text(
text = "${levelForLength(globeState.precision).displayName}${coverageString(globeState.precision)}",
fontSize = (BASE_FONT_SIZE - 4).sp,
fontFamily = BitchatFontFamily,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
// Button row
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Decrease precision
Button(
onClick = { globeState.animatePrecision(globeState.precision - 1) },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
contentColor = MaterialTheme.colorScheme.primary
)
) {
Icon(Icons.Filled.Remove, contentDescription = stringResource(R.string.cd_decrease_precision))
}
// Button row
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically
// Increase precision
Button(
onClick = { globeState.animatePrecision(globeState.precision + 1) },
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f),
contentColor = MaterialTheme.colorScheme.primary
)
) {
// Decrease precision
Button(
onClick = {
precision = (precision - 1).coerceAtLeast(1)
webViewRef?.evaluateJavascript("window.setPrecision($precision)", null)
},
colors = ButtonDefaults.buttonColors(
containerColor = standardGreen.copy(alpha = 0.12f),
contentColor = standardGreen
)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Remove, contentDescription = stringResource(R.string.cd_decrease_precision))
}
}
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.cd_increase_precision))
}
// Increase precision
Button(
onClick = {
precision = (precision + 1).coerceAtMost(12)
webViewRef?.evaluateJavascript("window.setPrecision($precision)", null)
},
colors = ButtonDefaults.buttonColors(
containerColor = standardGreen.copy(alpha = 0.12f),
contentColor = standardGreen
)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.cd_increase_precision))
// Select button
Button(
onClick = {
val gh = globeState.selectedGeohash
if (gh.isNotEmpty()) {
val result = Intent().apply { putExtra(EXTRA_RESULT_GEOHASH, gh) }
setResult(Activity.RESULT_OK, result)
finish()
}
}
// Select button
Button(
onClick = {
webViewRef?.evaluateJavascript("window.getGeohash()") { value ->
val gh = value?.trim('"') ?: currentGeohash
val result = Intent().apply { putExtra(EXTRA_RESULT_GEOHASH, gh) }
setResult(Activity.RESULT_OK, result)
finish()
}
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
contentColor = MaterialTheme.colorScheme.onSurface
)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.cd_select_geohash))
Spacer(Modifier.width(6.dp))
Text(
text = stringResource(R.string.select),
fontSize = (BASE_FONT_SIZE - 2).sp,
fontFamily = BitchatFontFamily
)
}
}
},
enabled = globeState.selectedGeohash.isNotEmpty(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f),
contentColor = MaterialTheme.colorScheme.onSurface
)
) {
Icon(Icons.Filled.Check, contentDescription = stringResource(R.string.cd_select_geohash))
Spacer(Modifier.width(6.dp))
Text(
text = stringResource(R.string.select),
fontSize = (BASE_FONT_SIZE - 2).sp,
fontFamily = BitchatFontFamily
)
}
}
}
@ -286,4 +289,36 @@ class GeohashPickerActivity : OrientationAwareActivity() {
}
}
}
private fun levelForLength(length: Int): GeohashChannelLevel {
return when (length) {
in 0..2 -> GeohashChannelLevel.REGION
in 3..4 -> GeohashChannelLevel.PROVINCE
5 -> GeohashChannelLevel.CITY
6 -> GeohashChannelLevel.NEIGHBORHOOD
7 -> GeohashChannelLevel.BLOCK
else -> GeohashChannelLevel.BUILDING
}
}
private fun coverageString(precision: Int): String {
val maxMeters = when (precision) {
2 -> 1_250_000.0
3 -> 156_000.0
4 -> 39_100.0
5 -> 4_890.0
6 -> 1_220.0
7 -> 153.0
8 -> 38.2
9 -> 4.77
10 -> 1.19
else -> if (precision <= 1) 5_000_000.0 else 1.19 * Math.pow(0.25, (precision - 10).toDouble())
}
val km = maxMeters / 1000.0
return when {
km >= 100 -> "~${String.format(java.util.Locale.US, "%.0f", km)} km"
km >= 1 -> "~${String.format(java.util.Locale.US, "%.1f", km)} km"
else -> "~${String.format(java.util.Locale.US, "%.0f", maxMeters)} m"
}
}
}

View File

@ -0,0 +1,100 @@
package com.bitchat.android.ui.globe
import kotlin.math.*
/**
* Orthographic globe projection and geohash zoom math for the 3D globe picker.
*
* The globe is rendered as a disc of radius R centered on screen. Points are
* projected with an orthographic projection around a view center lat/lon.
*/
object GlobeMath {
data class Projection(val x: Float, val y: Float, val cosC: Float)
/**
* Projects (lat, lon) onto the view disc of a globe centered at (centerLat, centerLon).
* Returns x/y in units of globe radius (screen y down). [Projection.cosC] is negative
* when the point is on the far side of the sphere.
*/
fun projectRaw(latDeg: Double, lonDeg: Double, centerLatDeg: Double, centerLonDeg: Double): Projection {
val phi = Math.toRadians(latDeg)
val phi0 = Math.toRadians(centerLatDeg)
val dLambda = Math.toRadians(normalizeLon(lonDeg - centerLonDeg))
val cosC = sin(phi0) * sin(phi) + cos(phi0) * cos(phi) * cos(dLambda)
val x = cos(phi) * sin(dLambda)
val y = -(cos(phi0) * sin(phi) - sin(phi0) * cos(phi) * cos(dLambda))
return Projection(x.toFloat(), y.toFloat(), cosC.toFloat())
}
/** Like [projectRaw] but null when the point is behind the limb. */
fun project(latDeg: Double, lonDeg: Double, centerLatDeg: Double, centerLonDeg: Double): Projection? {
val p = projectRaw(latDeg, lonDeg, centerLatDeg, centerLonDeg)
return if (p.cosC >= 0f) p else null
}
/**
* Inverse projection: disc coordinates (units of radius, screen y down) back to lat/lon.
* Returns null if the point lies outside the disc.
*/
fun unproject(x: Double, y: Double, centerLatDeg: Double, centerLonDeg: Double): Pair<Double, Double>? {
val rho = sqrt(x * x + y * y)
if (rho > 1.0) return null
val c = asin(rho.coerceIn(-1.0, 1.0))
val phi0 = Math.toRadians(centerLatDeg)
val sinC = sin(c)
val cosC = cos(c)
val lat: Double
val lonOffset: Double
if (rho < 1e-9) {
lat = centerLatDeg
lonOffset = 0.0
} else {
lat = Math.toDegrees(asin(cosC * sin(phi0) + (-y) * sinC * cos(phi0) / rho))
lonOffset = Math.toDegrees(atan2(x * sinC, rho * cos(phi0) * cosC - (-y) * sin(phi0) * sinC))
}
return lat to normalizeLon(centerLonDeg + lonOffset)
}
fun normalizeLon(lon: Double): Double {
var x = lon % 360.0
if (x > 180.0) x -= 360.0
if (x < -180.0) x += 360.0
return x
}
/** Longitude span of a geohash cell in degrees. */
fun cellSpanLon(precision: Int): Double = 360.0 / 2.0.pow(ceil(5.0 * precision / 2.0))
/** Latitude span of a geohash cell in degrees. */
fun cellSpanLat(precision: Int): Double = 180.0 / 2.0.pow(floor(5.0 * precision / 2.0))
/**
* Picks the geohash precision whose cells render at a comfortable on-screen size
* for the current zoom: the largest precision whose cell is at least ~22% of the
* screen's smallest dimension.
*/
fun autoPrecision(globeRadiusPx: Float, screenMinPx: Float): Int {
val targetPx = screenMinPx * 0.22f
var best = 1
for (p in 1..MAX_PRECISION) {
val spanPx = (cellSpanLat(p) * (Math.PI / 180.0) * globeRadiusPx).toFloat()
if (spanPx >= targetPx) best = p else break
}
return best.coerceIn(1, MAX_PRECISION)
}
/**
* Zoom factor (globe radius multiplier over the fit-to-screen base radius) that frames
* a cell of the given precision nicely: the cell spans ~1/3 of the screen.
*/
fun zoomForPrecision(precision: Int, baseRadiusPx: Float, screenMinPx: Float): Float {
val spanRad = cellSpanLat(precision) * (Math.PI / 180.0)
val targetRadius = (screenMinPx / 3.0) / spanRad
return (targetRadius / baseRadiusPx).toFloat().coerceIn(MIN_ZOOM, MAX_ZOOM)
}
const val MIN_ZOOM = 1f
const val MAX_ZOOM = 120000f
const val MAX_PRECISION = 12
}

View File

@ -0,0 +1,185 @@
package com.bitchat.android.ui.globe
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.bitchat.android.geohash.Geohash
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.pow
/**
* Hoisted state for the interactive 3D globe: view center, zoom, geohash precision
* and the current selection. All mutation funnels through this class so rendering,
* gestures and buttons stay in sync.
*/
class GlobeState(
targetLat: Double,
targetLon: Double,
initialPrecision: Int,
startZoomedOut: Boolean
) {
var centerLat by mutableFloatStateOf(if (startZoomedOut) (targetLat * 0.4).toFloat() else targetLat.toFloat())
private set
var centerLon by mutableFloatStateOf(if (startZoomedOut) GlobeMath.normalizeLon(targetLon - 70.0).toFloat() else targetLon.toFloat())
private set
var zoom by mutableFloatStateOf(if (startZoomedOut) GlobeMath.MIN_ZOOM else 1f)
private set
var precision by mutableIntStateOf(initialPrecision.coerceIn(1, GlobeMath.MAX_PRECISION))
private set
var selectedGeohash by mutableStateOf("")
private set
var isInteracting by mutableStateOf(false)
internal set
internal var baseRadiusPx by mutableFloatStateOf(0f)
internal var screenMinPx by mutableFloatStateOf(0f)
private var scope: CoroutineScope? = null
private var animJob: Job? = null
/** Pending cinematic intro target (lat, lon, precision); consumed when played. */
var introTarget: Triple<Double, Double, Int>? = null
private var introPlayed = false
fun playPendingIntroIfAny() {
if (introPlayed) return
val target = introTarget ?: return
introPlayed = true
introTarget = null
playIntro(target.first, target.second, target.third)
}
fun attach(scope: CoroutineScope) {
this.scope = scope
}
fun setViewport(baseRadiusPx: Float, screenMinPx: Float) {
if (baseRadiusPx <= 0f || screenMinPx <= 0f) return
this.baseRadiusPx = baseRadiusPx
this.screenMinPx = screenMinPx
syncSelection()
}
val globeRadiusPx: Float get() = baseRadiusPx * zoom
/** Direct rotation from drag gestures. Deltas are in screen px. */
fun rotateBy(dxPx: Float, dyPx: Float) {
val r = globeRadiusPx
if (r <= 0f) return
val degPerPx = 180.0 / (Math.PI * r)
centerLon = GlobeMath.normalizeLon(centerLon - dxPx * degPerPx).toFloat()
centerLat = (centerLat + dyPx * degPerPx).toFloat().coerceIn(MIN_LAT, MAX_LAT)
syncSelection()
}
/** Continuous zoom from pinch gestures; precision follows automatically. */
fun zoomBy(factor: Float) {
if (factor == 1f) return
zoom = (zoom * factor).coerceIn(GlobeMath.MIN_ZOOM, GlobeMath.MAX_ZOOM)
syncPrecisionFromZoom()
syncSelection()
}
private fun syncPrecisionFromZoom() {
if (baseRadiusPx <= 0f) return
precision = GlobeMath.autoPrecision(globeRadiusPx, screenMinPx)
}
/** Animate the view to a lat/lon, optionally to a zoom and precision. */
fun animateTo(
lat: Double,
lon: Double,
targetZoom: Float? = null,
targetPrecision: Int? = null,
durationMs: Int = 550
) {
val s = scope ?: return
animJob?.cancel()
val startLat = centerLat
val startLon = centerLon
val dLon = GlobeMath.normalizeLon(lon - startLon)
val startZoom = zoom
val endZoom = (targetZoom ?: zoom).coerceIn(GlobeMath.MIN_ZOOM, GlobeMath.MAX_ZOOM)
animJob = s.launch {
val anim = Animatable(0f)
anim.animateTo(1f, tween(durationMs, easing = FastOutSlowInEasing)) {
val t = value
centerLat = (startLat + (lat.toFloat() - startLat) * t).coerceIn(MIN_LAT, MAX_LAT)
centerLon = GlobeMath.normalizeLon(startLon + dLon * t).toFloat()
// exponential interpolation feels natural for zoom
zoom = startZoom * (endZoom / startZoom).pow(t)
if (targetPrecision != null) {
precision = targetPrecision.coerceIn(1, GlobeMath.MAX_PRECISION)
} else {
syncPrecisionFromZoom()
}
syncSelection()
}
}
}
/** Button-driven precision change: adjusts precision and animates zoom to frame it. */
fun animatePrecision(newPrecision: Int) {
val p = newPrecision.coerceIn(1, GlobeMath.MAX_PRECISION)
if (p == precision || baseRadiusPx <= 0f) return
val targetZoom = GlobeMath.zoomForPrecision(p, baseRadiusPx, screenMinPx)
animateTo(centerLat.toDouble(), centerLon.toDouble(), targetZoom, p, durationMs = 450)
}
/** Cinematic intro: spin and zoom from a far view into the target location. */
private fun playIntro(targetLat: Double, targetLon: Double, targetPrecision: Int) {
if (baseRadiusPx <= 0f) {
// viewport not ready yet; retry once attached to layout via caller
return
}
val targetZoom = GlobeMath.zoomForPrecision(targetPrecision, baseRadiusPx, screenMinPx)
animateTo(targetLat, targetLon, targetZoom, targetPrecision, durationMs = 1400)
}
/** Inertial spin after a fling. Velocities are in px/ms. */
fun fling(velocityX: Float, velocityY: Float) {
val s = scope ?: return
if (abs(velocityX) < 0.05f && abs(velocityY) < 0.05f) return
animJob?.cancel()
animJob = s.launch {
var vx = velocityX
var vy = velocityY
var lastTime = System.nanoTime()
while (abs(vx) > 0.02f || abs(vy) > 0.02f) {
val now = System.nanoTime()
val dtMs = ((now - lastTime) / 1_000_000f).coerceAtMost(50f)
lastTime = now
rotateBy(vx * dtMs, vy * dtMs)
val decay = 0.94f.pow(dtMs / 16f)
vx *= decay
vy *= decay
kotlinx.coroutines.delay(16)
}
}
}
fun cancelAnimations() {
animJob?.cancel()
}
private companion object {
// Close to the projection limit so polar geohash cells remain selectable;
// clamping tighter would make syncSelection() encode the wrong cell.
const val MIN_LAT = -89f
const val MAX_LAT = 89f
}
private fun syncSelection() {
if (baseRadiusPx <= 0f) return
val gh = Geohash.encode(centerLat.toDouble(), centerLon.toDouble(), precision)
if (gh != selectedGeohash) selectedGeohash = gh
}
}

View File

@ -0,0 +1,841 @@
package com.bitchat.android.ui.globe
import android.graphics.Paint
import android.graphics.Typeface
import android.os.SystemClock
import android.view.HapticFeedbackConstants
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.calculatePan
import androidx.compose.foundation.gestures.calculateZoom
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.sp
import com.bitchat.android.geohash.Geohash
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.first
import kotlin.math.ceil
import kotlin.math.min
import kotlin.math.sqrt
/** Color palette for the globe, derived from the app theme by the caller. */
data class GlobeColors(
val accent: Color,
val land: Color,
val coastline: Color,
val border: Color,
val oceanCenter: Color,
val oceanEdge: Color,
val atmosphere: Color,
val graticule: Color,
val grid: Color,
val label: Color,
val labelHalo: Color,
val star: Color
)
private class Star(val x: Float, val y: Float, val radius: Float, val alpha: Float)
@Composable
fun GlobeView(
state: GlobeState,
colors: GlobeColors,
land: List<LandData.Ring>,
borders: List<LandData.Ring>,
cities: List<LandData.City>,
labelTypeface: Typeface?,
labelTypefaceBold: Typeface?,
modifier: Modifier = Modifier
) {
val view = LocalView.current
val density = LocalDensity.current
val stars = remember {
val rnd = kotlin.random.Random(42)
List(160) {
Star(
x = rnd.nextFloat(),
y = rnd.nextFloat(),
radius = 0.6f + rnd.nextFloat() * 1.5f,
alpha = 0.15f + rnd.nextFloat() * 0.55f
)
}
}
val maxRingPoints = remember(land) { land.maxOfOrNull { it.size } ?: 0 }
val scratch = remember(maxRingPoints) { FloatArray(maxOf(1, maxRingPoints) * 3) }
val maxBorderPoints = remember(borders) { borders.maxOfOrNull { it.size } ?: 0 }
val borderScratch = remember(maxBorderPoints) { FloatArray(maxOf(1, maxBorderPoints) * 3) }
val labelPaint = remember {
Paint(Paint.ANTI_ALIAS_FLAG).apply { textAlign = Paint.Align.CENTER }
}
val haloPaint = remember {
Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
style = Paint.Style.STROKE
}
}
val pulse by rememberInfiniteTransition(label = "crosshair").animateFloat(
initialValue = 0.45f,
targetValue = 1f,
animationSpec = infiniteRepeatable(tween(1100), RepeatMode.Reverse),
label = "crosshairAlpha"
)
LaunchedEffect(state) {
// Fire intro once the viewport size is known.
snapshotFlow { state.baseRadiusPx }.first { it > 0f }
state.playPendingIntroIfAny()
}
LaunchedEffect(state) {
snapshotFlow { state.selectedGeohash }
.drop(1)
.collect {
view.performHapticFeedback(
HapticFeedbackConstants.KEYBOARD_TAP,
HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING
)
}
}
val labelTextSize = with(density) { 12.5.sp.toPx() }
val labelTextSizeSmall = with(density) { 10.sp.toPx() }
Canvas(
modifier = modifier
.onSizeChanged { size: IntSize ->
val minDim = min(size.width, size.height).toFloat()
state.setViewport(minDim * 0.44f, minDim)
}
.pointerInput(state) {
var lastTapTime = 0L
var lastTapPos = Offset.Zero
awaitEachGesture {
val down = awaitFirstDown()
state.cancelAnimations()
state.isInteracting = true
val downTime = SystemClock.uptimeMillis()
val downPos = down.position
var maxPointers = 1
var moved = Offset.Zero
val panTimes = ArrayDeque<Long>()
val panVec = ArrayDeque<Offset>()
while (true) {
val event = awaitPointerEvent()
val pressed = event.changes.filter { it.pressed }
if (pressed.isEmpty()) break
maxPointers = maxOf(maxPointers, pressed.size)
val pan = event.calculatePan()
val zoomChange = event.calculateZoom()
if (pan != Offset.Zero) {
state.rotateBy(pan.x, pan.y)
moved += pan
val now = SystemClock.uptimeMillis()
panTimes.addLast(now)
panVec.addLast(pan)
while (panTimes.isNotEmpty() && now - panTimes.first() > 120) {
panTimes.removeFirst()
panVec.removeFirst()
}
}
if (zoomChange != 1f) {
state.zoomBy(zoomChange)
}
event.changes.forEach { if (it.positionChanged()) it.consume() }
}
state.isInteracting = false
val upTime = SystemClock.uptimeMillis()
val isTap = maxPointers == 1 &&
upTime - downTime < 400 &&
moved.getDistance() < viewConfiguration.touchSlop
if (isTap) {
val cx = size.width / 2f
val cy = size.height / 2f
val r = state.globeRadiusPx
if (r > 0f) {
val latLon = GlobeMath.unproject(
((downPos.x - cx) / r).toDouble(),
((downPos.y - cy) / r).toDouble(),
state.centerLat.toDouble(),
state.centerLon.toDouble()
)
if (latLon != null) {
val now = upTime
val lastTap = lastTapTime
val isDouble = now - lastTap < 350 &&
(downPos - lastTapPos).getDistance() < viewConfiguration.touchSlop * 4
lastTapTime = now
lastTapPos = downPos
if (isDouble) {
val targetZoom = (state.zoom * 1.9f).coerceAtMost(GlobeMath.MAX_ZOOM)
state.animateTo(latLon.first, latLon.second, targetZoom, null, 500)
} else {
state.animateTo(latLon.first, latLon.second, null, null, 450)
}
}
}
} else if (panVec.isNotEmpty()) {
var sx = 0f; var sy = 0f
panVec.forEach { sx += it.x; sy += it.y }
val windowMs = (SystemClock.uptimeMillis() - panTimes.first()).coerceAtLeast(1)
state.fling(sx / windowMs, sy / windowMs)
}
}
}
) {
val cx = size.width / 2f
val cy = size.height / 2f
val baseR = state.baseRadiusPx
if (baseR <= 0f) return@Canvas
val r = state.globeRadiusPx
val cLat = state.centerLat.toDouble()
val cLon = state.centerLon.toDouble()
// Starfield
stars.forEach { s ->
drawCircle(
color = colors.star.copy(alpha = s.alpha),
radius = s.radius * density.density,
center = Offset(s.x * size.width, s.y * size.height)
)
}
// Atmosphere glow
drawCircle(
brush = Brush.radialGradient(
0f to colors.atmosphere.copy(alpha = 0.30f),
0.75f to colors.atmosphere.copy(alpha = 0.12f),
1f to Color.Transparent,
center = Offset(cx, cy),
radius = r * 1.22f
),
radius = r * 1.22f,
center = Offset(cx, cy)
)
// Ocean sphere
drawCircle(
brush = Brush.radialGradient(
0f to colors.oceanCenter,
0.7f to colors.oceanCenter,
1f to colors.oceanEdge,
center = Offset(cx - r * 0.32f, cy - r * 0.38f),
radius = r * 1.5f
),
radius = r,
center = Offset(cx, cy)
)
val clip = ClipRect(-size.width, -size.height, size.width * 2f, size.height * 2f)
// Graticule
drawGraticule(cx, cy, r, cLat, cLon, colors.graticule, clip)
// Landmasses
for (ring in land) {
drawLandRing(ring, scratch, cx, cy, r, cLat, cLon, colors, clip)
}
// Country borders
for (line in borders) {
drawBorderLine(line, borderScratch, cx, cy, r, cLat, cLon, colors, clip)
}
// Sphere shading: dark limb + night side for 3D depth
drawCircle(
brush = Brush.radialGradient(
0f to Color.Transparent,
0.62f to Color.Transparent,
0.88f to Color.Black.copy(alpha = 0.34f),
1f to Color.Black.copy(alpha = 0.72f),
center = Offset(cx - r * 0.25f, cy - r * 0.3f),
radius = r * 1.35f
),
radius = r + 1,
center = Offset(cx, cy)
)
drawCircle(
brush = Brush.linearGradient(
0f to Color.Transparent,
0.55f to Color.Transparent,
1f to Color.Black.copy(alpha = 0.42f),
start = Offset(cx - r * 0.7f, cy - r * 0.7f),
end = Offset(cx + r * 0.75f, cy + r * 0.8f)
),
radius = r + 1,
center = Offset(cx, cy)
)
// Cities (dots + names) over the shaded sphere
drawCities(
cities, state, cx, cy, r, cLat, cLon, colors,
labelPaint, haloPaint, labelTypeface, labelTextSizeSmall, density.density
)
// Geohash cells
if (state.selectedGeohash.isNotEmpty()) {
drawGeohashGrid(state, cx, cy, r, cLat, cLon, colors, clip)
}
// Labels
if (state.selectedGeohash.isNotEmpty()) {
drawGeohashLabels(
state, cx, cy, r, cLat, cLon, colors,
labelPaint, haloPaint, labelTypeface, labelTypefaceBold,
labelTextSize, labelTextSizeSmall
)
}
// Center crosshair
val crossAlpha = if (state.isInteracting) 0.9f else pulse
val crossColor = colors.accent.copy(alpha = crossAlpha)
val gap = 5 * density.density
val len = 9 * density.density
val strokeW = 1.6f * density.density
drawLine(crossColor, Offset(cx - gap - len, cy), Offset(cx - gap, cy), strokeW)
drawLine(crossColor, Offset(cx + gap, cy), Offset(cx + gap + len, cy), strokeW)
drawLine(crossColor, Offset(cx, cy - gap - len), Offset(cx, cy - gap), strokeW)
drawLine(crossColor, Offset(cx, cy + gap), Offset(cx, cy + gap + len), strokeW)
drawCircle(crossColor, radius = 1.8f * density.density, center = Offset(cx, cy))
}
}
private fun DrawScope.drawGraticule(
cx: Float, cy: Float, r: Float, cLat: Double, cLon: Double, color: Color, clip: ClipRect
) {
val path = Path()
val step = 4.0
fun strokeSegment(x0: Float, y0: Float, x1: Float, y1: Float) {
val seg = clipSegment(x0, y0, x1, y1, clip) ?: return
path.moveTo(seg.first.first, seg.first.second)
path.lineTo(seg.second.first, seg.second.second)
}
// latitude lines
var lat = -75.0
while (lat <= 75.0) {
var prev: GlobeMath.Projection? = null
var lon = -180.0
while (lon <= 180.0) {
val p = GlobeMath.project(lat, lon, cLat, cLon)
if (p != null && p.cosC > 0.02f) {
val pp = prev
if (pp != null) strokeSegment(cx + pp.x * r, cy + pp.y * r, cx + p.x * r, cy + p.y * r)
prev = p
} else prev = null
lon += step
}
lat += 15.0
}
// longitude lines
var lon = -180.0
while (lon < 180.0) {
var prev: GlobeMath.Projection? = null
var la = -90.0
while (la <= 90.0) {
val p = GlobeMath.project(la, lon, cLat, cLon)
if (p != null && p.cosC > 0.02f) {
val pp = prev
if (pp != null) strokeSegment(cx + pp.x * r, cy + pp.y * r, cx + p.x * r, cy + p.y * r)
prev = p
} else prev = null
la += step
}
lon += 15.0
}
drawPath(path, color, style = Stroke(width = 1f))
}
private data class DiscPt(val x: Float, val y: Float, val front: Boolean)
private fun limbPoint(behind: DiscPt, front: DiscPt): Pair<Float, Float> {
var lo = 0f; var hi = 1f
repeat(14) {
val t = (lo + hi) / 2f
val x = behind.x + (front.x - behind.x) * t
val y = behind.y + (front.y - behind.y) * t
if (x * x + y * y < 1f) lo = t else hi = t
}
val t = (lo + hi) / 2f
return (behind.x + (front.x - behind.x) * t) to (behind.y + (front.y - behind.y) * t)
}
/**
* Splits a projected polygon into front-facing runs. Runs that touch the limb are
* padded with the horizon intersection point so they can be closed along the horizon.
* Pass [closed] = false for open polylines (border lines).
*/
private fun buildFrontRuns(pts: List<DiscPt>, closed: Boolean = true): List<MutableList<Pair<Float, Float>>> {
if (pts.isEmpty()) return emptyList()
val n = pts.size
val runs = mutableListOf<MutableList<Pair<Float, Float>>>()
var run = mutableListOf<Pair<Float, Float>>()
var prev: DiscPt? = if (closed) pts[n - 1] else null
for (i in 0 until n) {
val cur = pts[i]
val p = prev
if (cur.front) {
if (run.isEmpty() && p != null && !p.front) run.add(limbPoint(p, cur))
// Antimeridian wrap: consecutive front points can jump across the whole
// disc when a polygon crosses ±180° — break the run instead of drawing
// a chord through the view.
if (run.isNotEmpty()) {
val last = run.last()
val dx = cur.x - last.first
val dy = cur.y - last.second
if (dx * dx + dy * dy > 1.2f) {
runs.add(run)
run = mutableListOf()
}
}
run.add(cur.x to cur.y)
} else {
if (run.isNotEmpty() && p != null) {
run.add(limbPoint(p, cur))
runs.add(run)
run = mutableListOf()
}
}
prev = cur
}
if (run.isNotEmpty()) {
if (closed && runs.isNotEmpty() && pts[0].front && pts[n - 1].front) {
runs[0] = (run + runs[0]).toMutableList()
} else {
runs.add(run)
}
}
return runs
}
// Skia's edge rasterizer loses precision with path coordinates beyond ~32767px,
// which happens at high globe zoom. All geometry is clipped to an expanded
// viewport rect in screen space before being handed to a Path.
private class ClipRect(val left: Float, val top: Float, val right: Float, val bottom: Float)
private fun clipPolygon(pts: List<Pair<Float, Float>>, rect: ClipRect): List<Pair<Float, Float>> {
fun clipEdge(
input: List<Pair<Float, Float>>,
inside: (Pair<Float, Float>) -> Boolean,
intersect: (Pair<Float, Float>, Pair<Float, Float>) -> Pair<Float, Float>
): List<Pair<Float, Float>> {
if (input.isEmpty()) return input
val result = mutableListOf<Pair<Float, Float>>()
var s = input.last()
for (e in input) {
val eIn = inside(e)
val sIn = inside(s)
if (eIn) {
if (!sIn) result.add(intersect(s, e))
result.add(e)
} else if (sIn) {
result.add(intersect(s, e))
}
s = e
}
return result
}
var out = pts
out = clipEdge(out, { it.first >= rect.left }) { a, b ->
val t = (rect.left - a.first) / (b.first - a.first)
rect.left to (a.second + t * (b.second - a.second))
}
out = clipEdge(out, { it.first <= rect.right }) { a, b ->
val t = (rect.right - a.first) / (b.first - a.first)
rect.right to (a.second + t * (b.second - a.second))
}
out = clipEdge(out, { it.second >= rect.top }) { a, b ->
val t = (rect.top - a.second) / (b.second - a.second)
(a.first + t * (b.first - a.first)) to rect.top
}
out = clipEdge(out, { it.second <= rect.bottom }) { a, b ->
val t = (rect.bottom - a.second) / (b.second - a.second)
(a.first + t * (b.first - a.first)) to rect.bottom
}
return out
}
/** LiangBarsky clip of a segment to the rect; returns clipped endpoints or null. */
private fun clipSegment(
x0: Float, y0: Float, x1: Float, y1: Float, rect: ClipRect
): Pair<Pair<Float, Float>, Pair<Float, Float>>? {
val dx = x1 - x0
val dy = y1 - y0
var u1 = 0f
var u2 = 1f
fun test(p: Float, q: Float): Boolean {
if (p == 0f) return q >= 0f
val r = q / p
if (p < 0f) {
if (r > u2) return false
if (r > u1) u1 = r
} else {
if (r < u1) return false
if (r < u2) u2 = r
}
return true
}
if (!test(-dx, x0 - rect.left)) return null
if (!test(dx, rect.right - x0)) return null
if (!test(-dy, y0 - rect.top)) return null
if (!test(dy, rect.bottom - y0)) return null
return ((x0 + u1 * dx) to (y0 + u1 * dy)) to ((x0 + u2 * dx) to (y0 + u2 * dy))
}
/**
* Builds a fill polygon for a projected ring by clamping back-facing points onto the
* limb and inserting horizon arc steps between consecutive limb points, so the result
* approximates (polygon visible disc) without self-intersecting chords.
*/
private fun buildFillPolygon(
pts: List<DiscPt>,
cx: Float, cy: Float, r: Float
): List<Pair<Float, Float>> {
val out = ArrayList<Pair<Float, Float>>(pts.size + 128)
var prevLimbAngle: Float? = null
var firstLimbAngle: Float? = null
fun addArc(from: Float, to: Float) {
var d = to - from
while (d > Math.PI) d -= (2 * Math.PI).toFloat()
while (d < -Math.PI) d += (2 * Math.PI).toFloat()
val steps = (kotlin.math.abs(d) / 0.04f).toInt().coerceIn(1, 64)
for (s in 1 until steps) {
val a = from + d * s / steps
out.add((cx + kotlin.math.cos(a) * r) to (cy + kotlin.math.sin(a) * r))
}
}
for (p in pts) {
if (p.front) {
out.add((cx + p.x * r) to (cy + p.y * r))
prevLimbAngle = null
} else {
val len = kotlin.math.sqrt(p.x * p.x + p.y * p.y)
val lx: Float; val ly: Float
if (len > 1e-6f) { lx = p.x / len; ly = p.y / len } else { lx = 0f; ly = -1f }
val ang = kotlin.math.atan2(ly, lx)
prevLimbAngle?.let { addArc(it, ang) }
if (firstLimbAngle == null) firstLimbAngle = ang
out.add((cx + lx * r) to (cy + ly * r))
prevLimbAngle = ang
}
}
// wrap-around arc if the ring ends and starts on the limb
val lastAng = prevLimbAngle
val firstAng = firstLimbAngle
if (lastAng != null && firstAng != null && pts.isNotEmpty() && !pts[0].front) {
addArc(lastAng, firstAng)
}
return out
}
private fun DrawScope.fillPolygonClipped(
pts: List<DiscPt>,
cx: Float, cy: Float, r: Float,
color: Color,
clip: ClipRect
) {
if (pts.none { it.front }) return
val poly = buildFillPolygon(pts, cx, cy, r)
if (poly.size < 3) return
val clipped = clipPolygon(poly, clip)
if (clipped.size < 3) return
val path = Path()
path.moveTo(clipped[0].first, clipped[0].second)
for (k in 1 until clipped.size) {
path.lineTo(clipped[k].first, clipped[k].second)
}
path.close()
drawPath(path, color)
}
private fun DrawScope.strokeRuns(
runs: List<MutableList<Pair<Float, Float>>>,
cx: Float, cy: Float, r: Float,
color: Color,
width: Float,
clip: ClipRect
) {
for (run in runs) {
if (run.size < 2) continue
val path = Path()
for (k in 1 until run.size) {
val seg = clipSegment(
cx + run[k - 1].first * r, cy + run[k - 1].second * r,
cx + run[k].first * r, cy + run[k].second * r,
clip
) ?: continue
path.moveTo(seg.first.first, seg.first.second)
path.lineTo(seg.second.first, seg.second.second)
}
drawPath(path, color, style = Stroke(width = width))
}
}
private fun DrawScope.drawBorderLine(
line: LandData.Ring,
scratch: FloatArray,
cx: Float, cy: Float, r: Float,
cLat: Double, cLon: Double,
colors: GlobeColors,
clip: ClipRect
) {
val n = line.size
if (n < 2 || n * 3 > scratch.size) return
var anyFront = false
val pts = ArrayList<DiscPt>(n)
var i = 0
while (i < n) {
val lat = line.coords[i * 2].toDouble()
val lon = line.coords[i * 2 + 1].toDouble()
val p = GlobeMath.projectRaw(lat, lon, cLat, cLon)
val front = p.cosC > 0.005f
pts.add(DiscPt(p.x, p.y, front))
if (p.cosC >= 0f) anyFront = true
i++
}
if (!anyFront) return
val runs = buildFrontRuns(pts, closed = false)
strokeRuns(runs, cx, cy, r, colors.border, 1.2f, clip)
}
private fun DrawScope.drawCities(
cities: List<LandData.City>,
state: GlobeState,
cx: Float, cy: Float, r: Float,
cLat: Double, cLon: Double,
colors: GlobeColors,
labelPaint: Paint,
haloPaint: Paint,
typeface: Typeface?,
textSize: Float,
density: Float
) {
if (cities.isEmpty()) return
val zoom = state.zoom
val maxRank = when {
zoom < 2f -> 1
zoom < 8f -> 3
zoom < 40f -> 4
else -> 10
}
val canvas = drawContext.canvas.nativeCanvas
for (city in cities) {
if (city.rank > maxRank) continue
val p = GlobeMath.project(city.lat.toDouble(), city.lon.toDouble(), cLat, cLon) ?: continue
if (p.cosC < 0.03f) continue
val sx = cx + p.x * r
val sy = cy + p.y * r
if (sx < -50 || sx > size.width + 50 || sy < -50 || sy > size.height + 50) continue
val alpha = p.cosC.coerceIn(0.25f, 1f)
val important = city.capital || city.megacity
val dotRadius = (if (important) 2.6f else 1.8f) * density
val dotColor = if (city.capital) colors.accent.copy(alpha = alpha)
else colors.label.copy(alpha = alpha * 0.85f)
drawCircle(dotColor, radius = dotRadius, center = Offset(sx, sy))
if (zoom >= 6f || (important && zoom >= 2.5f)) {
labelPaint.textSize = textSize
labelPaint.typeface = typeface
labelPaint.textAlign = Paint.Align.LEFT
labelPaint.color = android.graphics.Color.argb(
(200 * alpha).toInt(),
(colors.label.red * 255).toInt(), (colors.label.green * 255).toInt(), (colors.label.blue * 255).toInt()
)
haloPaint.textSize = textSize
haloPaint.typeface = typeface
haloPaint.textAlign = Paint.Align.LEFT
haloPaint.strokeWidth = textSize * 0.16f
haloPaint.color = android.graphics.Color.argb(
(140 * alpha).toInt(),
(colors.labelHalo.red * 255).toInt(), (colors.labelHalo.green * 255).toInt(), (colors.labelHalo.blue * 255).toInt()
)
val tx = sx + dotRadius + 3 * density
val ty = sy - ((labelPaint.descent() + labelPaint.ascent()) / 2f)
canvas.drawText(city.name, tx, ty, haloPaint)
canvas.drawText(city.name, tx, ty, labelPaint)
}
}
labelPaint.textAlign = Paint.Align.CENTER
haloPaint.textAlign = Paint.Align.CENTER
}
private fun DrawScope.drawLandRing(
ring: LandData.Ring,
scratch: FloatArray,
cx: Float, cy: Float, r: Float,
cLat: Double, cLon: Double,
colors: GlobeColors,
clip: ClipRect
) {
val n = ring.size
if (n < 3 || n * 3 > scratch.size) return
var anyFront = false
var i = 0
while (i < n) {
val lat = ring.coords[i * 2].toDouble()
val lon = ring.coords[i * 2 + 1].toDouble()
val p = GlobeMath.projectRaw(lat, lon, cLat, cLon)
scratch[i * 3] = p.x
scratch[i * 3 + 1] = p.y
scratch[i * 3 + 2] = p.cosC
if (p.cosC >= 0f) anyFront = true
i++
}
if (!anyFront) return
val pts = ArrayList<DiscPt>(n)
i = 0
while (i < n) {
pts.add(DiscPt(scratch[i * 3], scratch[i * 3 + 1], scratch[i * 3 + 2] > 0.005f))
i++
}
val runs = buildFrontRuns(pts)
fillPolygonClipped(pts, cx, cy, r, colors.land, clip)
strokeRuns(runs, cx, cy, r, colors.coastline, 1.4f, clip)
}
private fun DrawScope.drawGeohashGrid(
state: GlobeState,
cx: Float, cy: Float, r: Float,
cLat: Double, cLon: Double,
colors: GlobeColors,
clip: ClipRect
) {
val selected = state.selectedGeohash
val cells = linkedSetOf(selected)
cells.addAll(Geohash.neighborsSamePrecision(selected))
for (cell in cells) {
val isSelected = cell == selected
val b = Geohash.decodeToBounds(cell)
val spanLat = b.latMax - b.latMin
val spanLon = b.lonMax - b.lonMin
val steps = ceil(spanLat / 1.5).toInt().coerceIn(4, 48)
// Sample the cell boundary: N edge l->r, E edge t->b, S edge r->l, W edge b->t
val pts = ArrayList<Triple<Float, Float, Boolean>>(steps * 4 + 4)
fun addPt(lat: Double, lonRaw: Double) {
var lon = lonRaw
// keep boundary continuous across the antimeridian relative to the view
val ref = cLon
while (lon - ref > 180.0) lon -= 360.0
while (lon - ref < -180.0) lon += 360.0
val p = GlobeMath.projectRaw(lat, lon, cLat, cLon)
pts.add(Triple(p.x, p.y, p.cosC >= 0.005f))
}
for (s in 0..steps) {
val t = s.toDouble() / steps
addPt(b.latMax, b.lonMin + spanLon * t)
}
for (s in 1..steps) {
val t = s.toDouble() / steps
addPt(b.latMax - spanLat * t, b.lonMax)
}
for (s in 1..steps) {
val t = s.toDouble() / steps
addPt(b.latMin, b.lonMax - spanLon * t)
}
for (s in 1 until steps) {
val t = s.toDouble() / steps
addPt(b.latMin + spanLat * t, b.lonMin)
}
if (pts.none { it.third }) continue
val discPts = pts.map { DiscPt(it.first, it.second, it.third) }
val runs = buildFrontRuns(discPts)
if (isSelected) {
fillPolygonClipped(discPts, cx, cy, r, colors.accent.copy(alpha = 0.20f), clip)
strokeRuns(runs, cx, cy, r, colors.accent.copy(alpha = 0.35f), 7f, clip)
strokeRuns(runs, cx, cy, r, colors.accent, 3.2f, clip)
} else {
fillPolygonClipped(discPts, cx, cy, r, colors.grid.copy(alpha = 0.05f), clip)
strokeRuns(runs, cx, cy, r, colors.grid, 1.6f, clip)
}
}
}
private fun DrawScope.drawGeohashLabels(
state: GlobeState,
cx: Float, cy: Float, r: Float,
cLat: Double, cLon: Double,
colors: GlobeColors,
labelPaint: Paint,
haloPaint: Paint,
labelTypeface: Typeface?,
labelTypefaceBold: Typeface?,
selectedSize: Float,
neighborSize: Float
) {
val selected = state.selectedGeohash
val cells = linkedSetOf(selected)
cells.addAll(Geohash.neighborsSamePrecision(selected))
val canvas = drawContext.canvas.nativeCanvas
for (cell in cells) {
val isSelected = cell == selected
val (lat, lon) = Geohash.decodeToCenter(cell)
val p = GlobeMath.project(lat, lon, cLat, cLon) ?: continue
if (p.cosC < 0.08f) continue
val sx = cx + p.x * r
val sy = cy + p.y * r
val paint = labelPaint
paint.textSize = if (isSelected) selectedSize else neighborSize
paint.typeface = if (isSelected) (labelTypefaceBold ?: labelTypeface) else labelTypeface
paint.color = if (isSelected) {
android.graphics.Color.argb(255, (colors.accent.red * 255).toInt(), (colors.accent.green * 255).toInt(), (colors.accent.blue * 255).toInt())
} else {
android.graphics.Color.argb(
(160 * p.cosC.coerceIn(0.4f, 1f)).toInt(),
(colors.label.red * 255).toInt(), (colors.label.green * 255).toInt(), (colors.label.blue * 255).toInt()
)
}
val baseline = sy - ((paint.descent() + paint.ascent()) / 2f)
haloPaint.textSize = paint.textSize
haloPaint.typeface = paint.typeface
haloPaint.strokeWidth = paint.textSize * 0.18f
haloPaint.color = android.graphics.Color.argb(
if (isSelected) 200 else 120,
(colors.labelHalo.red * 255).toInt(), (colors.labelHalo.green * 255).toInt(), (colors.labelHalo.blue * 255).toInt()
)
canvas.drawText(cell, sx, baseline, haloPaint)
canvas.drawText(cell, sx, baseline, paint)
}
}

View File

@ -0,0 +1,130 @@
package com.bitchat.android.ui.globe
import android.content.Context
import org.json.JSONObject
/**
* 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)
data class City(val name: String, val lat: Float, val lon: Float, val rank: Int, val capital: Boolean, val megacity: Boolean)
@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))
}
}
}

View File

@ -536,7 +536,7 @@
<string name="channel_count_prefix"> · ⧉ </string>
<string name="nobody_around">Nobody around…</string>
<string name="you_suffix"> (you)</string>
<string name="pan_zoom_instruction">Pan and zoom to select a geohash</string>
<string name="pan_zoom_instruction">Drag to spin · Pinch to zoom · Tap to focus</string>
<string name="select">Select</string>
<string name="type_a_message_placeholder">Type a message…</string>
<string name="mention_suggestion_at">@%1$s</string>