diff --git a/.gitignore b/.gitignore index dda9ca6..5d8b128 100644 --- a/.gitignore +++ b/.gitignore @@ -1,24 +1,12 @@ +*.iml +.gradle +/local.properties +/.idea .DS_Store -node_modules -/dist -/npm - - -# local env files -.env.local -.env.*.local - -# Log files -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* - -# Editor directories and files -.idea -.vscode -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? +/build +/captures +.externalNativeBuild +.cxx +local.properties +/platypush/build +/app/release diff --git a/CHANGELOG.md b/CHANGELOG.md index d76bc8e..27e655c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,15 @@ # Changelog All notable changes to this project will be documented in this file. -## [Unreleased] +## [1.0.1] - 2021-04-28 +### Added +- Support for saved/favourite hosts and services. + +### Changed +- App migrated from AndroidJS to native Kotlin+webview (and APK size dropped to ~4MB). + +### Fixed +- Improved speed and stability of services scan. ## [1.0.0] - 2021-02-26 ### Added diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..ba3ca21 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,46 @@ +plugins { + id 'com.android.application' + id 'kotlin-android' + id 'kotlin-android-extensions' +} + +android { + compileSdkVersion 29 + buildToolsVersion "30.0.3" + + defaultConfig { + applicationId "tech.platypush.platypush" + minSdkVersion 23 + targetSdkVersion 29 + versionCode 1000100 + versionName "1.0.1" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation 'androidx.core:core-ktx:1.3.2' + implementation 'androidx.appcompat:appcompat:1.2.0' + implementation 'com.google.android.material:material:1.3.0' + implementation 'androidx.constraintlayout:constraintlayout:2.0.4' + implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5' + implementation 'androidx.navigation:navigation-ui-ktx:2.3.5' + testImplementation 'junit:junit:4.+' + androidTestImplementation 'androidx.test.ext:junit:1.1.2' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c7a0ad1 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/web/css/style.css b/app/src/main/assets/web/css/style.css new file mode 100644 index 0000000..e0a3d95 --- /dev/null +++ b/app/src/main/assets/web/css/style.css @@ -0,0 +1,292 @@ +html, body { + width: 100%; + height: 100%; + margin: 0; +} + +#app { + width: 100%; + height: 100%; + background: #e0eae8; + font-size: 20px; + display: flex; + justify-content: center; + font-family: Avenir, Helvetica, Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + color: #2c3e50; +} + +.services { + width: 60%; + height: max-content; + min-width: 17em; + max-width: 22.5em; + background: white; + 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 { + border-radius: 1.5em 1.5em 0 0; +} + +.service:last-child { + border-radius: 0 0 1.5em 1.5em; +} + +.service:hover { + background: #bef6da; +} + +.service .remove { + position: absolute; + right: 1.5em; +} + +.service .remove img { + width: 1.2em; + height: 1.2em; +} + +.name { + font-weight: bold; +} + +.add-btn { + width: 2.5em; + height: 2.5em; + background: url('../icon/plus.svg'); + position: fixed; + bottom: 1em; + right: 1em; + cursor: pointer; +} + +.add-btn:hover { + opacity: .75; +} + +/* Add host modal */ +.add-modal-container { + width: 100%; + height: 100%; + position: fixed; + display: flex; + justify-content: center; + align-items: center; + z-index: 2; +} + +.add-modal-background { + width: 100%; + height: 100%; + background: black; + position: absolute; + opacity: .87; +} + +.add-modal { + width: 80%; + height: max-content; + max-width: 22.5em; + background: #f0f0f0; + display: flex; + flex-direction: column; + font-size: 1.2em; + padding: 1em; + border-radius: 1em; + z-index: 3; +} + +.add-modal .header { + display: flex; + align-items: center; + justify-content: center; + padding-bottom: .5em; +} + +.add-modal form, +.add-modal form label { + width: 100%; +} + +.add-modal form input { + border-radius: .25em; + padding: .25em; +} + +.add-modal form input[type=text], +.add-modal form input[type=number] { + width: 95%; + font-size: 1.05em; + margin-bottom: .5em; +} + +.add-modal form input[type=submit] { + 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; + height: 100vh; + position: absolute; + background: white; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + font-size: 1.2em; + z-index: 1000; +} + +.splash .icon { + width: 150px; + height: 150px; + display: flex; + margin-bottom: .5em; +} + +.splash .icon img { + width: 100%; + height: 100%; +} + +/* Fade in/out animations */ +.fade-in { + animation-duration: 0.5s; + -webkit-animation-duration: 0.5s; + animation-fill-mode: both; + animation-name: fadeIn; + -webkit-animation-name: fadeIn; +} + +.fade-out { + animation-duration: 0.5s; + -webkit-animation-duration: 0.5s; + animation-fill-mode: both; + animation-name: fadeOut; + -webkit-animation-name: fadeOut; +} + +@keyframes fadeIn { + 0% {opacity: 0;} + 100% {opacity: 1;} +} + +@keyframes fadeOut { + 0% {opacity: 1;} + 100% { + opacity: 0; + display: none; + } +} + +/* Loading animation */ +.loading { + display: flex; + align-items: center; + justify-content: center; + font-size: 3em; + position: absolute; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: #909090; + opacity: 0.5; +} + +.icon { + display: inline-block; + position: relative; + width: 80px; + height: 80px; +} + +.icon div { + position: absolute; + top: 33px; + width: 13px; + height: 13px; + border-radius: 50%; + background: #fff; + animation-timing-function: cubic-bezier(0, 1, 1, 0); +} + +.icon div:nth-child(1) { + left: 8px; + animation: lds-ellipsis1 0.6s infinite; +} + +.icon div:nth-child(2) { + left: 8px; + animation: lds-ellipsis2 0.6s infinite; +} + +.icon div:nth-child(3) { + left: 32px; + animation: lds-ellipsis2 0.6s infinite; +} + +.icon div:nth-child(4) { + left: 56px; + animation: lds-ellipsis3 0.6s infinite; +} + +@keyframes lds-ellipsis1 { + 0% { + transform: scale(0); + } + 100% { + transform: scale(1); + } +} + +@keyframes lds-ellipsis3 { + 0% { + transform: scale(1); + } + 100% { + transform: scale(0); + } +} + +@keyframes lds-ellipsis2 { + 0% { + transform: translate(0, 0); + } + 100% { + transform: translate(24px, 0); + } +} diff --git a/assets/icon/icon.png b/app/src/main/assets/web/icon/icon.png similarity index 100% rename from assets/icon/icon.png rename to app/src/main/assets/web/icon/icon.png diff --git a/assets/icon/plus.svg b/app/src/main/assets/web/icon/plus.svg similarity index 100% rename from assets/icon/plus.svg rename to app/src/main/assets/web/icon/plus.svg diff --git a/app/src/main/assets/web/img/icon.png b/app/src/main/assets/web/img/icon.png new file mode 120000 index 0000000..9ca39a4 --- /dev/null +++ b/app/src/main/assets/web/img/icon.png @@ -0,0 +1 @@ +../../../res/mipmap-xxxhdpi/ic_launcher.png \ No newline at end of file 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 0000000..447f99d Binary files /dev/null and b/app/src/main/assets/web/img/trash.png differ diff --git a/app/src/main/assets/web/index.html b/app/src/main/assets/web/index.html new file mode 100644 index 0000000..a0d6069 --- /dev/null +++ b/app/src/main/assets/web/index.html @@ -0,0 +1,101 @@ + + + + Platypush + + + + + + +
+
+
+ +
+
+ Platypush +
+
+ +
+
+
+
+
+ +
+
+

