diff --git a/android/tinySSB/app/build.gradle b/android/tinySSB/app/build.gradle index 021680d..9b50b9a 100644 --- a/android/tinySSB/app/build.gradle +++ b/android/tinySSB/app/build.gradle @@ -86,6 +86,7 @@ dependencies { // database implementation "androidx.room:room-runtime:2.3.0" + implementation 'com.google.android.gms:play-services-location:18.0.0' // 21.3.0' kapt "androidx.room:room-compiler:2.3.0" // implementation "androidx.room:room-rxjava2:2.3.0" // implementation "androidx.room:room-guava:2.3.0" diff --git a/android/tinySSB/app/src/main/assets/web/prod/chat.js b/android/tinySSB/app/src/main/assets/web/prod/chat.js index 58f0430..1c33053 100644 --- a/android/tinySSB/app/src/main/assets/web/prod/chat.js +++ b/android/tinySSB/app/src/main/assets/web/prod/chat.js @@ -67,7 +67,13 @@ function new_text_post(s) { if (s.length == 0) { return; } - var draft = unicodeStringToTypedArray(document.getElementById('draft').value); // escapeHTML( + var draft = '' + if (Android.isGeoLocationEnabled() == "true") { + var plusCode = Android.getCurrentLocationAsPlusCode(); + if (plusCode != null && plusCode.length > 0) //check if we actually received a location + draft += "pfx:loc/plus," + plusCode + "|"; + } + draft += unicodeStringToTypedArray(document.getElementById('draft').value); // escapeHTML( var ch = tremola.chats[curr_chat] if (!(ch.timeline instanceof Timeline)) { ch.timeline = Timeline.fromJSON(ch.timeline) @@ -92,7 +98,13 @@ function new_text_post(s) { } function new_voice_post(voice_b64) { - var draft = unicodeStringToTypedArray(document.getElementById('draft').value); // escapeHTML( + var draft = '' + if (Android.isGeoLocationEnabled() == "true") { + var plusCode = Android.getCurrentLocationAsPlusCode(); + if (plusCode != null && plusCode.length > 0) //check if we actually received a location + draft += "pfx:loc/plus," + plusCode + "|"; + } + draft += unicodeStringToTypedArray(document.getElementById('draft').value); // escapeHTML( if (draft.length == 0) draft = "null" else @@ -144,16 +156,48 @@ function new_image_post() { function load_post_item(p) { // { 'key', 'from', 'when', 'body', 'to' (if group or public)> var pl = document.getElementById('lst:posts'); var is_other = p["from"] != myId; + /* var box = "
\n"); + // split each message, so that we may access the fields of the message + var fieldsOfBody = textOfBody.split(/(\|)/g); + var geoLocPlusCode = null; + // here we check all prefixes that might be in a message, it is currently not possible to send a sketch with any prefixes since this completely breaks the sketch because uses the character '|' + var i = 0; + var otherText = ""; + while (i < fieldsOfBody.length){ + var field = fieldsOfBody[i]; + if (field.startsWith("pfx:loc/plus")){ + var partsOfGeoLoc = field.split(','); + geoLocPlusCode = partsOfGeoLoc[1]; + i++; + } else { + otherText += field; + } + i++; + } + if ((p.voice != null) && geoLocPlusCode == null) + box += " onclick='play_voice(\"" + curr_chat + "\", \"" + p.key + "\");'"; + if ((geoLocPlusCode != null) && (p.voice == null)) + box += " onclick='showGeoMenu(\"" + geoLocPlusCode + "\");'"; + if ((geoLocPlusCode != null) && (p.voice != null)) + box += " onclick='showGeoVoiceMenu(\"" + geoLocPlusCode + "\",\"" + curr_chat + "\", \"" + p.key + "\");'"; + box += ">" + // console.log("box=", box); if (is_other) box += "" + fid2display(p["from"]) + "
"; + // if (geoLocPlusCode != null) + // box += "" + "this message contains geolocation" + "
"; var txt = "" if (p["body"] != null) { - txt = escapeHTML(p["body"]).replace(/\n/g, "
\n"); + // txt = escapeHTML(p["body"]).replace(/\n/g, "
\n"); + txt = otherText; // Sketch app if (txt.startsWith("data:image/png;base64")) { // check if the string is a data url let compressedBase64 = txt.split(',')[1]; @@ -231,6 +275,8 @@ function load_post_item(p) { // { 'key', 'from', 'when', 'body', 'to' (if group box += txt var d = new Date(p["when"]); d = d.toDateString() + ' ' + d.toTimeString().substring(0, 5); + if (geoLocPlusCode != null) + d = '📌 ' + d box += "
"; box += d + "
"; var row; diff --git a/android/tinySSB/app/src/main/assets/web/prod/settings.js b/android/tinySSB/app/src/main/assets/web/prod/settings.js index ace2332..be28354 100644 --- a/android/tinySSB/app/src/main/assets/web/prod/settings.js +++ b/android/tinySSB/app/src/main/assets/web/prod/settings.js @@ -14,14 +14,17 @@ const BrowserOnlySettings = { 'hide_forgotten_kanbans': true, 'udp_multicast_enabled': true, 'ble_enabled': true, - 'websocket_url': "ws://meet.dmi.unibas.ch:8989" + 'websocket_url': "ws://meet.dmi.unibas.ch:8989", + 'geo_location': true } // button/toggle handler for boolean settings; settingID is determined by the id of the html object that emitted the event (e.id) function toggle_changed(e) { - // console.log("toggle ", e.id); + console.log("toggle:", e.id); tremola.settings[e.id] = e.checked; - backend("settings:set " + e.id + " " + e.checked) + var cmd = "settings:set " + e.id + " " + e.checked + backend(cmd) + console.log(`sent "${cmd}" to backend`) persist() applySetting(e.id, e.checked); } diff --git a/android/tinySSB/app/src/main/assets/web/prod/sketch.js b/android/tinySSB/app/src/main/assets/web/prod/sketch.js index 488aef0..12e68fa 100644 --- a/android/tinySSB/app/src/main/assets/web/prod/sketch.js +++ b/android/tinySSB/app/src/main/assets/web/prod/sketch.js @@ -433,7 +433,7 @@ async function sketch_get_current_size() { // return the current sketch as a base64 string (including the preceding data type descriptor) async function sketch_getImage() { - // console.log('getImage', JSON.stringify(sketch.svg)); + console.log('getImage', JSON.stringify(sketch.svg)); const buf = new ArrayBuffer(bipf_encodingLength(sketch.svg)) const e = bipf_encode(sketch.svg, buf, 0) // console.log(' bipf:', JSON.stringify(new Uint8Array(buf))) @@ -474,6 +474,7 @@ async function sketch_getImage() { for (var i = 0; i < len; i++) { binary += String.fromCharCode( bytes[ i ] ); } + let shortenedDataURL = 'data:image/svg+bipf;base64,' + btoa(binary); return shortenedDataURL @@ -481,33 +482,42 @@ async function sketch_getImage() { //function called by the drawing submit button async function chat_sendDrawing() { - let img = await sketch_getImage() - if (img.length == 0) { + var img = await sketch_getImage() + if (img.length == 0) return; - } - // send to backend - var ch = tremola.chats[curr_chat] - if (!(ch.timeline instanceof Timeline)) { - ch.timeline = Timeline.fromJSON(ch.timeline) - } - let tips = JSON.stringify(ch.timeline.get_tips()) - if (curr_chat == "ALL") { - var cmd = `publ:post ${tips} ` + btoa(img) + " null"; // + recps - // console.log(cmd) - backend(cmd); - } else { - var recps = tremola.chats[curr_chat].members.join(' '); - var cmd = `priv:post ${tips} ` + btoa(img) + " null " + recps; - backend(cmd); - } + launch_snackbar("sending sketch ...") + setTimeout(function () { // delay sending (and getting location beforehand), allows snackbar to show + + //add geolocation to message if enabled. + if (Android.isGeoLocationEnabled() == "true"){ //ony add if enabled + var plusCode = Android.getCurrentLocationAsPlusCode(); + if (plusCode != null && plusCode.length > 0) //check if we actually received a location + img = "pfx:loc/plus," + plusCode + "|" + img; + } + + // send to backend + var ch = tremola.chats[curr_chat] + if (!(ch.timeline instanceof Timeline)) + ch.timeline = Timeline.fromJSON(ch.timeline) + let tips = JSON.stringify(ch.timeline.get_tips()) + if (curr_chat == "ALL") { + var cmd = `publ:post ${tips} ` + btoa(img) + " null"; // + recps + // console.log(cmd) + backend(cmd); + } else { + var recps = tremola.chats[curr_chat].members.join(' '); + var cmd = `priv:post ${tips} ` + btoa(img) + " null " + recps; + backend(cmd); + } - closeOverlay(); - // setTimeout(function () { // let image rendering (fetching size) take place before we scroll - let c = document.getElementById('core'); - c.scrollTop = c.scrollHeight; - // }, 100); + closeOverlay(); + // setTimeout(function () { // let image rendering (fetching size) take place before we scroll + let c = document.getElementById('core'); + c.scrollTop = c.scrollHeight; + // }, 100); - // close sketch - chat_closeSketch(); + // close sketch + chat_closeSketch(); + }, 100); } diff --git a/android/tinySSB/app/src/main/assets/web/prod/tools.js b/android/tinySSB/app/src/main/assets/web/prod/tools.js index 8c91602..a381e13 100644 --- a/android/tinySSB/app/src/main/assets/web/prod/tools.js +++ b/android/tinySSB/app/src/main/assets/web/prod/tools.js @@ -5,11 +5,11 @@ function load_prod_list() { document.getElementById("lst:prod").innerHTML = ''; load_prod_item('Kanban', 'img/kanban.svg', 'setScenario("kanban")', - 'text text text h aghjwd gldfhjs hlgsf hgljksf hgls fdhglf sdhgl hfgskj hls dfhgjl shgjkls hgl sfdhgjk sdfjklg hljs hfgl dfjlsfs'); + 'Collaboratively visualize your work items and give participants a view of progress and process, from start to finish.
Author: Jannick Heisch'); load_prod_item('Event Scheduler (dpi24.14)', 'img/schedule.svg', 'setScenario("scheduling")', - 'text text text h aghjwd gldfhjs hlgsf hgljksf hgls fdhglf sdhgl hfgskj hls dfhgjl shgjkls hgl sfdhgjk sdfjklg hljs hfgl dfjlsfs'); + 'Collaboratively find suitable dates by collecting availability of participants.
Authors: Sascha Schumacher and Jasra Mohamed Yoosuf'); load_prod_item('Kahoot Quiz (dpi24.15)', 'img/quiz.svg', 'xyz', - 'text text text h aghjwd gldfhjs hlgsf hgljksf hgls fdhglf sdhgl hfgskj hls dfhgjl shgjkls hgl sfdhgjk sdfjklg hljs hfgl dfjlsfs'); + 'Create and participate in quizzes - a fun way for users to test their knowledge and learn new information.
Authors: Anoozh Akileswaran, Prabavan Balasubramaniam and Jakob Spiess'); load_prod_item('Lokens (coming soon)', 'img/hand_and_coins.svg', 'xyz', 'see Erick Lavoie: "GOC-Ledger: State-based Conflict-Free Replicated Ledger from Grow-Only Counters", https://arxiv.org/abs/2305.16976'); } @@ -21,7 +21,7 @@ function load_prod_item(title, imageName, cb, descr) { row = `"; + row += "" + descr + ""; item.innerHTML = row; document.getElementById('lst:prod').appendChild(item); } diff --git a/android/tinySSB/app/src/main/assets/web/tremola.css b/android/tinySSB/app/src/main/assets/web/tremola.css index 27a6d07..2c9e8c1 100644 --- a/android/tinySSB/app/src/main/assets/web/tremola.css +++ b/android/tinySSB/app/src/main/assets/web/tremola.css @@ -95,6 +95,22 @@ textarea { box-shadow: 0 0 25px rgba(0,0,0,0.9); } +.geo-menu-overlay { + display: none; + position: absolute; + left: 50%; + top: 20%; + transform: translateX(-50%); + width: auto; + max-width: 90%; + min-width: 70%; + background: #fff; + padding: 0.5em; + z-index: 10002; /* high z-index */ + border-radius: 5px; + box-shadow: 0 0 25px rgba(0,0,0,0.9); +} + .qr-overlay { display: none; background: #fff; @@ -516,4 +532,5 @@ input:checked + .slider:before { filter: invert(62%) sepia(8%) saturate(116%) hue-rotate(145deg) brightness(89%) contrast(89%); } + /* eof */ diff --git a/android/tinySSB/app/src/main/assets/web/tremola.html b/android/tinySSB/app/src/main/assets/web/tremola.html index 81942b6..7360344 100644 --- a/android/tinySSB/app/src/main/assets/web/tremola.html +++ b/android/tinySSB/app/src/main/assets/web/tremola.html @@ -30,11 +30,14 @@
- + + +
+
@@ -64,7 +67,7 @@
- +
@@ -489,6 +492,11 @@

Game Invitation declined

Etienne Mettaz
Cedrik Schimschar
Christian Tschudin
+
+ Students of DPI.fs24:
+ Luca Gloor
+ Anna Pietzak
+ Pius Walser  

Icons
@@ -566,12 +574,21 @@

Game Invitation declined

- diff --git a/android/tinySSB/app/src/main/assets/web/tremola_ui.js b/android/tinySSB/app/src/main/assets/web/tremola_ui.js index 1a4f0f9..0c4492c 100644 --- a/android/tinySSB/app/src/main/assets/web/tremola_ui.js +++ b/android/tinySSB/app/src/main/assets/web/tremola_ui.js @@ -22,11 +22,11 @@ var scenarioDisplay = { 'chats': ['div:qr', 'core', 'lst:chats', 'div:footer', 'plus'], 'contacts': ['div:qr', 'core', 'lst:contacts', 'div:footer', 'plus'], 'posts': ['div:back', 'core', 'div:posts', 'div:textarea'], - 'games': ['div:qr', 'core', 'lst:games', 'div:footer'], + 'games': ['div:back', 'core', 'lst:games', 'div:footer'], 'members': ['div:back', 'core', 'lst:members', 'div:confirm-members'], - 'productivity': ['div:qr', 'core', 'lst:prod', 'div:footer'], + 'productivity': ['div:back', 'core', 'lst:prod', 'div:footer'], 'settings': ['div:back', 'div:settings', 'core'], - 'kanban': ['div:qr', 'core', 'lst:kanban', 'div:footer', 'plus'], // KANBAN + 'kanban': ['div:back', 'core', 'lst:kanban', 'div:footer', 'plus'], // KANBAN 'board': ['div:back', 'core', 'div:board'], // KANBAN 'duels': ['div:back', 'core', 'lst:duels', 'plus'], // BATTLESHIP 'battleships': ['div:back', 'core', 'div:battleships'], // BATTLESHIP @@ -310,6 +310,7 @@ function menu_settings() { function closeOverlay() { document.getElementById('menu').style.display = 'none'; + document.getElementById('geo-menu').style.display = 'none'; document.getElementById('qr-overlay').style.display = 'none'; document.getElementById('preview-overlay').style.display = 'none'; document.getElementById('image-overlay').style.display = 'none'; @@ -654,4 +655,55 @@ function refresh_connection_progressbar(min_entries, old_min_entries, old_want_e } } +function show_geo_location(locPlus) { +// var win = window.open("https://maps.app.goo.gl/Z7WWLG8UTAnfyJpb7", '_blank'); + + var win = window.open("https://plus.codes/" + locPlus, '_blank'); + win.focus(); +} + +function copyToClipboard(text) { + navigator.clipboard.writeText(text).then(() => { + console.log('Text copied to clipboard successfully.'); + }).catch(err => { + console.error('Failed to copy text to clipboard:', err); + }); +} + +function showGeoMenu(plusCode) { + closeOverlay(); + var latLongString = Android.getCoordinatesForPlusCode(plusCode); + var latLong = JSON.parse(latLongString); + var LatitudeLongitude = latLong.latitude + " " + latLong.longitude; + var m = ''; + m += "
"; + m += "
"; + m += ""; + document.getElementById("geo-menu").innerHTML = m; + document.getElementById("geo-menu").style.display = 'initial'; + document.getElementById("overlay-trans").style.display = 'initial'; +} + +function showGeoVoiceMenu(plusCode, chat, key) { + closeOverlay(); + var latLongString = Android.getCoordinatesForPlusCode(plusCode); + var latLong = JSON.parse(latLongString); + var LatitudeLongitude = latLong.latitude + " " + latLong.longitude; + var m = ''; + m += "
"; + m += "
"; + m += "
"; + m += ""; + document.getElementById("geo-menu").innerHTML = m; + document.getElementById("geo-menu").style.display = 'initial'; + document.getElementById("overlay-trans").style.display = 'initial'; +} + // --- \ No newline at end of file diff --git a/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/Settings.kt b/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/Settings.kt index e7d0cfc..a11907d 100644 --- a/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/Settings.kt +++ b/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/Settings.kt @@ -1,6 +1,7 @@ package nz.scuttlebutt.tremolavossbol import android.content.Context +import android.util.Log import nz.scuttlebutt.tremolavossbol.utils.Constants.Companion.TINYSSB_SIMPLEPUB_URL import org.json.JSONObject @@ -20,7 +21,8 @@ class Settings(val context: MainActivity) { "ble_enabled" to "true", "udp_multicast_enabled" to "true", "websocket_enabled" to "false", - "websocket_url" to TINYSSB_SIMPLEPUB_URL + "websocket_url" to TINYSSB_SIMPLEPUB_URL, + "geo_location_enabled" to "true" ) fun getSettings(): String { val currentSettings = mutableMapOf() @@ -40,6 +42,7 @@ class Settings(val context: MainActivity) { // Sets a value for the provided settingID and executes necessary backend actions to align with the updated setting. fun set(settingID: String, value: String): Boolean { + Log.d("set", "ID=${settingID} val=${value}") if (!defaultSettings.keys.contains(settingID)) { return false // default of setting id must be defined } @@ -52,6 +55,7 @@ class Settings(val context: MainActivity) { "ble_enabled" -> handleBleEnabled(value.toBoolean()) "udp_multicast_enabled" -> handleUdpMulticastEnabled(value.toBoolean()) "websocket_url" -> handleWebsocketUrl(value) + "geo_location_enabled" -> handleGeoLocation(value.toBoolean()) } return true } @@ -75,6 +79,10 @@ class Settings(val context: MainActivity) { return sharedPreferences.getString("udp_multicast_enabled", defaultSettings["udp_multicast_enabled"]).toBoolean() } + fun isGeoLocationEnabled(): Boolean { + return sharedPreferences.getString("geo_location_enabled", defaultSettings["geo_location_enabled"]).toBoolean() + } + fun getWebsocketUrl(): String { return sharedPreferences.getString("websocket_url", defaultSettings["websocket_url"])!! } @@ -104,4 +112,13 @@ class Settings(val context: MainActivity) { context.websocket?.updateUrl(value) } + fun handleGeoLocation(value: Boolean) { + Log.d("set", "no side effects yet for setting geoLocation to ${value}") + if (value) { + //TODO Stop sending location + } else { + //TODO start sending location + } + } + } \ No newline at end of file diff --git a/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/WebAppInterface.kt b/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/WebAppInterface.kt index 7cb34f9..f5cd41a 100644 --- a/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/WebAppInterface.kt +++ b/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/WebAppInterface.kt @@ -12,10 +12,16 @@ import android.webkit.JavascriptInterface import android.webkit.WebView import android.widget.Toast import androidx.core.content.ContextCompat.checkSelfPermission +import androidx.annotation.RequiresPermission import com.google.zxing.integration.android.IntentIntegrator +import com.google.android.gms.location.LocationServices +import com.google.android.gms.tasks.CancellationTokenSource +import com.google.android.gms.tasks.Tasks + import org.json.JSONArray import org.json.JSONObject - +import java.sql.Time +import java.util.concurrent.TimeUnit import nz.scuttlebutt.tremolavossbol.utils.Bipf import nz.scuttlebutt.tremolavossbol.utils.Bipf.Companion.BIPF_BYTES @@ -33,6 +39,7 @@ import nz.scuttlebutt.tremolavossbol.utils.Constants.Companion.TINYSSB_APP_SCHED import nz.scuttlebutt.tremolavossbol.utils.HelperFunctions.Companion.deRef import nz.scuttlebutt.tremolavossbol.utils.HelperFunctions.Companion.toBase64 import nz.scuttlebutt.tremolavossbol.utils.HelperFunctions.Companion.toHex +import nz.scuttlebutt.tremolavossbol.utils.PlusCodesUtils import nz.scuttlebutt.tremolavossbol.games.battleships.BattleshipGame import nz.scuttlebutt.tremolavossbol.games.common.GamesHandler import nz.scuttlebutt.tremolavossbol.games.battleships.GameStates @@ -47,6 +54,51 @@ class WebAppInterface(val act: MainActivity, val webView: WebView, val gameHandl val frontend_frontier = act.getSharedPreferences("frontend_frontier", Context.MODE_PRIVATE) var gamesHandler = gameHandler + /** + * Retrieves the current geolocation of the Android device and returns it as a PlusCode. + */ + @JavascriptInterface + @RequiresPermission( + anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION], + ) + fun getCurrentLocationAsPlusCode(): String { + val locationClient = LocationServices.getFusedLocationProviderClient(act) + + try { + val currentLocationTask = locationClient.getCurrentLocation(102, CancellationTokenSource().token) + val currentLocation = Tasks.await(currentLocationTask, 2, TimeUnit.SECONDS) + return PlusCodesUtils.encode(currentLocation.latitude, currentLocation.longitude) + } catch (e: Exception) { + val lastLocationTast = locationClient.lastLocation + try { + val lastLocation = Tasks.await(lastLocationTast, 2, TimeUnit.SECONDS) + return PlusCodesUtils.encode(lastLocation.latitude, lastLocation.longitude) + } catch (e: Exception) { + Toast.makeText(act, "Failed to get location. Location is not sent with this message.", Toast.LENGTH_LONG).show() + return "" + } + } + + } + + @JavascriptInterface + fun getCoordinatesForPlusCode(code: String): String { + val (latitude, longitude) = PlusCodesUtils.decode(code) + return JSONObject() + .put("latitude", latitude) + .put("longitude", longitude) + .toString() + } + + @JavascriptInterface + fun isGeoLocationEnabled(): String { + return if (act.settings!!.isGeoLocationEnabled()) { + "true" + } else { + "false" + } + } + @JavascriptInterface fun onFrontendRequest(s: String) { //handle the data captured from webview} diff --git a/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/utils/PlusCodesUtils.kt b/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/utils/PlusCodesUtils.kt new file mode 100644 index 0000000..a29ee1a --- /dev/null +++ b/android/tinySSB/app/src/main/java/nz/scuttlebutt/tremolavossbol/utils/PlusCodesUtils.kt @@ -0,0 +1,91 @@ +package nz.scuttlebutt.tremolavossbol.utils + +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow + +/** + * Utility class to encode and decode location coordinates to/from a Google Plus Code. + * + * Created by translating [this](https://github.com/google/open-location-code/blob/main/java/src/main/java/com/google/openlocationcode/OpenLocationCode.java) + * Java code (freely provided by Google) to Kotlin + */ +object PlusCodesUtils { + private const val CODE_ALPHABET: String = "23456789CFGHJMPQRVWX" + private const val SEPARATOR: Char = '+' + private const val MAX_DIGIT_COUNT: Int = 15 + private const val PAIR_CODE_LENGTH: Int = 10 + private const val GRID_CODE_LENGTH: Int = MAX_DIGIT_COUNT - PAIR_CODE_LENGTH + private const val ENCODING_BASE = CODE_ALPHABET.length + private const val LATITUDE_MAX: Int = 90 + private const val LONGITUDE_MAX: Int = 180 + private const val GRID_COLUMNS: Int = 4 + private const val GRID_ROWS: Int = 5 + private const val LAT_INTEGER_MULTIPLIER: Long = 8000 * 3125 + private const val LNG_INTEGER_MULTIPLIER: Long = 8000 * 1024 + private const val LAT_MSP_VALUE: Long = LAT_INTEGER_MULTIPLIER * ENCODING_BASE * ENCODING_BASE + private const val LNG_MSP_VALUE: Long = LNG_INTEGER_MULTIPLIER * ENCODING_BASE * ENCODING_BASE + + fun encode(latitude: Double, longitude: Double): String { + var mutableLatitude = clipLatitude(latitude) + + // Latitude 90 needs to be adjusted to be just less, so the returned code can also be decoded. + if (mutableLatitude == LATITUDE_MAX.toDouble()) { + mutableLatitude = 89.9998875 + } + + val revBuilder = StringBuilder() + var latVal = getLatVal(mutableLatitude) + var lngVal = getLngVal(normalizeLongitude(longitude)) + + for (i in 0..4) { + revBuilder.append(CODE_ALPHABET[(lngVal % ENCODING_BASE).toInt()]) + revBuilder.append(CODE_ALPHABET[(latVal % ENCODING_BASE).toInt()]) + latVal /= ENCODING_BASE + lngVal /= ENCODING_BASE + + if (i == 0) { + revBuilder.append(SEPARATOR) + } + } + return revBuilder.reverse().toString() + } + + private fun clipLatitude(latitude: Double): Double { + return min(max(latitude, -LATITUDE_MAX.toDouble()), LATITUDE_MAX.toDouble()) + } + + private fun normalizeLongitude(longitude: Double): Double { + if (longitude >= -LONGITUDE_MAX && longitude < LONGITUDE_MAX) + return longitude + val circleDeg = 2 * LONGITUDE_MAX + return (longitude % circleDeg + circleDeg + LONGITUDE_MAX) % circleDeg - LONGITUDE_MAX + + } + + private fun getLatVal(latitude: Double) = + (Math.round((latitude + LATITUDE_MAX) * LAT_INTEGER_MULTIPLIER * 1e6) / 1e6 / + GRID_ROWS.toDouble().pow(GRID_CODE_LENGTH)).toLong() + + private fun getLngVal(longitude: Double) = + (Math.round((longitude + LONGITUDE_MAX) * LNG_INTEGER_MULTIPLIER * 1e6) / 1e6 / + GRID_COLUMNS.toDouble().pow(GRID_CODE_LENGTH)).toLong() + + fun decode(code: String): Pair { + val clean = code.filterNot { it == SEPARATOR } + + var latVal = -LATITUDE_MAX * LAT_INTEGER_MULTIPLIER + var lngVal = -LONGITUDE_MAX * LNG_INTEGER_MULTIPLIER + + var latPlaceVal = LAT_MSP_VALUE + var lngPlaceVal = LNG_MSP_VALUE + for (i in clean.indices step 2) { + latPlaceVal /= ENCODING_BASE + lngPlaceVal /= ENCODING_BASE + latVal += CODE_ALPHABET.indexOf(clean[i]) * latPlaceVal + lngVal += CODE_ALPHABET.indexOf(clean[i + 1]) * lngPlaceVal + } + return Pair(latVal.toDouble() / LAT_INTEGER_MULTIPLIER, + lngVal.toDouble() / LNG_INTEGER_MULTIPLIER) + } +}