From a804782476c922cdacb7e7c3defbecfe7ce01014 Mon Sep 17 00:00:00 2001
From: lollerfirst <43107113+lollerfirst@users.noreply.github.com>
Date: Sat, 6 Sep 2025 11:49:23 +0200
Subject: [PATCH] feat(geohash): add in-app Geohash Picker (#363)
* feat(geohash): add in-app Geohash Picker map with quadrant drill-down and Activity integration
- New GeohashPickerActivity hosting a WebView with Leaflet-based picker
- Adds map icon next to custom geohash input in LocationChannelsSheet
- Picker allows quadrant selection and subquadrant drill-down; returns selected geohash
- Register activity in AndroidManifest
perf(picker): improve map performance and correctness
- Use LayerGroup and clearLayers() on redraw to avoid stale overlays
- Use Leaflet canvas renderer for rectangles to reduce DOM and improve perf
- Make labels non-interactive and pointer-events: none to avoid gesture overhead
- Fix initial mega-label artifact by grouping and clearing labels with cells
chore: add asset geohash_picker.html with minimal geohash helpers (bounds/encode/adjacent)
* remove accidentally committed submodule.
* fix some issues
* better geohash labels on map
* design wip
* countries green
* wip
* geohashpicker: pan + zoom; start from current geohash channel
* better ui
* ui elements
* nice
* readability
---------
Co-authored-by: callebtc <93376500+callebtc@users.noreply.github.com>
---
app/src/main/AndroidManifest.xml | 6 +
app/src/main/assets/geohash_picker.html | 225 ++++++++++++++
.../android/ui/GeohashPickerActivity.kt | 283 ++++++++++++++++++
.../android/ui/LocationChannelsSheet.kt | 35 +++
4 files changed, 549 insertions(+)
create mode 100644 app/src/main/assets/geohash_picker.html
create mode 100644 app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 2b61df2f..9516695d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -41,6 +41,12 @@
android:supportsRtl="true"
android:theme="@style/Theme.BitchatAndroid"
tools:targetApi="31">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt
new file mode 100644
index 00000000..97f1d3ce
--- /dev/null
+++ b/app/src/main/java/com/bitchat/android/ui/GeohashPickerActivity.kt
@@ -0,0 +1,283 @@
+package com.bitchat.android.ui
+
+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.WebViewClient
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+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 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.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.view.updateLayoutParams
+import com.bitchat.android.geohash.Geohash
+import com.bitchat.android.geohash.LocationChannelManager
+import com.bitchat.android.ui.theme.BASE_FONT_SIZE
+
+@OptIn(ExperimentalMaterial3Api::class)
+class GeohashPickerActivity : ComponentActivity() {
+
+ companion object {
+ const val EXTRA_INITIAL_GEOHASH = "initial_geohash"
+ 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
+
+ if (!initialGeohash.isNullOrEmpty()) {
+ geohashToFocus = initialGeohash
+ try {
+ val (lat, lon) = Geohash.decodeToCenter(initialGeohash)
+ initLat = lat
+ initLon = lon
+ } catch (_: Throwable) {}
+ } else {
+ // If no initial geohash, try to use the user's coarsest location
+ val locationManager = LocationChannelManager.getInstance(applicationContext)
+ val channels = locationManager.availableChannels.value
+ if (!channels.isNullOrEmpty()) {
+ val coarsestChannel = channels.minByOrNull { it.geohash.length }
+ if (coarsestChannel != null) {
+ geohashToFocus = coarsestChannel.geohash
+ try {
+ val (lat, lon) = Geohash.decodeToCenter(coarsestChannel.geohash)
+ initLat = lat
+ initLon = lon
+ } catch (_: Throwable) {}
+ }
+ }
+ }
+
+ val initialPrecision = geohashToFocus?.length ?: 5
+
+ setContent {
+ MaterialTheme {
+ var currentGeohash by remember { mutableStateOf(geohashToFocus ?: "") }
+ var precision by remember { mutableStateOf(initialPrecision.coerceIn(1, 12)) }
+ var webViewRef by remember { mutableStateOf(null) }
+
+ // iOS system-like colors used across app
+ val colorScheme = MaterialTheme.colorScheme
+ val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
+ val standardGreen = if (isDark) Color(0xFF32D74B) else Color(0xFF248A3D)
+
+ Scaffold { padding ->
+ Box(Modifier.fillMaxSize()) {
+ AndroidView(
+ factory = { context ->
+ WebView(context).apply {
+ settings.javaScriptEnabled = true
+ settings.domStorageEnabled = true
+ settings.cacheMode = WebSettings.LOAD_DEFAULT
+ settings.allowFileAccess = true
+ settings.allowContentAccess = true
+ webChromeClient = WebChromeClient()
+ webViewClient = object : WebViewClient() {
+ 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 {
+ 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) {}
+ }
+ )
+
+ // Floating info pill
+ Surface(
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .padding(top = 20.dp)
+ .fillMaxWidth(0.75f),
+ color = MaterialTheme.colorScheme.surface.copy(alpha = 0.85f),
+ shape = RoundedCornerShape(12.dp),
+ tonalElevation = 3.dp,
+ shadowElevation = 6.dp
+ ) {
+ Text(
+ text = "pan and zoom to select a geohash",
+ fontSize = 12.sp,
+ textAlign = TextAlign.Center,
+ fontFamily = FontFamily.Monospace,
+ 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
+ ) {
+ Text(
+ text = if (currentGeohash.isNotEmpty()) "#${currentGeohash}" else "select location",
+ fontSize = BASE_FONT_SIZE.sp,
+ fontFamily = FontFamily.Monospace,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier
+ .padding(horizontal = 14.dp, vertical = 10.dp)
+ )
+ }
+
+ // Button row
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // 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 = "Decrease 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 = "Increase precision")
+ }
+ }
+
+ // 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 = "Select geohash")
+ Spacer(Modifier.width(6.dp))
+ Text(
+ text = "select",
+ fontSize = (BASE_FONT_SIZE - 2).sp,
+ fontFamily = FontFamily.Monospace
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt
index 66a86ef1..e21dd8f5 100644
--- a/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt
+++ b/app/src/main/java/com/bitchat/android/ui/LocationChannelsSheet.kt
@@ -10,6 +10,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Map
import androidx.compose.material.icons.filled.PinDrop
import androidx.compose.material3.*
import androidx.compose.ui.text.font.FontWeight
@@ -30,6 +31,8 @@ import com.bitchat.android.geohash.GeohashChannel
import com.bitchat.android.geohash.GeohashChannelLevel
import com.bitchat.android.geohash.LocationChannelManager
import java.util.*
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
/**
* Location Channels Sheet for selecting geohash-based location channels
@@ -71,6 +74,18 @@ fun LocationChannelsSheet(
// Scroll state for LazyColumn
val listState = rememberLazyListState()
+ val mapPickerLauncher = rememberLauncherForActivityResult(
+ contract = ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ if (result.resultCode == android.app.Activity.RESULT_OK) {
+ val gh = result.data?.getStringExtra(GeohashPickerActivity.EXTRA_RESULT_GEOHASH)
+ if (!gh.isNullOrBlank()) {
+ customGeohash = gh
+ customError = null
+ }
+ }
+ }
+
// iOS system colors (matches iOS exactly)
val colorScheme = MaterialTheme.colorScheme
val isDark = colorScheme.background.red + colorScheme.background.green + colorScheme.background.blue < 1.5f
@@ -312,6 +327,26 @@ fun LocationChannelsSheet(
)
val normalized = customGeohash.trim().lowercase().replace("#", "")
+
+ // Map picker button
+ IconButton(onClick = {
+ val initial = when {
+ normalized.isNotBlank() -> normalized
+ selectedChannel is ChannelID.Location -> (selectedChannel as ChannelID.Location).channel.geohash
+ else -> ""
+ }
+ val intent = Intent(context, GeohashPickerActivity::class.java).apply {
+ putExtra(GeohashPickerActivity.EXTRA_INITIAL_GEOHASH, initial)
+ }
+ mapPickerLauncher.launch(intent)
+ }) {
+ Icon(
+ imageVector = Icons.Filled.Map,
+ contentDescription = "Open map",
+ tint = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f)
+ )
+ }
+
val isValid = validateGeohash(normalized)
// iOS-style teleport button