Saved services

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

Scanned services

+ +
+
No Platypush web services found on the network
+
+ +
+ + on + : +
+
+
+ +
+
+
+
+ Connect to a Platypush web service +
+ +
+
+ + + + + + +
+
+ +
+
+ +
+
+
+
+
+
+ +
+
+ + + + + diff --git a/app/src/main/assets/web/js/main.js b/app/src/main/assets/web/js/main.js new file mode 100644 index 0000000..daef38d --- /dev/null +++ b/app/src/main/assets/web/js/main.js @@ -0,0 +1,167 @@ +new Vue({ + el: '#app', + data: function() { + return { + loading: false, + splash: false, + 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.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 + } + }, + + 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 + } + }, + + connect: function(host, port) { + this.loading = true + app.stopServicesPoll() + window.location.href = `http://${host}:${port}/` + }, + + splashScreen: function(duration) { + const self = this + this.splash = true + 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(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() + setInterval(this.refresh, 500) + } +}) diff --git a/assets/js/vue.min.js b/app/src/main/assets/web/js/vue.min.js similarity index 100% rename from assets/js/vue.min.js rename to app/src/main/assets/web/js/vue.min.js diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..c255832 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/java/tech/platypush/platypush/JSON.kt b/app/src/main/java/tech/platypush/platypush/JSON.kt new file mode 100644 index 0000000..0457fbd --- /dev/null +++ b/app/src/main/java/tech/platypush/platypush/JSON.kt @@ -0,0 +1,54 @@ +package tech.platypush.platypush + +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/platypush/MainActivity.kt b/app/src/main/java/tech/platypush/platypush/MainActivity.kt new file mode 100644 index 0000000..01ad9cb --- /dev/null +++ b/app/src/main/java/tech/platypush/platypush/MainActivity.kt @@ -0,0 +1,30 @@ +package tech.platypush.platypush + +import android.annotation.SuppressLint +import android.os.Bundle +import android.webkit.WebChromeClient +import androidx.appcompat.app.AppCompatActivity +import kotlinx.android.synthetic.main.activity_main.* + + +class MainActivity : AppCompatActivity() { + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + 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) + webview.addJavascriptInterface(WebAppInterface(this), "app") + webview.loadUrl("file:///android_asset/web/index.html") + } + + override fun onBackPressed() { + if (webview != null && webview.canGoBack()) + webview.goBack() + else + super.onBackPressed() + } +} diff --git a/app/src/main/java/tech/platypush/platypush/Services.kt b/app/src/main/java/tech/platypush/platypush/Services.kt new file mode 100644 index 0000000..257e9be --- /dev/null +++ b/app/src/main/java/tech/platypush/platypush/Services.kt @@ -0,0 +1,249 @@ +package tech.platypush.platypush + +import android.content.ContentValues.TAG +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 + + +/** + * Service data class + */ +data class Service(val host: String, val port: Int, val name: String?) { + fun toMap(): Map { + return mapOf( + "host" to host, + "port" to port, + "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? + ) + } + } +} + + +/** + * ZeroConf/Bonjour/mDNS event listener + */ +class Listener(private val scanner: Scanner): NsdManager.DiscoveryListener { + val serviceType = "_platypush-http._tcp." + + override fun onDiscoveryStarted(regType: String) { + Log.d(TAG, "Service discovery started") + } + + override fun onServiceFound(service: NsdServiceInfo) { + Log.d(TAG, "Service discovery succeeded: $service") + if (service.serviceType != serviceType) { + Log.d(TAG, "Unknown service type: ${service.serviceType}") + return + } + + scanner.resolveService(service) + } + + override fun onServiceLost(service: NsdServiceInfo) { + Log.w(TAG, "Service lost: $service") + } + + override fun onDiscoveryStopped(serviceType: String) { + Log.i(TAG, "Discovery stopped: $serviceType") + } + + override fun onStartDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.e(TAG, "Discovery start failed: Error code: $errorCode") + scanner.stopScan() + } + + override fun onStopDiscoveryFailed(serviceType: String, errorCode: Int) { + Log.e(TAG, "Discovery stop failed: Error code: $errorCode") + scanner.stopScan() + } +} + + +/** + * ZeroConf/Bonjour/mDNS service scanner and resolver + */ +class Scanner(context: Context) { + private val nsdManager = context.getSystemService(Context.NSD_SERVICE) as NsdManager + private val listener = Listener(this) + private val services = HashMap, Service>() + private val resolverLock = ReentrantLock() + + fun startScan() { + nsdManager.discoverServices(listener.serviceType, NsdManager.PROTOCOL_DNS_SD, listener) + } + + fun stopScan() { + nsdManager.stopServiceDiscovery(listener) + } + + fun getServices(): Collection { + return services.values + } + + fun resolveService(service: NsdServiceInfo) { + // Service resolution is a critical section + resolverLock.lock() + val scanner = this + + nsdManager.resolveService(service, object : NsdManager.ResolveListener { + override fun onResolveFailed(serviceInfo: NsdServiceInfo, errorCode: Int) { + resolverLock.unlock() + val msg = "Resolve of service ${serviceInfo.serviceName} failed" + + // Retry logic + when (errorCode) { + NsdManager.FAILURE_ALREADY_ACTIVE -> { + Thread.sleep(100) + scanner.resolveService(serviceInfo) + Log.w(TAG, "$msg: Resolver already active") + } + + NsdManager.FAILURE_MAX_LIMIT -> { + Thread.sleep(5000) + scanner.resolveService(serviceInfo) + Log.e(TAG, "$msg: Maximum number of resolve requests reached") + } + + NsdManager.FAILURE_INTERNAL_ERROR -> { + Log.e(TAG, "$msg: Internal error") + } + } + } + + override fun onServiceResolved(serviceInfo: NsdServiceInfo) { + services[Pair(serviceInfo.host.hostAddress, serviceInfo.port)] = Service( + serviceInfo.host.hostAddress, serviceInfo.port, serviceInfo.serviceName) + resolverLock.unlock() + Log.i(TAG, "Resolve succeeded: $serviceInfo") + } + }) + } +} + +/** + * 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/platypush/WebAppInterface.kt b/app/src/main/java/tech/platypush/platypush/WebAppInterface.kt new file mode 100644 index 0000000..8298335 --- /dev/null +++ b/app/src/main/java/tech/platypush/platypush/WebAppInterface.kt @@ -0,0 +1,67 @@ +package tech.platypush.platypush + +import android.content.ContentValues.TAG +import android.content.Context +import android.util.Log +import android.webkit.JavascriptInterface +import org.json.JSONArray +import java.util.* + + +class WebAppInterface(context: Context) { + private val serviceScanner = Scanner(context) + private val serviceManager = Manager(context) + + @Suppress("unused") + @JavascriptInterface + fun startServicesPoll() { + serviceScanner.startScan() + } + + @Suppress("unused") + @JavascriptInterface + fun stopServicesPoll() { + serviceScanner.stopScan() + } + + @Suppress("unused") + @JavascriptInterface + fun pollServices(): String { + val services = LinkedList>() + for (srv in serviceScanner.getServices()) + 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 + } +} diff --git a/app/src/main/java/tech/platypush/platypush/WebView.kt b/app/src/main/java/tech/platypush/platypush/WebView.kt new file mode 100644 index 0000000..d60e130 --- /dev/null +++ b/app/src/main/java/tech/platypush/platypush/WebView.kt @@ -0,0 +1,27 @@ +package tech.platypush.platypush + +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient + +class WebView : WebViewClient() { + private fun shouldOverrideUrlLoadingInner(view: WebView, url: String): Boolean { + view.loadUrl(url) + return true + } + + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { + if (view == null || request == null) + return super.shouldOverrideUrlLoading(view, request) + + return this.shouldOverrideUrlLoadingInner(view, request.url.toString()) + } + + override fun shouldOverrideUrlLoading(view: WebView?, url: String?): Boolean { + @Suppress("DEPRECATION") + if (view == null || url == null) + return super.shouldOverrideUrlLoading(view, url) + + return this.shouldOverrideUrlLoadingInner(view, url) + } +} diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..230b730 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,26 @@ + + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..edad1d1 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,20 @@ + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..4499d88 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..49d9e89 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..5ddc9d3 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..8378865 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..f950817 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..e39a18a Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..9581760 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..c54dcd4 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4498ead Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..4cce3ef Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml new file mode 100644 index 0000000..7775c01 --- /dev/null +++ b/app/src/main/res/navigation/nav_graph.xml @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..1b1f0ea --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/app/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..125df87 --- /dev/null +++ b/app/src/main/res/values/dimens.xml @@ -0,0 +1,3 @@ + + 16dp + \ No newline at end of file diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..c5d5899 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..489382d --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + Platypush + Settings + Next + Previous + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..32ab82f --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,25 @@ + + + + + + +