diff --git a/mobile/src/full/java/org/openhab/habdroid/core/FcmMessageListenerService.kt b/mobile/src/full/java/org/openhab/habdroid/core/FcmMessageListenerService.kt index 6bba4ea27a..9ff23d08e3 100644 --- a/mobile/src/full/java/org/openhab/habdroid/core/FcmMessageListenerService.kt +++ b/mobile/src/full/java/org/openhab/habdroid/core/FcmMessageListenerService.kt @@ -21,7 +21,7 @@ import org.openhab.habdroid.model.CloudMessage import org.openhab.habdroid.model.CloudNotificationAction import org.openhab.habdroid.model.CloudNotificationId import org.openhab.habdroid.model.toCloudNotificationAction -import org.openhab.habdroid.model.toIconResource +import org.openhab.habdroid.model.toOH2IconResource import org.openhab.habdroid.util.map import org.openhab.habdroid.util.toJsonArrayOrNull @@ -58,7 +58,7 @@ class FcmMessageListenerService : FirebaseMessagingService() { // timestamp, so use the (undocumented) google.sent_time as a time reference // in that case. If that also isn't present, don't show time at all. createdTimestamp = data["timestamp"]?.toLong() ?: message.sentTime, - icon = data["icon"].toIconResource(), + icon = data["icon"].toOH2IconResource(), tag = data["tag"], actions = actions, onClickAction = data["on-click"]?.let { CloudNotificationAction("", it) }, diff --git a/mobile/src/main/java/org/openhab/habdroid/core/UpdateBroadcastReceiver.kt b/mobile/src/main/java/org/openhab/habdroid/core/UpdateBroadcastReceiver.kt index b39c07d1f5..1b2e5de82c 100644 --- a/mobile/src/main/java/org/openhab/habdroid/core/UpdateBroadcastReceiver.kt +++ b/mobile/src/main/java/org/openhab/habdroid/core/UpdateBroadcastReceiver.kt @@ -31,7 +31,7 @@ import org.openhab.habdroid.model.DefaultSitemap import org.openhab.habdroid.model.ServerConfiguration import org.openhab.habdroid.model.ServerPath import org.openhab.habdroid.model.putIconResource -import org.openhab.habdroid.model.toIconResource +import org.openhab.habdroid.model.toOH2IconResource import org.openhab.habdroid.ui.homescreenwidget.ItemUpdateWidget import org.openhab.habdroid.ui.preference.PreferencesActivity import org.openhab.habdroid.util.PrefKeys @@ -107,7 +107,7 @@ class UpdateBroadcastReceiver : BroadcastReceiver() { val widgetPrefs = ItemUpdateWidget.getPrefsForWidget(context, id) val icon = widgetPrefs.getStringOrNull(PreferencesActivity.ITEM_UPDATE_WIDGET_ICON) widgetPrefs.edit { - putIconResource(PreferencesActivity.ITEM_UPDATE_WIDGET_ICON, icon.toIconResource()) + putIconResource(PreferencesActivity.ITEM_UPDATE_WIDGET_ICON, icon.toOH2IconResource()) } } diff --git a/mobile/src/main/java/org/openhab/habdroid/model/CloudNotification.kt b/mobile/src/main/java/org/openhab/habdroid/model/CloudNotification.kt index 646aaa3b2f..75d11ad694 100644 --- a/mobile/src/main/java/org/openhab/habdroid/model/CloudNotification.kt +++ b/mobile/src/main/java/org/openhab/habdroid/model/CloudNotification.kt @@ -135,7 +135,7 @@ fun JSONObject.toCloudMessage(): CloudMessage? { title = payload?.optString("title").orEmpty(), message = payload?.optString("message", "") ?: getString("message"), createdTimestamp = created, - icon = (payload?.optStringOrNull("icon") ?: optStringOrNull("icon")).toIconResource(), + icon = (payload?.optStringOrNull("icon") ?: optStringOrNull("icon")).toOH2IconResource(), tag = tag, actions = payload?.optJSONArray("actions")?.map { it.toCloudNotificationAction() }?.filterNotNull(), onClickAction = payload?.optStringOrNull("on-click")?.let { CloudNotificationAction("", it) }, diff --git a/mobile/src/main/java/org/openhab/habdroid/model/IconResource.kt b/mobile/src/main/java/org/openhab/habdroid/model/IconResource.kt index 9007c4a365..33aa0b8fca 100644 --- a/mobile/src/main/java/org/openhab/habdroid/model/IconResource.kt +++ b/mobile/src/main/java/org/openhab/habdroid/model/IconResource.kt @@ -32,6 +32,7 @@ import org.openhab.habdroid.util.getStringOrNull @Parcelize data class IconResource internal constructor( internal val icon: String, + internal val isOh2: Boolean, internal val customState: String ) : Parcelable { fun toUrl(context: Context, includeState: Boolean): String { @@ -41,6 +42,10 @@ data class IconResource internal constructor( @VisibleForTesting fun toUrl(includeState: Boolean, iconFormat: IconFormat, desiredSizePixels: Int): String { + if (!isOh2) { + return "images/$icon.png" + } + var iconSource = "oh" var iconSet = "classic" var iconName = "none" @@ -114,7 +119,7 @@ data class IconResource internal constructor( } fun withCustomState(state: String): IconResource { - return IconResource(icon, state) + return IconResource(icon, isOh2, state) } companion object { @@ -127,8 +132,9 @@ fun SharedPreferences.getIconResource(key: String): IconResource? { return try { val obj = JSONObject(iconString) val icon = obj.getString("icon") + val isOh2 = obj.getInt("ohversion") == 2 val customState = obj.optString("state") - IconResource(icon, customState) + IconResource(icon, isOh2, customState) } catch (e: JSONException) { null } @@ -140,6 +146,7 @@ fun SharedPreferences.Editor.putIconResource(key: String, icon: IconResource?): } else { val iconString = JSONObject() .put("icon", icon.icon) + .put("ohversion", if (icon.isOh2) 2 else 1) .put("state", icon.customState) .toString() putString(key, iconString) @@ -150,11 +157,15 @@ fun SharedPreferences.Editor.putIconResource(key: String, icon: IconResource?): @VisibleForTesting fun String.isNoneIcon() = "(oh:([a-z]+:)?)?none".toRegex().matches(this) -fun String?.toIconResource(): IconResource? { - return if (isNullOrEmpty() || isNoneIcon()) null else IconResource(this, "") +fun String?.toOH1IconResource(): IconResource? { + return if (isNullOrEmpty() || isNoneIcon()) null else IconResource(this, false, "") +} + +fun String?.toOH2IconResource(): IconResource? { + return if (isNullOrEmpty() || isNoneIcon()) null else IconResource(this, true, "") } -internal fun String?.toWidgetIconResource( +internal fun String?.toOH2WidgetIconResource( item: Item?, type: Widget.Type, hasMappings: Boolean, @@ -195,7 +206,7 @@ internal fun String?.toWidgetIconResource( else -> item.state.asString } - return IconResource(this, iconState.orEmpty()) + return IconResource(this, true, iconState.orEmpty()) } enum class IconFormat { diff --git a/mobile/src/main/java/org/openhab/habdroid/model/Item.kt b/mobile/src/main/java/org/openhab/habdroid/model/Item.kt index 5fb651ac3d..9b9f7fa700 100644 --- a/mobile/src/main/java/org/openhab/habdroid/model/Item.kt +++ b/mobile/src/main/java/org/openhab/habdroid/model/Item.kt @@ -19,10 +19,12 @@ import kotlinx.parcelize.Parcelize import org.json.JSONException import org.json.JSONObject import org.openhab.habdroid.R +import org.openhab.habdroid.util.forEach import org.openhab.habdroid.util.map import org.openhab.habdroid.util.mapString import org.openhab.habdroid.util.optFloatOrNull import org.openhab.habdroid.util.optStringOrNull +import org.w3c.dom.Node @Parcelize data class Item internal constructor( @@ -243,6 +245,47 @@ data class Item internal constructor( } } +fun Node.toItem(): Item? { + var name: String? = null + var state: String? = null + var link: String? = null + var type = Item.Type.None + var groupType = Item.Type.None + childNodes.forEach { node -> + when (node.nodeName) { + "type" -> type = node.textContent.toItemType() + "groupType" -> groupType = node.textContent.toItemType() + "name" -> name = node.textContent + "state" -> state = node.textContent + "link" -> link = node.textContent + } + } + + val finalName = name ?: return null + if (state == "Uninitialized" || state == "Undefined") { + state = null + } + + return Item( + name = finalName, + rawLabel = finalName, + category = null, + type = type, + groupType = groupType, + link = link, + readOnly = false, + members = emptyList(), + options = null, + state = state.toParsedState(), + tags = emptyList(), + groupNames = emptyList(), + minimum = null, + maximum = null, + step = null, + linkToMore = null + ) +} + @Throws(JSONException::class) fun JSONObject.toItem(): Item { val name = getString("name") diff --git a/mobile/src/main/java/org/openhab/habdroid/model/LabeledValue.kt b/mobile/src/main/java/org/openhab/habdroid/model/LabeledValue.kt index dccf7070dc..4475051126 100644 --- a/mobile/src/main/java/org/openhab/habdroid/model/LabeledValue.kt +++ b/mobile/src/main/java/org/openhab/habdroid/model/LabeledValue.kt @@ -34,6 +34,6 @@ fun JSONObject.toLabeledValue(valueKey: String, labelKey: String): LabeledValue val value = getString(valueKey) val valueRelease = optStringOrNull("releaseCommand") val label = optString(labelKey, value) - val icon = optStringOrNull("icon")?.toIconResource() + val icon = optStringOrNull("icon")?.toOH2IconResource() return LabeledValue(value, valueRelease, label, icon, optInt("row"), optInt("column")) } diff --git a/mobile/src/main/java/org/openhab/habdroid/model/LinkedPage.kt b/mobile/src/main/java/org/openhab/habdroid/model/LinkedPage.kt index ee4a1a1f88..98ddbe914e 100644 --- a/mobile/src/main/java/org/openhab/habdroid/model/LinkedPage.kt +++ b/mobile/src/main/java/org/openhab/habdroid/model/LinkedPage.kt @@ -16,7 +16,9 @@ package org.openhab.habdroid.model import android.os.Parcelable import kotlinx.parcelize.Parcelize import org.json.JSONObject +import org.openhab.habdroid.util.forEach import org.openhab.habdroid.util.optStringOrNull +import org.w3c.dom.Node /** * This is a class to hold information about openHAB linked page. @@ -41,6 +43,26 @@ data class LinkedPage( } } +fun Node.toLinkedPage(): LinkedPage? { + var id: String? = null + var title: String? = null + var icon: String? = null + var link: String? = null + + childNodes.forEach { node -> + when (node.nodeName) { + "id" -> id = node.textContent + "title" -> title = node.textContent + "icon" -> icon = node.textContent + "link" -> link = node.textContent + } + } + + val finalId = id ?: return null + val finalLink = link ?: return null + return LinkedPage.build(finalId, title, icon.toOH1IconResource(), finalLink) +} + fun JSONObject?.toLinkedPage(): LinkedPage? { if (this == null) { return null @@ -49,7 +71,7 @@ fun JSONObject?.toLinkedPage(): LinkedPage? { return LinkedPage.build( getString("id"), optStringOrNull("title"), - icon.toIconResource(), + icon.toOH2IconResource(), getString("link") ) } diff --git a/mobile/src/main/java/org/openhab/habdroid/model/ServerProperties.kt b/mobile/src/main/java/org/openhab/habdroid/model/ServerProperties.kt index 750ef9173b..74a8b34c34 100644 --- a/mobile/src/main/java/org/openhab/habdroid/model/ServerProperties.kt +++ b/mobile/src/main/java/org/openhab/habdroid/model/ServerProperties.kt @@ -15,6 +15,10 @@ package org.openhab.habdroid.model import android.os.Parcelable import android.util.Log +import java.io.IOException +import java.io.StringReader +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.parsers.ParserConfigurationException import kotlinx.parcelize.Parcelize import okhttp3.Request import org.json.JSONArray @@ -22,9 +26,15 @@ import org.json.JSONException import org.json.JSONObject import org.openhab.habdroid.core.connection.Connection import org.openhab.habdroid.util.HttpClient +import org.xml.sax.InputSource +import org.xml.sax.SAXException @Parcelize data class ServerProperties(val flags: Int, val sitemaps: List) : Parcelable { + fun hasJsonApi(): Boolean { + return flags and SERVER_FLAG_JSON_REST_API != 0 + } + fun hasSseSupport(): Boolean { return flags and SERVER_FLAG_SSE_SUPPORT != 0 } @@ -40,6 +50,7 @@ data class ServerProperties(val flags: Int, val sitemaps: List) : Parce companion object { private val TAG = ServerProperties::class.java.simpleName + const val SERVER_FLAG_JSON_REST_API = 1 shl 0 const val SERVER_FLAG_SSE_SUPPORT = 1 shl 1 const val SERVER_FLAG_ICON_FORMAT_SUPPORT = 1 shl 2 const val SERVER_FLAG_CHART_SCALING_SUPPORT = 1 shl 3 @@ -76,7 +87,12 @@ data class ServerProperties(val flags: Int, val sitemaps: List) : Parce val result = client.get("rest/").asText() try { val resultJson = JSONObject(result.response) - var flags = (SERVER_FLAG_ICON_FORMAT_SUPPORT or SERVER_FLAG_CHART_SCALING_SUPPORT) + // If this succeeded, we're talking to OH2 + var flags = ( + SERVER_FLAG_JSON_REST_API + or SERVER_FLAG_ICON_FORMAT_SUPPORT + or SERVER_FLAG_CHART_SCALING_SUPPORT + ) try { val version = resultJson.getString("version").toInt() Log.i(TAG, "Server has rest api version $version") @@ -114,7 +130,12 @@ data class ServerProperties(val flags: Int, val sitemaps: List) : Parce FlagsSuccess(flags) } catch (e: JSONException) { - FlagsFailure(result.request, 200, e) + if (result.response.startsWith(") : Parce private suspend fun fetchSitemaps(client: HttpClient, flags: Int): PropsResult = try { val result = client.get("rest/sitemaps").asText() - val sitemaps = loadSitemapsFromJson(result.response) + // OH1 returns XML, later versions return JSON + val sitemaps = if (flags and SERVER_FLAG_JSON_REST_API != 0) { + loadSitemapsFromJson(result.response) + } else { + loadSitemapsFromXml(result.response) + } + Log.d(TAG, "Server returned sitemaps: $sitemaps") PropsSuccess(ServerProperties(flags, sitemaps)) } catch (e: HttpClient.HttpException) { PropsFailure(e.request, e.statusCode, e) } + private fun loadSitemapsFromXml(response: String): List { + val dbf = DocumentBuilderFactory.newInstance() + try { + val builder = dbf.newDocumentBuilder() + val sitemapsXml = builder.parse(InputSource(StringReader(response))) + return sitemapsXml.toSitemapList() + } catch (e: ParserConfigurationException) { + Log.e(TAG, "Failed parsing sitemap XML", e) + } catch (e: SAXException) { + Log.e(TAG, "Failed parsing sitemap XML", e) + } catch (e: IOException) { + Log.e(TAG, "Failed parsing sitemap XML", e) + } + return emptyList() + } + private fun loadSitemapsFromJson(response: String): List { return try { val jsonArray = JSONArray(response) diff --git a/mobile/src/main/java/org/openhab/habdroid/model/Sitemap.kt b/mobile/src/main/java/org/openhab/habdroid/model/Sitemap.kt index ab84922e91..5ef69b7ead 100644 --- a/mobile/src/main/java/org/openhab/habdroid/model/Sitemap.kt +++ b/mobile/src/main/java/org/openhab/habdroid/model/Sitemap.kt @@ -19,7 +19,10 @@ import kotlinx.parcelize.Parcelize import org.json.JSONArray import org.json.JSONException import org.json.JSONObject +import org.openhab.habdroid.util.forEach import org.openhab.habdroid.util.optStringOrNull +import org.w3c.dom.Document +import org.w3c.dom.Node @Parcelize data class Sitemap internal constructor( @@ -29,13 +32,43 @@ data class Sitemap internal constructor( val homepageLink: String ) : Parcelable +fun Node.toSitemap(): Sitemap? { + var label: String? = null + var name: String? = null + var icon: String? = null + var homepageLink: String? = null + + childNodes.forEach { node -> + when (node.nodeName) { + "name" -> name = node.textContent + "label" -> label = node.textContent + "icon" -> icon = node.textContent + "homepage" -> + node.childNodes.forEach { pageNode -> + if (pageNode.nodeName == "link") { + homepageLink = pageNode.textContent + } + } + } + } + + val finalName = name ?: return null + val finalLink = homepageLink ?: return null + return Sitemap(finalName, label ?: finalName, icon.toOH1IconResource(), finalLink) +} + fun JSONObject.toSitemap(): Sitemap? { val name = optStringOrNull("name") ?: return null val homepageLink = optJSONObject("homepage")?.optStringOrNull("link") ?: return null val label = optStringOrNull("label") val icon = optStringOrNull("icon") - return Sitemap(name, label ?: name, icon.toIconResource(), homepageLink) + return Sitemap(name, label ?: name, icon.toOH2IconResource(), homepageLink) +} + +fun Document.toSitemapList(): List { + val sitemapNodes = getElementsByTagName("sitemap") + return (0 until sitemapNodes.length).mapNotNull { index -> sitemapNodes.item(index).toSitemap() } } fun JSONArray.toSitemapList(): List { diff --git a/mobile/src/main/java/org/openhab/habdroid/model/Widget.kt b/mobile/src/main/java/org/openhab/habdroid/model/Widget.kt index 7d92173caa..290f72b41c 100644 --- a/mobile/src/main/java/org/openhab/habdroid/model/Widget.kt +++ b/mobile/src/main/java/org/openhab/habdroid/model/Widget.kt @@ -32,6 +32,7 @@ import org.openhab.habdroid.util.optIntOrNull import org.openhab.habdroid.util.optStringOrFallback import org.openhab.habdroid.util.optStringOrNull import org.openhab.habdroid.util.shouldRequestHighResChart +import org.w3c.dom.Node @Parcelize data class Widget( @@ -192,7 +193,7 @@ data class Widget( val iconName = eventPayload.optStringOrFallback("icon", source.icon?.icon) val staticIcon = source.icon?.customState?.isEmpty() == true val hasMappings = source.mappings.isNotEmpty() - val icon = iconName.toWidgetIconResource(item, source.type, hasMappings, !staticIcon) + val icon = iconName.toOH2WidgetIconResource(item, source.type, hasMappings, !staticIcon) return Widget( id = source.id, parentId = source.parentId, @@ -273,6 +274,114 @@ fun String?.toLabelSource(): Widget.LabelSource = when (this) { else -> Widget.LabelSource.Unknown } +// This function is only used on openHAB versions with XML API, which is openHAB 1.x +fun Node.collectWidgets(parent: Widget?): List { + var item: Item? = null + var linkedPage: LinkedPage? = null + var id: String? = null + var label: String? = null + var icon: String? = null + var url: String? = null + var period = "" + var service = "" + var encoding: String? = null + var iconColor: String? = null + var labelColor: String? = null + var valueColor: String? = null + var switchSupport = false + var type = Widget.Type.Unknown + var minValue = 0f + var maxValue = 100f + var step = 1f + var refresh = 0 + var height = 0 + val mappings = ArrayList() + val childWidgetNodes = ArrayList() + + childNodes.forEach { node -> + when (node.nodeName) { + "item" -> item = node.toItem() + "linkedPage" -> linkedPage = node.toLinkedPage() + "widget" -> childWidgetNodes.add(node) + "type" -> type = node.textContent.toWidgetType() + "widgetId" -> id = node.textContent + "label" -> label = node.textContent + "icon" -> icon = node.textContent + "url" -> url = node.textContent + "minValue" -> minValue = node.textContent.toFloat() + "maxValue" -> maxValue = node.textContent.toFloat() + "step" -> step = node.textContent.toFloat() + "refresh" -> refresh = node.textContent.toInt() + "period" -> period = node.textContent + "service" -> service = node.textContent + "height" -> height = node.textContent.toInt() + "iconcolor" -> iconColor = node.textContent + "valuecolor" -> valueColor = node.textContent + "labelcolor" -> labelColor = node.textContent + "encoding" -> encoding = node.textContent + "switchSupport" -> switchSupport = node.textContent?.toBoolean() == true + "mapping" -> { + var mappingCommand = "" + var mappingLabel = "" + node.childNodes.forEach { childNode -> + when (childNode.nodeName) { + "command" -> mappingCommand = childNode.textContent + "label" -> mappingLabel = childNode.textContent + } + } + mappings.add(LabeledValue(mappingCommand, null, mappingLabel, null, 0, 0)) + } + else -> {} + } + } + + val finalId = id ?: return emptyList() + + val widget = Widget( + id = finalId, + parentId = parent?.id, + rawLabel = label.orEmpty(), + labelSource = Widget.LabelSource.Unknown, + icon = icon.toOH1IconResource(), + state = item?.state, + type = type, + url = url, + item = item, + linkedPage = linkedPage, + mappings = mappings, + encoding = encoding, + iconColor = iconColor, + labelColor = labelColor, + valueColor = valueColor, + refresh = Widget.sanitizeRefreshRate(refresh), + rawMinValue = minValue, + rawMaxValue = maxValue, + rawStep = step, + // row, column, command, releaseCommand, stateless were added in openHAB 4.2 + // so no support for openHAB 1 required. + row = null, + column = null, + command = null, + releaseCommand = null, + stateless = null, + period = Widget.sanitizePeriod(period), + service = service, + legend = null, + // forceAsItem was added in openHAB 3, so no support for openHAB 1 required. + forceAsItem = false, + yAxisDecimalPattern = null, + switchSupport = switchSupport, + releaseOnly = null, + height = height, + // inputHint was added in openHAB 4, so no support for openHAB 1 required. + rawInputHint = null, + visibility = true + ) + val childWidgets = childWidgetNodes.map { node -> node.collectWidgets(widget) }.flatten() + + return listOf(widget) + childWidgets +} + @Throws(JSONException::class) fun JSONObject.collectWidgets(parent: Widget?): List { val mappings = if (has("mappings")) { @@ -291,7 +400,7 @@ fun JSONObject.collectWidgets(parent: Widget?): List { parentId = parent?.id, rawLabel = optString("label", ""), labelSource = optStringOrNull("labelSource").toLabelSource(), - icon = icon.toWidgetIconResource(item, type, mappings.isNotEmpty(), !staticIcon), + icon = icon.toOH2WidgetIconResource(item, type, mappings.isNotEmpty(), !staticIcon), state = Widget.determineWidgetState(optStringOrNull("state"), item), type = type, url = optStringOrNull("url"), diff --git a/mobile/src/main/java/org/openhab/habdroid/model/WidgetDataSource.kt b/mobile/src/main/java/org/openhab/habdroid/model/WidgetDataSource.kt index 398f965aa9..4c7e6a2b47 100644 --- a/mobile/src/main/java/org/openhab/habdroid/model/WidgetDataSource.kt +++ b/mobile/src/main/java/org/openhab/habdroid/model/WidgetDataSource.kt @@ -18,13 +18,14 @@ import org.json.JSONException import org.json.JSONObject import org.openhab.habdroid.util.forEach import org.openhab.habdroid.util.optStringOrNull +import org.w3c.dom.Node /** * This class provides datasource for openHAB widgets from sitemap page. - * It uses a sitemap page JSON document to create a list of widgets + * It uses a sitemap page XML document to create a list of widgets */ -class WidgetDataSource { +class WidgetDataSource() { private val allWidgets = ArrayList() var title: String = "" private set @@ -49,6 +50,22 @@ class WidgetDataSource { } } + fun setSourceNode(rootNode: Node?) { + if (rootNode == null) { + return + } + rootNode.childNodes.forEach { node -> + when (node.nodeName) { + "widget" -> allWidgets.addAll(node.collectWidgets(null)) + "title" -> title = node.textContent.orEmpty() + "id" -> id = node.textContent + "icon" -> icon = node.textContent + "link" -> link = node.textContent + else -> { } + } + } + } + fun setSourceJson(jsonObject: JSONObject) { if (!jsonObject.has("widgets")) { return diff --git a/mobile/src/main/java/org/openhab/habdroid/ui/ItemPickerAdapter.kt b/mobile/src/main/java/org/openhab/habdroid/ui/ItemPickerAdapter.kt index f2828218e3..e425bb662d 100644 --- a/mobile/src/main/java/org/openhab/habdroid/ui/ItemPickerAdapter.kt +++ b/mobile/src/main/java/org/openhab/habdroid/ui/ItemPickerAdapter.kt @@ -23,7 +23,7 @@ import java.util.Locale import org.openhab.habdroid.R import org.openhab.habdroid.core.connection.ConnectionFactory import org.openhab.habdroid.model.Item -import org.openhab.habdroid.model.toIconResource +import org.openhab.habdroid.model.toOH2IconResource import org.openhab.habdroid.ui.widget.WidgetImageView import org.openhab.habdroid.util.determineDataUsagePolicy @@ -117,7 +117,7 @@ class ItemPickerAdapter(context: Context, private val itemClickListener: ItemCli val context = itemView.context val connection = ConnectionFactory.primaryUsableConnection?.connection - val icon = item.category.toIconResource() + val icon = item.category.toOH2IconResource() if (icon != null && connection != null) { iconView.setImageUrl( connection, diff --git a/mobile/src/main/java/org/openhab/habdroid/ui/activity/PageConnectionHolderFragment.kt b/mobile/src/main/java/org/openhab/habdroid/ui/activity/PageConnectionHolderFragment.kt index 4a9df98bc2..4a2ba48ef5 100644 --- a/mobile/src/main/java/org/openhab/habdroid/ui/activity/PageConnectionHolderFragment.kt +++ b/mobile/src/main/java/org/openhab/habdroid/ui/activity/PageConnectionHolderFragment.kt @@ -17,6 +17,11 @@ import android.os.Bundle import android.util.Log import androidx.core.net.toUri import androidx.fragment.app.Fragment +import java.io.IOException +import java.io.StringReader +import java.util.HashMap +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.parsers.ParserConfigurationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -33,6 +38,8 @@ import org.openhab.habdroid.model.WidgetDataSource import org.openhab.habdroid.ui.WidgetListFragment import org.openhab.habdroid.util.HttpClient import org.openhab.habdroid.util.appendQueryParameter +import org.xml.sax.InputSource +import org.xml.sax.SAXException /** * Fragment that manages connections for active instances of @@ -259,6 +266,9 @@ class PageConnectionHolderFragment : Fragment(), CoroutineScope { Log.d(TAG, "Loading data for $url, long polling $longPolling") val headers = HashMap() + if (callback.serverProperties?.hasJsonApi() == false) { + headers["Accept"] = "application/xml" + } if (longPolling) { headers["X-Atmosphere-Transport"] = "long-polling" @@ -307,7 +317,11 @@ class PageConnectionHolderFragment : Fragment(), CoroutineScope { } val dataSource = WidgetDataSource() - val hasUpdate = parseResponseJson(dataSource, response) + val hasUpdate = if (callback.serverProperties?.hasJsonApi() == true) { + parseResponseJson(dataSource, response) + } else { + parseResponseXml(dataSource, response) + } if (hasUpdate) { // Remove frame widgets with no label text @@ -326,6 +340,35 @@ class PageConnectionHolderFragment : Fragment(), CoroutineScope { load() } + private fun parseResponseXml(dataSource: WidgetDataSource, response: String): Boolean { + val dbf = DocumentBuilderFactory.newInstance() + try { + val builder = dbf.newDocumentBuilder() + val document = builder.parse(InputSource(StringReader(response))) + if (document == null) { + Log.d(TAG, "Got empty XML document for $url") + longPolling = false + return false + } + val rootNode = document.firstChild + dataSource.setSourceNode(rootNode) + longPolling = true + return true + } catch (e: ParserConfigurationException) { + Log.d(TAG, "Parsing data for $url failed", e) + longPolling = false + return false + } catch (e: SAXException) { + Log.d(TAG, "Parsing data for $url failed", e) + longPolling = false + return false + } catch (e: IOException) { + Log.d(TAG, "Parsing data for $url failed", e) + longPolling = false + return false + } + } + private fun parseResponseJson(dataSource: WidgetDataSource, response: String): Boolean { try { val pageJson = JSONObject(response) @@ -416,7 +459,7 @@ class PageConnectionHolderFragment : Fragment(), CoroutineScope { } } - private class EventHelper( + private class EventHelper internal constructor( private val scope: CoroutineScope, private val client: HttpClient, private val sitemap: String, diff --git a/mobile/src/main/java/org/openhab/habdroid/ui/preference/fragments/WidgetSettingsFragment.kt b/mobile/src/main/java/org/openhab/habdroid/ui/preference/fragments/WidgetSettingsFragment.kt index e83a1d65a8..7bef3c9206 100644 --- a/mobile/src/main/java/org/openhab/habdroid/ui/preference/fragments/WidgetSettingsFragment.kt +++ b/mobile/src/main/java/org/openhab/habdroid/ui/preference/fragments/WidgetSettingsFragment.kt @@ -36,7 +36,7 @@ import androidx.preference.SwitchPreferenceCompat import com.google.android.material.snackbar.Snackbar import org.openhab.habdroid.R import org.openhab.habdroid.background.BackgroundTasksManager -import org.openhab.habdroid.model.toIconResource +import org.openhab.habdroid.model.toOH2IconResource import org.openhab.habdroid.ui.BasicItemPickerActivity import org.openhab.habdroid.ui.homescreenwidget.ItemUpdateWidget import org.openhab.habdroid.ui.preference.PreferencesActivity @@ -160,7 +160,7 @@ class WidgetSettingsFragment : label = itemAndStatePref.label.orEmpty(), widgetLabel = namePref.text.orEmpty(), mappedState = itemAndStatePref.mappedState.orEmpty(), - icon = itemAndStatePref.icon.toIconResource(), + icon = itemAndStatePref.icon.toOH2IconResource(), showState = showStatePref.isChecked ) } diff --git a/mobile/src/main/java/org/openhab/habdroid/util/ExtensionFuncs.kt b/mobile/src/main/java/org/openhab/habdroid/util/ExtensionFuncs.kt index 52c8d52f63..9b45b9f1ba 100644 --- a/mobile/src/main/java/org/openhab/habdroid/util/ExtensionFuncs.kt +++ b/mobile/src/main/java/org/openhab/habdroid/util/ExtensionFuncs.kt @@ -85,6 +85,8 @@ import org.openhab.habdroid.model.ServerConfiguration import org.openhab.habdroid.model.ServerPath import org.openhab.habdroid.model.ServerProperties import org.openhab.habdroid.util.Util.TAG +import org.w3c.dom.Node +import org.w3c.dom.NodeList fun Throwable?.hasCause(cause: Class): Boolean { var error = this @@ -271,6 +273,8 @@ fun InputStream.svgToBitmap( } } +fun NodeList.forEach(action: (Node) -> Unit) = (0 until length).forEach { index -> action(item(index)) } + fun JSONArray.forEach(action: (JSONObject) -> Unit) = (0 until length()).forEach { index -> action(getJSONObject(index)) } diff --git a/mobile/src/main/java/org/openhab/habdroid/util/ItemClient.kt b/mobile/src/main/java/org/openhab/habdroid/util/ItemClient.kt index db16b0fe8d..763d907a46 100644 --- a/mobile/src/main/java/org/openhab/habdroid/util/ItemClient.kt +++ b/mobile/src/main/java/org/openhab/habdroid/util/ItemClient.kt @@ -14,6 +14,10 @@ package org.openhab.habdroid.util import android.util.Log +import java.io.IOException +import java.io.StringReader +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.parsers.ParserConfigurationException import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay @@ -24,6 +28,8 @@ import org.json.JSONObject import org.openhab.habdroid.core.connection.Connection import org.openhab.habdroid.model.Item import org.openhab.habdroid.model.toItem +import org.xml.sax.InputSource +import org.xml.sax.SAXException object ItemClient { private val TAG = ItemClient::class.java.simpleName @@ -31,24 +37,73 @@ object ItemClient { @Throws(HttpClient.HttpException::class) suspend fun loadItems(connection: Connection): List? { val response = connection.httpClient.get("rest/items") + val contentType = response.response.contentType() val content = response.asText().response - return try { - JSONArray(content).map { it.toItem() } - } catch (e: JSONException) { - Log.e(TAG, "Failed parsing JSON result for items", e) - null + + if (contentType?.type == "application" && contentType.subtype == "json") { + // JSON + return try { + JSONArray(content).map { it.toItem() } + } catch (e: JSONException) { + Log.e(TAG, "Failed parsing JSON result for items", e) + null + } + } else { + // XML + return try { + val dbf = DocumentBuilderFactory.newInstance() + val builder = dbf.newDocumentBuilder() + val document = builder.parse(InputSource(StringReader(content))) + val nodes = document.childNodes + val items = ArrayList(nodes.length) + for (i in 0 until nodes.length) { + nodes.item(i).toItem()?.let { items.add(it) } + } + items + } catch (e: ParserConfigurationException) { + Log.e(TAG, "Failed parsing XML result for items", e) + null + } catch (e: SAXException) { + Log.e(TAG, "Failed parsing XML result for items", e) + null + } catch (e: IOException) { + Log.e(TAG, "Failed parsing XML result for items", e) + null + } } } @Throws(HttpClient.HttpException::class) suspend fun loadItem(connection: Connection, itemName: String): Item? { val response = connection.httpClient.get("rest/items/$itemName") + val contentType = response.response.contentType() val content = response.asText().response - return try { - JSONObject(content).toItem() - } catch (e: JSONException) { - Log.e(TAG, "Failed parsing JSON result for item $itemName", e) - null + + if (contentType?.type == "application" && contentType.subtype == "json") { + // JSON + return try { + JSONObject(content).toItem() + } catch (e: JSONException) { + Log.e(TAG, "Failed parsing JSON result for item $itemName", e) + null + } + } else { + // XML + return try { + val dbf = DocumentBuilderFactory.newInstance() + val builder = dbf.newDocumentBuilder() + val document = builder.parse(InputSource(StringReader(content))) + document.toItem() + } catch (e: ParserConfigurationException) { + Log.e(TAG, "Failed parsing XML result for item $itemName", e) + null + } catch (e: SAXException) { + Log.e(TAG, "Failed parsing XML result for item $itemName", e) + null + } catch (e: IOException) { + Log.e(TAG, "Failed parsing XML result for item $itemName", e) + null + } } } diff --git a/mobile/src/test/java/org/openhab/habdroid/model/IconResourceTest.kt b/mobile/src/test/java/org/openhab/habdroid/model/IconResourceTest.kt index 4e65e0115e..ca0f8d636a 100644 --- a/mobile/src/test/java/org/openhab/habdroid/model/IconResourceTest.kt +++ b/mobile/src/test/java/org/openhab/habdroid/model/IconResourceTest.kt @@ -88,7 +88,7 @@ class IconResourceTest { assertEquals( "$icon icon failed!", url, - IconResource(icon, "").toUrl(false, IconFormat.Png, 64) + IconResource(icon, true, "").toUrl(false, IconFormat.Png, 64) ) } } diff --git a/mobile/src/test/java/org/openhab/habdroid/model/ItemTest.kt b/mobile/src/test/java/org/openhab/habdroid/model/ItemTest.kt index f82f58a8e7..9e0c9d3cac 100644 --- a/mobile/src/test/java/org/openhab/habdroid/model/ItemTest.kt +++ b/mobile/src/test/java/org/openhab/habdroid/model/ItemTest.kt @@ -107,7 +107,7 @@ class ItemTest { @Test fun getCommandOptions() { val sut = itemWithCommandOptions.toItem() - assertEquals(LabeledValue("1", null, "One", "switch".toIconResource(), 1, 2), sut.options!!.component1()) + assertEquals(LabeledValue("1", null, "One", "switch".toOH2IconResource(), 1, 2), sut.options!!.component1()) assertEquals(LabeledValue("2", null, "Two", null, 0, 0), sut.options!!.component2()) } diff --git a/mobile/src/test/java/org/openhab/habdroid/model/WidgetTest.kt b/mobile/src/test/java/org/openhab/habdroid/model/WidgetTest.kt index 9ea2e51e47..2ff256665f 100644 --- a/mobile/src/test/java/org/openhab/habdroid/model/WidgetTest.kt +++ b/mobile/src/test/java/org/openhab/habdroid/model/WidgetTest.kt @@ -13,15 +13,20 @@ package org.openhab.habdroid.model +import java.io.StringReader import java.security.InvalidParameterException +import javax.xml.parsers.DocumentBuilderFactory import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test +import org.w3c.dom.Node +import org.xml.sax.InputSource class WidgetTest { + private lateinit var sutXml: List private lateinit var sut1: List private lateinit var sut2: List private lateinit var sut3: List @@ -30,6 +35,7 @@ class WidgetTest { @Before @Throws(Exception::class) fun parse_createsWidget() { + sutXml = createXmlNode().collectWidgets(null) sut1 = createJsonObject(1).collectWidgets(null) sut2 = createJsonObject(2).collectWidgets(null) sut3 = createJsonObject(3).collectWidgets(null) @@ -37,11 +43,18 @@ class WidgetTest { @Test fun testCountInstances() { + assertEquals(sutXml.size, 2) assertEquals(sut1.size, 2) assertEquals(sut2.size, 1) assertEquals(sut3.size, 5) } + @Test + fun getIconPath_iconExists_returnIconUrlFromImages() { + assertEquals("images/groupicon.png", sutXml[0].icon?.toUrl(false, IconFormat.Png, 64)) + assertEquals("images/groupicon.png", sutXml[0].icon?.toUrl(true, IconFormat.Png, 64)) + } + @Test fun testGetIconPath() { assertEquals( @@ -225,6 +238,56 @@ class WidgetTest { assertEquals(null, sut2[0].encoding) } + @Throws(Exception::class) + private fun createXmlNode(): Node { + val xml = + """ + + demo + Group + + groupicon + http://localhost/url + 0.0 + 10.0 + 1 + 10 + D + D + 10 + white + white + white + + + ON + + + + GroupItem + group1 + Undefined + http://localhost/rest/items/group1 + + + 0001 + LinkedPage + linkedpageicon + http://localhost/rest/sitemaps/demo/0001 + false + + + demo11 + Switch + " + + """.trimIndent() + val dbf = DocumentBuilderFactory.newInstance() + val builder = dbf.newDocumentBuilder() + val document = builder.parse(InputSource(StringReader(xml))) + return document.firstChild + } + /** * @param id get different json objects depending on the id * 1: All values are set diff --git a/mobile/src/test/java/org/openhab/habdroid/util/UtilTest.kt b/mobile/src/test/java/org/openhab/habdroid/util/UtilTest.kt index 783d465a4a..c3e8edfb32 100644 --- a/mobile/src/test/java/org/openhab/habdroid/util/UtilTest.kt +++ b/mobile/src/test/java/org/openhab/habdroid/util/UtilTest.kt @@ -13,8 +13,12 @@ package org.openhab.habdroid.util +import java.io.IOException +import java.io.StringReader import java.security.cert.CertPathValidatorException import javax.net.ssl.SSLException +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.parsers.ParserConfigurationException import okhttp3.HttpUrl.Companion.toHttpUrl import org.json.JSONArray import org.json.JSONException @@ -25,8 +29,97 @@ import org.junit.Assert.assertTrue import org.junit.Test import org.openhab.habdroid.model.sortedWithDefaultName import org.openhab.habdroid.model.toSitemapList +import org.w3c.dom.Document +import org.xml.sax.InputSource +import org.xml.sax.SAXException class UtilTest { + private val sitemapOH1Document: Document + @Throws(ParserConfigurationException::class, IOException::class, SAXException::class) + get() { + val xml = + """ + + + + default + + http://myopenhab/rest/sitemaps/default + + http://myopenhab/rest/sitemaps/default/default + false + + + + heating + + http://myopenhab/rest/sitemaps/heating + + http://myopenhab/rest/sitemaps/heating/heating + false + + + + lighting + + http://myopenhab/rest/sitemaps/lighting + + http://myopenhab/rest/sitemaps/lighting/lighting + false + + + + heatpump + + http://myopenhab/rest/sitemaps/heatpump + + http://myopenhab/rest/sitemaps/heatpump/heatpump + false + + + + schedule + + http://myopenhab/rest/sitemaps/schedule + + http://myopenhab/rest/sitemaps/schedule/schedule + false + + + + outside + http://myopenhab/rest/sitemaps/outside + + http://myopenhab/rest/sitemaps/outside/outside + false + + + + garden + + http://myopenhab/rest/sitemaps/garden + + http://myopenhab/rest/sitemaps/garden/garden + false + + + + scenes + + http://myopenhab/rest/sitemaps/scenes + + http://myopenhab/rest/sitemaps/scenes/scenes + false + + + + """.trimIndent() + + val dbf = DocumentBuilderFactory.newInstance() + val builder = dbf.newDocumentBuilder() + return builder.parse(InputSource(StringReader(xml))) + } + @Test fun normalizeUrl() { assertEquals("http://localhost/", "http://localhost/".toNormalizedUrl()) @@ -62,7 +155,23 @@ class UtilTest { } @Test - fun parseSitemapListWithId1() { + fun parseOH1SitemapList() { + val sitemapList = sitemapOH1Document.toSitemapList() + assertFalse(sitemapList.isEmpty()) + + assertEquals("i AM DEfault", sitemapList[0].label) + assertEquals("Heating", sitemapList[1].label) + assertEquals("Lighting", sitemapList[2].label) + assertEquals("Heatpump", sitemapList[3].label) + assertEquals("Schedule", sitemapList[4].label) + assertEquals("outside", sitemapList[5].label) + assertEquals("Garden", sitemapList[6].label) + assertEquals("Scenes", sitemapList[7].label) + assertEquals(8, sitemapList.size) + } + + @Test + fun parseOH2SitemapListWithId1() { val sitemapList = createJsonArray(1).toSitemapList() assertFalse(sitemapList.isEmpty()) @@ -71,7 +180,7 @@ class UtilTest { } @Test - fun parseSitemapListWithId2() { + fun parseOH2SitemapListWithId2() { val sitemapList = createJsonArray(2).toSitemapList() assertFalse(sitemapList.isEmpty()) @@ -82,7 +191,7 @@ class UtilTest { } @Test - fun parseSitemapListWithId3() { + fun parseOH2SitemapListWithId3() { val sitemapList = createJsonArray(3).toSitemapList() assertFalse(sitemapList.isEmpty()) @@ -91,20 +200,31 @@ class UtilTest { } @Test + @Throws(IOException::class, SAXException::class, ParserConfigurationException::class) fun testSortSitemapList() { - val sitemapList = createJsonArray(2).toSitemapList() + val sitemapList = sitemapOH1Document.toSitemapList() val sorted1 = sitemapList.sortedWithDefaultName("") // Should be sorted - assertEquals("HOME", sorted1[0].label) - assertEquals("Main Menu", sorted1[1].label) - assertEquals("test", sorted1[2].label) - - val sorted2 = sitemapList.sortedWithDefaultName("demo") - // Should be sorted, but "Main Menu" should be the first one - assertEquals("Main Menu", sorted2[0].label) - assertEquals("HOME", sorted2[1].label) - assertEquals("test", sorted2[2].label) + assertEquals("Garden", sorted1[0].label) + assertEquals("Heating", sorted1[1].label) + assertEquals("Heatpump", sorted1[2].label) + assertEquals("i AM DEfault", sorted1[3].label) + assertEquals("Lighting", sorted1[4].label) + assertEquals("outside", sorted1[5].label) + assertEquals("Scenes", sorted1[6].label) + assertEquals("Schedule", sorted1[7].label) + + val sorted2 = sitemapList.sortedWithDefaultName("schedule") + // Should be sorted, but "Schedule" should be the first one + assertEquals("Schedule", sorted2[0].label) + assertEquals("Garden", sorted2[1].label) + assertEquals("Heating", sorted2[2].label) + assertEquals("Heatpump", sorted2[3].label) + assertEquals("i AM DEfault", sorted2[4].label) + assertEquals("Lighting", sorted2[5].label) + assertEquals("outside", sorted2[6].label) + assertEquals("Scenes", sorted2[7].label) } @Test