Added support for saved services
This commit is contained in:
parent
2817cc5f18
commit
d452e57bca
8 changed files with 426 additions and 33 deletions
|
@ -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;
|
||||
|
|
BIN
app/src/main/assets/web/img/trash.png
Normal file
BIN
app/src/main/assets/web/img/trash.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 5.3 KiB |
|
@ -25,18 +25,37 @@
|
|||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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 v-if="service.name"> on </span>
|
||||
<span class="address" v-text="service.host"></span>:<span class="port" v-text="service.port"></span>
|
||||
</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" @click="$event.stopPropagation()">
|
||||
<div class="header">
|
||||
|
@ -44,22 +63,36 @@
|
|||
</div>
|
||||
|
||||
<div class="body">
|
||||
<form @submit.prevent="onConnect">
|
||||
<form @submit.prevent="onServiceConnect">
|
||||
<label>
|
||||
<input type="text" placeholder="IP or hostname" v-model="addModalHost">
|
||||
<input type="text" placeholder="IP or hostname" v-model="addModal.host">
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input type="number" placeholder="Port" v-model="addModalPort">
|
||||
<input type="number" placeholder="Port" v-model="addModal.port">
|
||||
</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">
|
||||
</div>
|
||||
<div class="button">
|
||||
<label>
|
||||
Remember this host
|
||||
<input type="checkbox" v-model="addModal.save">
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="add-btn" @click="addModalVisible = true"></div>
|
||||
<div class="add-btn" @click="addModal.visible = true"></div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript" src="js/main.js"></script>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
54
app/src/main/java/tech/platypush/app/JSON.kt
Normal file
54
app/src/main/java/tech/platypush/app/JSON.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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<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")
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue