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")