Added support for saved services

This commit is contained in:
Fabio Manganiello 2021-04-27 23:15:42 +02:00
parent 2817cc5f18
commit d452e57bca
8 changed files with 426 additions and 33 deletions

View file

@ -18,25 +18,32 @@ html, body {
} }
.services { .services {
width: 50%; width: 60%;
height: max-content; height: max-content;
min-width: 15em; min-width: 17em;
max-width: 22.5em; max-width: 22.5em;
background: white; background: white;
margin-top: 3em; margin-top: 2em;
box-shadow: 1px 1px 2px 2px #bbb; box-shadow: 1px 1px 2px 2px #bbb;
border-radius: 1.5em; border-radius: 1.5em;
} }
.services h3 {
width: 100%;
text-align: center;
}
.no-items { .no-items {
text-align: center; text-align: center;
padding: 2em 1em; padding: 2em 1em;
} }
.service { .service {
width: 100%;
padding: 1em .5em; padding: 1em .5em;
border-bottom: 1px solid #ccc; border-bottom: 1px solid #ccc;
cursor: pointer; cursor: pointer;
position: relative;
} }
.service:first-child { .service:first-child {
@ -51,6 +58,16 @@ html, body {
background: #bef6da; background: #bef6da;
} }
.service .remove {
position: absolute;
right: 1.5em;
}
.service .remove img {
width: 1.2em;
height: 1.2em;
}
.name { .name {
font-weight: bold; font-weight: bold;
} }
@ -69,6 +86,7 @@ html, body {
opacity: .75; opacity: .75;
} }
/* Add host modal */
.add-modal-container { .add-modal-container {
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -128,6 +146,16 @@ html, body {
font-size: 1.05em; font-size: 1.05em;
} }
.add-modal .buttons {
width: 100%;
display: flex;
align-items: center;
}
.add-modal .buttons .button {
width: 50%;
}
/* Splash screen */ /* Splash screen */
.splash { .splash {
width: 100vw; width: 100vw;

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

View file

@ -25,18 +25,37 @@
</div> </div>
<div class="services"> <div class="services">
<div class="no-items" v-if="!Object.keys(services).length"> <div class="saved" v-if="services.saved?.length">
<h3>Saved services</h3>
<div class="service" v-for="(service, i) in services.saved" :key="i"
@click="onServiceClick(service)">
<span class="name" v-text="service.name" v-if="service.name"></span>
<span v-if="service.name"> on </span>
<span class="address" v-text="service.host"></span>:<span class="port" v-text="service.port"></span>
<span class="remove" @click="removeService(i, $event)">
<img src="img/trash.png" alt="Remove">
</span>
</div>
</div>
<div class="scanned">
<h3>Scanned services</h3>
<div class="no-items" v-if="!services.scanned?.length">
<div class="empty">No Platypush web services found on the network</div> <div class="empty">No Platypush web services found on the network</div>
</div> </div>
<div class="service" v-for="(service, i) in services" :key="i" @click="onClick(service)"> <div class="service" v-for="(service, i) in services.scanned" :key="i"
@click="onServiceClick(service)">
<span class="name" v-text="service.name" v-if="service.name"></span> <span class="name" v-text="service.name" v-if="service.name"></span>
<span v-if="service.name"> on </span> <span v-if="service.name"> on </span>
<span class="address" v-text="service.host"></span>:<span class="port" v-text="service.port"></span> <span class="address" v-text="service.host"></span>:<span class="port" v-text="service.port"></span>
</div> </div>
</div> </div>
</div>
<div class="add-modal-container" @click="addModalVisible = false" v-if="addModalVisible"> <div class="add-modal-container" @click="addModal.visible = false" v-if="addModal.visible">
<div class="add-modal-background"></div> <div class="add-modal-background"></div>
<div class="add-modal" @click="$event.stopPropagation()"> <div class="add-modal" @click="$event.stopPropagation()">
<div class="header"> <div class="header">
@ -44,22 +63,36 @@
</div> </div>
<div class="body"> <div class="body">
<form @submit.prevent="onConnect"> <form @submit.prevent="onServiceConnect">
<label> <label>
<input type="text" placeholder="IP or hostname" v-model="addModalHost"> <input type="text" placeholder="IP or hostname" v-model="addModal.host">
</label> </label>
<label> <label>
<input type="number" placeholder="Port" v-model="addModalPort"> <input type="number" placeholder="Port" v-model="addModal.port">
</label> </label>
<label v-if="addModal.save">
<input type="text" placeholder="Name" v-model="addModal.name">
</label>
<div class="buttons">
<div class="button">
<input type="submit" value="Connect"> <input type="submit" value="Connect">
</div>
<div class="button">
<label>
Remember this host
<input type="checkbox" v-model="addModal.save">
</label>
</div>
</div>
</form> </form>
</div> </div>
</div> </div>
</div> </div>
<div class="add-btn" @click="addModalVisible = true"></div> <div class="add-btn" @click="addModal.visible = true"></div>
</div> </div>
<script type="text/javascript" src="js/main.js"></script> <script type="text/javascript" src="js/main.js"></script>

View file

@ -4,47 +4,164 @@ new Vue({
return { return {
loading: false, loading: false,
splash: false, splash: false,
services: {}, services: {
addModalVisible: false, scanned: [],
addModalHost: undefined, saved: [],
addModalPort: 8008, },
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: { methods: {
refresh: function() { refresh: function() {
this.services = { this.services.scanned = [
...this.services, ...this.services.scanned,
...JSON.parse(app.pollServices()), ...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) { removeService: function(savedIndex, event) {
this.loadService(service.host, service.port) 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() { connect: function(host, port) {
this.loadService(this.addModalHost, this.addModalPort)
},
loadService: function(host, port) {
this.loading = true this.loading = true
app.stopServicesPoll() app.stopServicesPoll()
window.location.href = `http://${host}:${port}/` window.location.href = `http://${host}:${port}/`
}, },
splashScreen: function(duration) { splashScreen: function(duration) {
var self = this const self = this
this.splash = true this.splash = true
window.setTimeout(() => { setTimeout(() => {
self.splash = false self.splash = false
}, duration) }, 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() { 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() app.startServicesPoll()
window.setInterval(this.refresh, 500) setInterval(this.refresh, 500)
} }
}) })

View file

@ -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<String, Any?> {
val ret = HashMap<String, Any?>()
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<Any?> {
val ret = LinkedList<Any?>()
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<String, Any?> {
return toMap(JSONObject(str))
}
fun dump(obj: Map<String, Any?>): String {
return JSONObject(obj).toString()
}
}
}

View file

@ -2,6 +2,7 @@ package tech.platypush.app
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import android.webkit.WebChromeClient
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import kotlinx.android.synthetic.main.activity_main.* import kotlinx.android.synthetic.main.activity_main.*
@ -12,6 +13,7 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
webview.webViewClient = WebView() webview.webViewClient = WebView()
webview.webChromeClient = WebChromeClient()
webview.settings.javaScriptEnabled = true webview.settings.javaScriptEnabled = true
webview.settings.javaScriptCanOpenWindowsAutomatically = true webview.settings.javaScriptCanOpenWindowsAutomatically = true
webview.clearCache(true) webview.clearCache(true)

View file

@ -5,8 +5,12 @@ import android.content.Context
import android.net.nsd.NsdManager import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo import android.net.nsd.NsdServiceInfo
import android.util.Log import android.util.Log
import java.io.File
import java.io.FileWriter
import java.lang.RuntimeException
import java.util.* import java.util.*
import java.util.concurrent.locks.ReentrantLock 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 "name" to name
) )
} }
override fun toString(): String {
return JSON.dump(toMap())
}
companion object {
fun load(obj: Map<String, Any?>): 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<String, List<Service>>(
"saved" to emptyList(),
"lastConnected" to emptyList()
)
private fun initServicesFile(reset: Boolean = false) {
if (reset || !servicesFile.exists())
saveServices(emptyServices)
}
fun loadServices(): Map<String, List<Service>> {
initServicesFile()
var services: Map<String, List<Service>> = 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<String, List<Service>>) {
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<Service>(services["saved"] ?: emptyList<Service>())
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<Service>): 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, List<Service>>): String {
val parsedServices = HashMap<String, List<Map<String, Any?>>>()
for (srvType in listOf("saved", "lastConnected")) {
val outputList = LinkedList<Map<String, Any?>>()
for (srv in services[srvType] ?: emptyList())
outputList.add(srv.toMap())
parsedServices[srvType] = outputList.toList()
}
return JSON.dump(parsedServices)
}
private fun deserializeServices(servicesJson: String): Map<String, List<Service>> {
@Suppress("UNCHECKED_CAST")
val services = JSON.load(servicesJson) as Map<String, List<Map<String, Any?>>>
val parsedServices = HashMap<String, List<Service>>()
for (srvType in listOf("saved", "lastConnected")) {
val outputList = LinkedList<Service>()
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")

View file

@ -1,6 +1,8 @@
package tech.platypush.app package tech.platypush.app
import android.content.ContentValues.TAG
import android.content.Context import android.content.Context
import android.util.Log
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
import org.json.JSONArray import org.json.JSONArray
import java.util.* import java.util.*
@ -8,6 +10,7 @@ import java.util.*
class WebAppInterface(context: Context) { class WebAppInterface(context: Context) {
private val serviceScanner = Scanner(context) private val serviceScanner = Scanner(context)
private val serviceManager = Manager(context)
@Suppress("unused") @Suppress("unused")
@JavascriptInterface @JavascriptInterface
@ -29,4 +32,36 @@ class WebAppInterface(context: Context) {
services.add(srv.toMap()) services.add(srv.toMap())
return JSONArray(services).toString() 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
}
} }