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