From d452e57bcae963dc9be674b7b422e6ec4f26ed4a Mon Sep 17 00:00:00 2001 From: Fabio Manganiello Date: Tue, 27 Apr 2021 23:15:42 +0200 Subject: [PATCH] Added support for saved services --- app/src/main/assets/web/css/style.css | 34 +++- app/src/main/assets/web/img/trash.png | Bin 0 -> 5397 bytes app/src/main/assets/web/index.html | 57 +++++-- app/src/main/assets/web/js/main.js | 153 +++++++++++++++--- app/src/main/java/tech/platypush/app/JSON.kt | 54 +++++++ .../java/tech/platypush/app/MainActivity.kt | 2 + .../main/java/tech/platypush/app/Services.kt | 124 ++++++++++++++ .../tech/platypush/app/WebAppInterface.kt | 35 ++++ 8 files changed, 426 insertions(+), 33 deletions(-) create mode 100644 app/src/main/assets/web/img/trash.png create mode 100644 app/src/main/java/tech/platypush/app/JSON.kt diff --git a/app/src/main/assets/web/css/style.css b/app/src/main/assets/web/css/style.css index 1cd8396..e0a3d95 100644 --- a/app/src/main/assets/web/css/style.css +++ b/app/src/main/assets/web/css/style.css @@ -18,25 +18,32 @@ html, body { } .services { - width: 50%; + width: 60%; height: max-content; - min-width: 15em; + min-width: 17em; max-width: 22.5em; background: white; - margin-top: 3em; + margin-top: 2em; box-shadow: 1px 1px 2px 2px #bbb; border-radius: 1.5em; } +.services h3 { + width: 100%; + text-align: center; +} + .no-items { text-align: center; padding: 2em 1em; } .service { + width: 100%; padding: 1em .5em; border-bottom: 1px solid #ccc; cursor: pointer; + position: relative; } .service:first-child { @@ -51,6 +58,16 @@ html, body { background: #bef6da; } +.service .remove { + position: absolute; + right: 1.5em; +} + +.service .remove img { + width: 1.2em; + height: 1.2em; +} + .name { font-weight: bold; } @@ -69,6 +86,7 @@ html, body { opacity: .75; } +/* Add host modal */ .add-modal-container { width: 100%; height: 100%; @@ -128,6 +146,16 @@ html, body { font-size: 1.05em; } +.add-modal .buttons { + width: 100%; + display: flex; + align-items: center; +} + +.add-modal .buttons .button { + width: 50%; +} + /* Splash screen */ .splash { width: 100vw; diff --git a/app/src/main/assets/web/img/trash.png b/app/src/main/assets/web/img/trash.png new file mode 100644 index 0000000000000000000000000000000000000000..447f99ddc233c05e04becc574879b45dbef8b329 GIT binary patch literal 5397 zcmeHLc~leU77r?g;({VzMT#M~u_T#--L`9#Vfb>g1#Pj-(=e)kZm~&<_^L@YjyZ8R? z{l1f#yx<_e$tG4N1Oj0)-=8M{zn;3&cs#i7s$Q)n5XQYq6Go^7NGegGluPhLjHq6z zz=)UzmkAOMD}xBF3)|K>sTN>{@uM7KmSo_zdz=gW6YuF{bJLeiR<~zO>Z8( z>CU?G*pgYaad+m_?v#_v-7B^weD->Vt^2G<$EbnK&kGj&_hxu)_vBPtsFM1QuS1Ka zS=xrn3oACZJV;^HxzFdmsukHFHr{3-N6J(Nhh-*$-}yg8PTG7>=+;;Da_;)<42$o3 zk+3@jO|7MJEj4(~p4WtW74)(-6Q958rxiKKhTi&%SGsoW(a!5JU*fgAr?|Dg_}=Z9 zz?rs}uJ5;5c9X-jF|N@fGaD6-Z>tu2y~x%|J=@StMvF?lCpmp%mRt_w+NzLxv+1Uv znMq`XV~JCE>xvo(-{KZK69r{!W-K1Re09OIW6is(wU4b>^SUN1nU_1wR}t~=_66-G zmudvN7rgz_v0~Fw2{Loj?eaT@M0obfvW2I&bf=Lk$K}-4Yj1q@k8}1(3u0<4Qs~s? zaehAOnF-*dKS%cP*3MD>P=gTe)Xv3v1AVVjjJNd8meVHm>h* zH-2`9;Z%pnNpTy6|BN!4)qO7CZbSLTd1060|A;y&Id7lv+s$rve|g^A&#D*?dAxa0#h#`^p`}JHZDoa} ztyiI|T3fFt%-AFIsuyva_A9tMd|TTgyc;PN_7$2ivkT zpL_JS{uo(YtoY^(fA7P_Di7b)&6MW&8?SqQl2!+0pLSO4OYT254|>T@D=lr@oV{Aw zaksYgeER7JQ=tB`#q00;8|-{1nx^q6l{bxFQ|;Nlvt?QO?C(uI3u_m(6O1;Tj>tQF zozvFZ(%MoNduw2TP?4`Z<(YQkEZC>4aIham1O~8Cxs-y4Dh@o5)l`3NK0J;p+E%}i^KiXlvrq5kPuBvKv`mvhr5ZJh7Aa$m>MB! zq=_;WTf-sgdD-AzH%uiF^$>LehZGSQOytUy7!jtx6o~Aj!BglYcN3zUQY>K$c)r6F z;E6+uQ>zthDm67Vm6GaAkt<`VG!~0Rh3Hf|oeU6U)k>Kf(U4^-I~~Ok2M<%BN?f7F z@0y?Tv!-9ii$5&sSz29>8JoX1qVD#kqBbI zOlL9!qOr&@9g~m|SCNG5BBG-r8bWhLG4UvhB}yE0C6YKgDjk&=P%#*EmP8~*$ux)# zq7jMcWF{S!keLh@kqZimVHesEl^A9F%9T*|hXm1; zg$f4r$phHenNXoFBz;0*?jYDLy7+BxK4fTE;|BGTVBrr`(r=etL1acw6 zt|(9#a)rsxFb!hRVOWHU==wB9*i~|gIu%i3Ua=rkkPT=ceKy1edMWKcMyJMMI-MXo zjSRtLIztFSY?#T0U`L3~h9DAkIAN-8Rlm>Jjrw1jxak3-wgF%_GzPX8uv<|_wyR;y zbTa;p$8atFMh}2`%*iL|J0{ndT%V-CCxOShYfP?BQs9%oW8L*Xlgs483lAm(|AJD% z>&(fe%iO?M8%1z{&`1ALGE}8@d&ww zU;2ZG&lUcWDguFGr#lTa`#e&>pplv%=wtL?f|VsSA^VMW4j78#^Sp!+^-r%~FgXnw z^FkgDX4M{a_ve;PsWR`XxUDg^-J3@|_$aq!g?VB}Us2JyraC8tH#+gj!4&2emTdPi79|(Z92WB4T+$8v<8&ZP?DrV_Ge4ij* JrT14ke*!IW4$lAp literal 0 HcmV?d00001 diff --git a/app/src/main/assets/web/index.html b/app/src/main/assets/web/index.html index cd8140c..a0d6069 100644 --- a/app/src/main/assets/web/index.html +++ b/app/src/main/assets/web/index.html @@ -25,18 +25,37 @@
-
-
No Platypush web services found on the network
+
+

Saved services

+ +
+ + on + : + + Remove + +
-
- - on - : +
+

Scanned services

+ +
+
No Platypush web services found on the network
+
+ +
+ + on + : +
-
+
@@ -44,22 +63,36 @@
-
+ - + + +
+
+ +
+
+ +
+
-
+
diff --git a/app/src/main/assets/web/js/main.js b/app/src/main/assets/web/js/main.js index 1cffecf..daef38d 100644 --- a/app/src/main/assets/web/js/main.js +++ b/app/src/main/assets/web/js/main.js @@ -4,47 +4,164 @@ new Vue({ return { loading: false, splash: false, - services: {}, - addModalVisible: false, - addModalHost: undefined, - addModalPort: 8008, + services: { + scanned: [], + saved: [], + }, + addModal: { + visible: false, + host: undefined, + port: 8008, + name: undefined, + save: false, + }, } }, + computed: { + servicesByName: function() { + return Object.values(this.services).reduce((obj, services) => + services.reduce((obj2, srv) => { + obj2[srv.name] = srv + return obj2 + }, obj), {}) + }, + + servicesByHostAndPort: function() { + return Object.values(this.services).reduce((obj, services) => + services.reduce((obj2, srv) => { + obj2[`${srv.host}:${srv.port}`] = srv + return obj2 + }, obj), {}) + }, + }, + methods: { refresh: function() { - this.services = { - ...this.services, - ...JSON.parse(app.pollServices()), + this.services.scanned = [ + ...this.services.scanned, + ...JSON.parse(app.pollServices()).filter((srv) => !(srv.name in this.servicesByName)), + ] + }, + + onServiceClick: function(service) { + this.connect(service.host, service.port) + }, + + onServiceConnect: function() { + if (!this.addModal.host?.length) { + app.alert('Please specify a host name or IP address') + return + } + + if (this.addModal.save) { + if (!this.saveService(this.addModal.host, this.addModal.port, this.addModal.name || '')) + return + } + + this.connect(this.addModal.host, this.addModal.port) + }, + + saveService: function(host, port, name) { + name = this.addModal.name.trim() + if (!name.length) { + app.alert('Please specify a name') + return false + } + + let overwrite = false + if (name in this.servicesByName) { + if (!confirm(`A service named ${name} already exists. ` + + `Do you want to overwrite it?`)) + return false + + overwrite = true + } else if (`${host}:${port}` in this.servicesByHostAndPort) { + if (!confirm(`A service on ${host}:${port} already exists. ` + + `Do you want to overwrite it?`)) + return false + + overwrite = true + } + + this.loading = true + try { + const rs = app.saveService(host, port, name, overwrite) + if (rs?.error) + throw rs.error + + this.loadServices() + return true + } finally { + this.loading = false } }, - onClick: function(service) { - this.loadService(service.host, service.port) + removeService: function(savedIndex, event) { + event.stopPropagation() + const srv = this.services.saved[savedIndex] + if (!(srv && confirm('Are you sure that you want to remove this service?'))) + return false + + this.loading = true + try { + const rs = app.removeService(srv.host, srv.port, srv.name) + if (rs?.error) + throw rs.error + + this.loadServices() + return true + } finally { + this.loading = false + } }, - onConnect: function() { - this.loadService(this.addModalHost, this.addModalPort) - }, - - loadService: function(host, port) { + connect: function(host, port) { this.loading = true app.stopServicesPoll() window.location.href = `http://${host}:${port}/` }, splashScreen: function(duration) { - var self = this + const self = this this.splash = true - window.setTimeout(() => { + setTimeout(() => { self.splash = false }, duration) }, + + resetAddModal: function() { + this.addModal = { + host: undefined, + port: 8008, + name: undefined, + save: false, + } + }, + + loadServices: function() { + this.services = { + ...this.services, + ...JSON.parse(app.loadServices()) + } + }, }, mounted: function() { - this.splashScreen(2500) + this.splashScreen(1500) + + this.$watch(() => this.addModal.visible, (newValue) => { + if (!newValue) + this.resetAddModal() + }) + + this.$watch(() => this.addModal.save, (newValue) => { + if (newValue) + this.addModal.name = this.addModal.host + }) + + this.loadServices() app.startServicesPoll() - window.setInterval(this.refresh, 500) + setInterval(this.refresh, 500) } }) diff --git a/app/src/main/java/tech/platypush/app/JSON.kt b/app/src/main/java/tech/platypush/app/JSON.kt new file mode 100644 index 0000000..bbd1d32 --- /dev/null +++ b/app/src/main/java/tech/platypush/app/JSON.kt @@ -0,0 +1,54 @@ +package tech.platypush.app + +import org.json.JSONArray +import org.json.JSONObject +import java.util.* +import kotlin.collections.HashMap + + +class JSON { + companion object { + private fun toMap(obj: JSONObject): Map { + val ret = HashMap() + val keys = obj.keys() + + while (keys.hasNext()) { + val key = keys.next() + var value = obj[key] + + if (value is JSONObject) + value = toMap(value) + else if (value is JSONArray) + value = toList(value) + + ret[key] = value + } + + return ret + } + + private fun toList(arr: JSONArray): List { + val ret = LinkedList() + for (i in 0 until arr.length()) { + var value = arr[i] + + if (value is JSONObject) + value = toMap(value) + else if (value is JSONArray) + value = toList(value) + + ret.add(value) + } + + return ret + } + + fun load(str: String): Map { + return toMap(JSONObject(str)) + } + + fun dump(obj: Map): String { + return JSONObject(obj).toString() + } + } +} diff --git a/app/src/main/java/tech/platypush/app/MainActivity.kt b/app/src/main/java/tech/platypush/app/MainActivity.kt index 0f4c084..96852a8 100644 --- a/app/src/main/java/tech/platypush/app/MainActivity.kt +++ b/app/src/main/java/tech/platypush/app/MainActivity.kt @@ -2,6 +2,7 @@ package tech.platypush.app import android.annotation.SuppressLint import android.os.Bundle +import android.webkit.WebChromeClient import androidx.appcompat.app.AppCompatActivity import kotlinx.android.synthetic.main.activity_main.* @@ -12,6 +13,7 @@ class MainActivity : AppCompatActivity() { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) webview.webViewClient = WebView() + webview.webChromeClient = WebChromeClient() webview.settings.javaScriptEnabled = true webview.settings.javaScriptCanOpenWindowsAutomatically = true webview.clearCache(true) diff --git a/app/src/main/java/tech/platypush/app/Services.kt b/app/src/main/java/tech/platypush/app/Services.kt index 8232201..e3884ce 100644 --- a/app/src/main/java/tech/platypush/app/Services.kt +++ b/app/src/main/java/tech/platypush/app/Services.kt @@ -5,8 +5,12 @@ import android.content.Context import android.net.nsd.NsdManager import android.net.nsd.NsdServiceInfo import android.util.Log +import java.io.File +import java.io.FileWriter +import java.lang.RuntimeException import java.util.* import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock /** @@ -20,6 +24,20 @@ data class Service(val host: String, val port: Int, val name: String?) { "name" to name ) } + + override fun toString(): String { + return JSON.dump(toMap()) + } + + companion object { + fun load(obj: Map): Service { + return Service( + host = obj["host"] as String, + port = obj["port"] as Int, + name = obj["name"] as String? + ) + } + } } @@ -123,3 +141,109 @@ class Scanner(context: Context) { }) } } + +/** + * Services manager class + */ +class Manager(context: Context) { + private val servicesFile = File(context.filesDir, "services.json") + private val servicesFileLock = ReentrantLock() + private val emptyServices = mapOf>( + "saved" to emptyList(), + "lastConnected" to emptyList() + ) + + private fun initServicesFile(reset: Boolean = false) { + if (reset || !servicesFile.exists()) + saveServices(emptyServices) + } + + fun loadServices(): Map> { + initServicesFile() + var services: Map> = emptyServices + + try { + services = deserializeServices(servicesFile.readText()) + } catch (e: Exception) { + Log.e(TAG, "Error while parsing $servicesFile: resetting it", e) + initServicesFile(reset = true) + } + + return services + } + + private fun saveServices(services: Map>) { + servicesFileLock.withLock { + FileWriter(servicesFile).use { + it.write(serializeServices(services)) + } + } + } + + fun saveService(host: String, port: Int, name: String, overwrite: Boolean = false) { + val service = Service(host, port, name) + val services = this.loadServices().toMutableMap() + val savedServices = services["saved"]?.toMutableList() ?: ArrayList() + val dupIndex = getSavedServiceIndex(service, savedServices) + + if (dupIndex != null && !overwrite) + throw DuplicateServiceException(service) + + savedServices.add(service) + services["saved"] = savedServices + saveServices(services) + } + + fun removeService(host: String? = null, port: Int? = null, name: String? = null) { + val service = Service(host=host ?: "", port=port ?: 0, name=name) + val services = this.loadServices().toMutableMap() + val savedServices = ArrayList(services["saved"] ?: emptyList()) + val srvIndex = getSavedServiceIndex(service, savedServices) + ?: throw NoSuchServiceException(host, port, name) + + savedServices.removeAt(srvIndex) + services["saved"] = savedServices + saveServices(services) + } + + private fun getSavedServiceIndex(service: Service, services: List): Int? { + val matchingSrvIndexes = services.indices.filter { + (services[it].host == service.host && services[it].port == service.port) || + services[it].name == service.name + } + + return if (matchingSrvIndexes.isNotEmpty()) matchingSrvIndexes[0] else null + } + + fun serializeServices(services: Map>): String { + val parsedServices = HashMap>>() + + for (srvType in listOf("saved", "lastConnected")) { + val outputList = LinkedList>() + for (srv in services[srvType] ?: emptyList()) + outputList.add(srv.toMap()) + parsedServices[srvType] = outputList.toList() + } + + return JSON.dump(parsedServices) + } + + private fun deserializeServices(servicesJson: String): Map> { + @Suppress("UNCHECKED_CAST") + val services = JSON.load(servicesJson) as Map>> + val parsedServices = HashMap>() + + for (srvType in listOf("saved", "lastConnected")) { + val outputList = LinkedList() + for (srv in services[srvType] ?: emptyList()) + outputList.add(Service.load(srv)) + parsedServices[srvType] = outputList + } + + return parsedServices + } +} + +class DuplicateServiceException(service: Service) : RuntimeException("Duplicate service: $service") +class NoSuchServiceException(host: String? = null, port: Int? = null, name: String? = null): + RuntimeException("No such host: host=$host, port=$port, name=$name") diff --git a/app/src/main/java/tech/platypush/app/WebAppInterface.kt b/app/src/main/java/tech/platypush/app/WebAppInterface.kt index 0ad6937..fb63a49 100644 --- a/app/src/main/java/tech/platypush/app/WebAppInterface.kt +++ b/app/src/main/java/tech/platypush/app/WebAppInterface.kt @@ -1,6 +1,8 @@ package tech.platypush.app +import android.content.ContentValues.TAG import android.content.Context +import android.util.Log import android.webkit.JavascriptInterface import org.json.JSONArray import java.util.* @@ -8,6 +10,7 @@ import java.util.* class WebAppInterface(context: Context) { private val serviceScanner = Scanner(context) + private val serviceManager = Manager(context) @Suppress("unused") @JavascriptInterface @@ -29,4 +32,36 @@ class WebAppInterface(context: Context) { services.add(srv.toMap()) return JSONArray(services).toString() } + + @Suppress("unused") + @JavascriptInterface + fun loadServices(): String { + return serviceManager.serializeServices(serviceManager.loadServices()) + } + + @Suppress("unused") + @JavascriptInterface + fun saveService(host: String, port: Int, name: String, overwrite: Boolean = false): String? { + try { + serviceManager.saveService(host, port, name, overwrite=overwrite) + } catch (e: Exception) { + Log.e(TAG, e.toString()) + return JSON.dump(mapOf("error" to e.toString())) + } + + return null + } + + @Suppress("unused") + @JavascriptInterface + fun removeService(host: String, port: Int, name: String): String? { + try { + serviceManager.removeService(host, port, name) + } catch (e: Exception) { + Log.e(TAG, e.toString()) + return JSON.dump(mapOf("error" to e.toString())) + } + + return null + } }